From 21f7ed9f75bb1d3966eeaeca620470c4f7dc893d Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 4 Mar 2024 13:52:16 +0800 Subject: [PATCH 001/213] =?UTF-8?q?[modify]=E9=80=82=E9=85=8Dcompose?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC=EF=BC=8C=E6=9B=BF=E6=8D=A2rememberR?= =?UTF-8?q?ipple=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- component/src/main/java/com/lalilu/component/card/SongCard.kt | 4 +++- .../java/com/lalilu/component/extension/ComposeModifierExt.kt | 3 ++- .../com/lalilu/component/settings/SettingProgressSeekBar.kt | 3 ++- .../java/com/lalilu/component/settings/SettingStateSeekBar.kt | 3 ++- .../java/com/lalilu/component/settings/SettingSwitcher.kt | 3 ++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/card/SongCard.kt b/component/src/main/java/com/lalilu/component/card/SongCard.kt index 24c70a736..8d987c13f 100644 --- a/component/src/main/java/com/lalilu/component/card/SongCard.kt +++ b/component/src/main/java/com/lalilu/component/card/SongCard.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -27,6 +28,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.ProvideTextStyle import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.ripple.createRippleModifierNode import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -129,7 +131,7 @@ fun SongCard( .background(color = bgColor) .combinedClickable( interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = onClick, onLongClick = onLongClick, onDoubleClick = onDoubleClick diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt index 4b3e14cf3..9fc5b6884 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt @@ -2,6 +2,7 @@ package com.lalilu.component.extension import android.annotation.SuppressLint import androidx.compose.foundation.Indication +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.indication @@ -45,7 +46,7 @@ fun Modifier.longClickable( .semantics { role = Role.Button } .indication( interactionSource = interactionSource, - indication = indication ?: rememberRipple() + indication = indication ?: LocalIndication.current ) .hoverable(interactionSource, true) .pointerInput(Unit) { diff --git a/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt b/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt index 4ff51613a..e0337f204 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt @@ -1,6 +1,7 @@ package com.lalilu.component.settings import androidx.annotation.StringRes +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -55,7 +56,7 @@ fun SettingProgressSeekBar( .fillMaxWidth() .clickable( interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = { } ) .padding(horizontal = 20.dp, vertical = 10.dp), diff --git a/component/src/main/java/com/lalilu/component/settings/SettingStateSeekBar.kt b/component/src/main/java/com/lalilu/component/settings/SettingStateSeekBar.kt index 834533ec0..953aac9b9 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingStateSeekBar.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingStateSeekBar.kt @@ -1,6 +1,7 @@ package com.lalilu.component.settings import androidx.annotation.StringRes +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -73,7 +74,7 @@ fun SettingStateSeekBar( .fillMaxWidth() .clickable( interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = { } ) .padding(paddingValues), diff --git a/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt b/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt index a13f96410..680347f26 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt @@ -2,6 +2,7 @@ package com.lalilu.component.settings import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.MarqueeSpacing import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable @@ -127,7 +128,7 @@ fun SettingSwitcher( .enableFor(enable = { enableContentClickable }) { clickable( interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = onContentStartClick ) } From b957d46f058b0fa528a9b909da9ea9fde92bcbca Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 4 Mar 2024 13:54:04 +0800 Subject: [PATCH 002/213] =?UTF-8?q?[modify]=E5=8F=96=E6=B6=88SideSheet?= =?UTF-8?q?=E7=BB=93=E6=9E=84=EF=BC=8C=E6=8B=86=E5=88=86SheetNavigator?= =?UTF-8?q?=E4=B8=BASheetController=E5=92=8CEnhanceNavigator=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=AF=BC=E8=88=AA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tNavigator.kt => BottomSheetController.kt} | 139 ++++----------- .../com/lalilu/component/base/CustomScreen.kt | 8 +- .../component/base/SideSheetNavigator.kt | 166 ------------------ .../component/navigation/GlobalNavigator.kt | 8 +- .../component/navigation/SheetController.kt | 143 +++++++++++++++ .../component/navigation/SheetNavigator.kt | 28 --- 6 files changed, 186 insertions(+), 306 deletions(-) rename component/src/main/java/com/lalilu/component/base/{BottomSheetNavigator.kt => BottomSheetController.kt} (56%) delete mode 100644 component/src/main/java/com/lalilu/component/base/SideSheetNavigator.kt create mode 100644 component/src/main/java/com/lalilu/component/navigation/SheetController.kt delete mode 100644 component/src/main/java/com/lalilu/component/navigation/SheetNavigator.kt diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt similarity index 56% rename from component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt rename to component/src/main/java/com/lalilu/component/base/BottomSheetController.kt index 914951291..93b521a0c 100644 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt @@ -3,21 +3,16 @@ package com.lalilu.component.base import android.annotation.SuppressLint import androidx.activity.compose.BackHandler import androidx.compose.animation.core.AnimationSpec -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetDefaults import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.SwipeableDefaults import androidx.compose.material.contentColorFor import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -25,20 +20,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.stack.Stack import cafe.adriel.voyager.navigator.CurrentScreen import cafe.adriel.voyager.navigator.Navigator -import com.lalilu.component.extension.rememberBottomSheetNestedScrollInterceptor -import com.lalilu.component.navigation.LocalSheetNavigator -import com.lalilu.component.navigation.SheetNavigator +import com.lalilu.component.navigation.EnhanceNavigator +import com.lalilu.component.navigation.LocalSheetController +import com.lalilu.component.navigation.SheetController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -typealias BottomSheetNavigatorContent = @Composable (bottomSheetNavigator: BottomSheetNavigator) -> Unit +typealias BottomSheetNavigatorContent = @Composable (bottomSheetNavigator: BottomSheetController) -> Unit @SuppressLint("UnnecessaryComposedModifier") @ExperimentalMaterialApi @@ -47,7 +40,6 @@ fun BottomSheetNavigatorLayout( modifier: Modifier = Modifier, navigator: Navigator, hideOnBackPress: Boolean = true, - resetOnHide: Boolean = false, visibleWhenShow: Boolean = false, defaultIsVisible: Boolean = false, scrimColor: Color = ModalBottomSheetDefaults.scrimColor, @@ -57,7 +49,7 @@ fun BottomSheetNavigatorLayout( sheetContentColor: Color = contentColorFor(sheetBackgroundColor), sheetGesturesEnabled: Boolean = true, skipHalfExpanded: Boolean = true, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, sheetContent: BottomSheetNavigatorContent = { CurrentScreen() }, content: BottomSheetNavigatorContent ) { @@ -68,25 +60,16 @@ fun BottomSheetNavigatorLayout( animationSpec = animationSpec ) - // 只能重组时取值后判断状态进行更新,且需要避免该参数变化触发不必要的重组 - LaunchedEffect(Unit) { - when { - defaultIsVisible && sheetState.currentValue == ModalBottomSheetValue.Hidden -> sheetState.show() - !defaultIsVisible && sheetState.currentValue == ModalBottomSheetValue.Expanded -> sheetState.hide() - } - } - val bottomSheetNavigator = remember { - BottomSheetNavigator( + BottomSheetController( visibleWhenShow = visibleWhenShow, - resetOnHide = resetOnHide, navigator = navigator, sheetState = sheetState, coroutineScope = coroutineScope ) } - CompositionLocalProvider(LocalSheetNavigator provides bottomSheetNavigator) { + CompositionLocalProvider(LocalSheetController provides bottomSheetNavigator) { ModalBottomSheetLayout( modifier = modifier, scrimColor = scrimColor, @@ -97,11 +80,10 @@ fun BottomSheetNavigatorLayout( sheetContentColor = sheetContentColor, sheetGesturesEnabled = sheetGesturesEnabled, sheetContent = { - BottomSheetNavigatorBackHandler(bottomSheetNavigator, hideOnBackPress) - Box( - modifier = Modifier.nestedScroll(rememberBottomSheetNestedScrollInterceptor()), - content = { sheetContent(bottomSheetNavigator) } - ) + BackHandler(enabled = bottomSheetNavigator.isVisible && hideOnBackPress) { + bottomSheetNavigator.back() + } + sheetContent(bottomSheetNavigator) }, content = { content(bottomSheetNavigator) } ) @@ -109,14 +91,12 @@ fun BottomSheetNavigatorLayout( } @OptIn(ExperimentalMaterialApi::class) -class BottomSheetNavigator internal constructor( +class BottomSheetController internal constructor( private val visibleWhenShow: Boolean = false, - private val resetOnHide: Boolean = false, private val navigator: Navigator, - private val defaultScreen: Screen = HiddenBottomSheetScreen, private val sheetState: ModalBottomSheetState, private val coroutineScope: CoroutineScope -) : Stack by navigator, SheetNavigator { +) : Stack by navigator, SheetController, EnhanceNavigator { override val isVisible: Boolean by derivedStateOf { if (sheetState.currentValue == sheetState.targetValue && sheetState.progress == 1f) { @@ -140,88 +120,39 @@ class BottomSheetNavigator internal constructor( } } - override fun back(enable: Boolean) { - // 若当前只剩一个页面,则不清空元素了 - if (navigator.items.size <= 1) { - hide() - return - } + override fun getCurrentScreen(): Screen? { + return lastItemOrNull + } - if (navigator.pop().not() && enable) { + override fun preBack(currentScreen: Screen?): Boolean { + // 若当前只剩一个页面,则不清空元素了 + if (items.size <= 1) { hide() + return false } + return true } - override fun show(screen: Screen?) { - if (screen == null) { - coroutineScope.launch { sheetState.show() } - return - } - - when { - screen is TabScreen -> { - if (items.size <= 1) { - push(screen) - return - } - - val firstItem = items.firstOrNull()?.let { listOf(it) } ?: emptyList() - replaceAll(firstItem) - - if (screen != firstItem) { - push(screen) - } - } - - lastItemOrNull == null || lastItemOrNull::class.java != screen::class.java -> { - push(screen) - } - - else -> { - replace(screen) - } - } + override fun postBack(fromScreen: Screen?) { + hide() + } - if (!isVisible) { - coroutineScope.launch { - sheetState.show() - } - } + override fun postJump(fromScreen: Screen?, toScreen: Screen): Boolean { + show() + return true } override fun hide() { - coroutineScope.launch { - if (isVisible) { - sheetState.hide() - } else if (resetOnHide && sheetState.targetValue == ModalBottomSheetValue.Hidden) { - // Swipe down - sheetState is already hidden here so `isVisible` is false - replaceAll(defaultScreen) - } + if (isVisible) { + coroutineScope.launch { sheetState.hide() } } } - override fun getNavigator(): Navigator { - return navigator - } -} - -object HiddenBottomSheetScreen : Screen { - - @Composable - override fun Content() { - Spacer(modifier = Modifier.height(1.dp)) - } -} - - -@ExperimentalMaterialApi -@Composable -fun BottomSheetNavigatorBackHandler( - navigator: SheetNavigator, - hideOnBackPress: Boolean -) { - BackHandler(enabled = navigator.isVisible) { - navigator.back(hideOnBackPress) + override fun show() { + if (!isVisible) { + coroutineScope.launch { sheetState.show() } + } } -} + override fun getNavigator(): Navigator = navigator +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt index ca352d9e4..3aae58ede 100644 --- a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt +++ b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt @@ -125,7 +125,7 @@ abstract class DynamicScreen : CustomScreen { ) { LaunchedEffect(isVisible.value) { if (isVisible.value) { - extraContentStack = extraContentStack.plus(ScreenBarComponent( + extraContentStack += ScreenBarComponent( state = isVisible, showMask = showMask(), showBackground = showBackground(), @@ -139,7 +139,7 @@ abstract class DynamicScreen : CustomScreen { } } } - )) + ) } else { val key = isVisible.hashCode().toString() extraContentStack = extraContentStack.filter { it.key != key } @@ -157,7 +157,7 @@ abstract class DynamicScreen : CustomScreen { ) { LaunchedEffect(isVisible.value) { if (isVisible.value) { - mainContentStack = mainContentStack.plus(ScreenBarComponent( + mainContentStack += ScreenBarComponent( state = isVisible, showMask = showMask(), showBackground = showBackground(), @@ -168,7 +168,7 @@ abstract class DynamicScreen : CustomScreen { onBackPressed() } } - )) + ) } else { val key = isVisible.hashCode().toString() mainContentStack = mainContentStack.filter { it.key != key } diff --git a/component/src/main/java/com/lalilu/component/base/SideSheetNavigator.kt b/component/src/main/java/com/lalilu/component/base/SideSheetNavigator.kt deleted file mode 100644 index c36864b5d..000000000 --- a/component/src/main/java/com/lalilu/component/base/SideSheetNavigator.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.lalilu.component.base - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetDefaults -import androidx.compose.material.SwipeableDefaults -import androidx.compose.material.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.Dp -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.stack.Stack -import cafe.adriel.voyager.navigator.CurrentScreen -import cafe.adriel.voyager.navigator.Navigator -import com.lalilu.component.navigation.LocalSheetNavigator -import com.lalilu.component.navigation.SheetNavigator -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun SideSheetNavigatorLayout( - modifier: Modifier = Modifier, - navigator: Navigator, - hideOnBackPress: Boolean = true, - defaultIsVisible: Boolean = false, - scrimColor: Color = ModalBottomSheetDefaults.scrimColor, - sheetShape: Shape = MaterialTheme.shapes.large, - sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, - sheetBackgroundColor: Color = MaterialTheme.colors.surface, - sheetContentColor: Color = contentColorFor(sheetBackgroundColor), - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - sheetContent: @Composable (SheetNavigator) -> Unit = { CurrentScreen() }, - content: @Composable (SheetNavigator) -> Unit -) { - val coroutineScope = rememberCoroutineScope() - val sheetState = rememberModalSideSheetState( - initialState = defaultIsVisible - ) - - val sheetNavigator = remember(navigator, sheetState, coroutineScope) { - SideSheetNavigator( - navigator = navigator, - sheetState = sheetState, - coroutineScope = coroutineScope - ) - } - - CompositionLocalProvider( - LocalSheetNavigator provides sheetNavigator - ) { - ModalSideSheetLayout( - modifier = modifier, - alignment = Alignment.CenterStart, - scrimColor = scrimColor, - sheetShape = sheetShape, - sheetState = sheetState, - animationSpec = animationSpec, - sheetElevation = sheetElevation, - sheetBackgroundColor = sheetBackgroundColor, - sheetContentColor = sheetContentColor, - sheetContent = { - SideSheetNavigatorBackHandler(sheetNavigator, hideOnBackPress) - sheetContent(sheetNavigator) - }, - content = { content(sheetNavigator) } - ) - } -} - -class SideSheetNavigator( - private val navigator: Navigator, - private val sheetState: ModalSideSheetState, - private val coroutineScope: CoroutineScope -) : Stack by navigator, SheetNavigator { - override val isVisible: Boolean by derivedStateOf { sheetState.isVisible } - - override fun show(screen: Screen?) { - if (screen == null) { - coroutineScope.launch { sheetState.isVisible = true } - return - } - - when { - screen is TabScreen -> { - if (items.size <= 1) { - push(screen) - return - } - - val firstItem = items.firstOrNull()?.let { listOf(it) } ?: emptyList() - replaceAll(firstItem) - - if (screen != firstItem) { - push(screen) - } - return - } - - lastItemOrNull == null || lastItemOrNull::class.java != screen::class.java -> { - if (!isVisible) popUntil { it is TabScreen } - push(screen) - } - - else -> { - replace(screen) - } - } - - if (!isVisible) { - coroutineScope.launch { - sheetState.isVisible = true - } - } - } - - override fun hide() { - coroutineScope.launch { - sheetState.isVisible = false - } - } - - override fun back(enable: Boolean) { - // 若当前只剩一个页面,则不清空元素了 - if (navigator.items.size <= 1 || navigator.lastItemOrNull is TabScreen) { - hide() - return - } - - val popped = navigator.pop() - - if (!popped && enable) { - hide() - } - - if (navigator.lastItemOrNull is TabScreen) { - hide() - } - } - - override fun getNavigator(): Navigator { - return navigator - } -} - -@ExperimentalMaterialApi -@Composable -fun SideSheetNavigatorBackHandler( - navigator: SheetNavigator, - hideOnBackPress: Boolean -) { - BackHandler(enabled = navigator.isVisible) { - navigator.back(hideOnBackPress) - } -} diff --git a/component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt index 0659d7f9c..23d4f8f0b 100644 --- a/component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt +++ b/component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt @@ -9,7 +9,7 @@ interface GlobalNavigator { */ fun goToDetailOf( mediaId: String, - navigator: SheetNavigator? = null + navigator: EnhanceNavigator? = null ) /** @@ -18,7 +18,7 @@ interface GlobalNavigator { fun showSongs( mediaIds: List, title: String? = null, - navigator: SheetNavigator? = null + navigator: EnhanceNavigator? = null ) /** @@ -31,10 +31,10 @@ interface GlobalNavigator { fun navigateTo( screen: Screen, singleTop: Boolean = true, - navigator: SheetNavigator? = null + navigator: EnhanceNavigator? = null ) fun goBack( - navigator: SheetNavigator? = null + navigator: EnhanceNavigator? = null ) } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/SheetController.kt b/component/src/main/java/com/lalilu/component/navigation/SheetController.kt new file mode 100644 index 000000000..c015919d9 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/SheetController.kt @@ -0,0 +1,143 @@ +package com.lalilu.component.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.stack.Stack +import cafe.adriel.voyager.navigator.Navigator +import com.lalilu.component.base.TabScreen + +interface SheetController { + val isVisible: Boolean + fun hide() + fun show() +} + +val LocalSheetController: ProvidableCompositionLocal = + staticCompositionLocalOf { null } + +@Composable +fun BackHandler( + navigator: SheetController? = LocalSheetController.current, + onBack: () -> Unit +) { + BackHandler(enabled = navigator?.isVisible ?: false, onBack = onBack) +} + +interface EnhanceNavigator : Stack { + + /** + * 跳转前的操作 + * + * @param targetScreen 目标跳转页面 + * @return 返回值决定是否继续下一步操作 + */ + fun preJump(targetScreen: Screen): Boolean = true + + /** + * 实际进行跳转的操作 + * + * @param targetScreen 目标跳转页面 + * @return 返回值决定是否继续下一步操作 + */ + fun doJump(targetScreen: Screen): Boolean { + when { + // Tab类型页面 + targetScreen is TabScreen -> { + val firstItem = items.firstOrNull()?.let { listOf(it) } ?: emptyList() + replaceAll(firstItem) + + + if (targetScreen != firstItem) { + push(targetScreen) + } + } + + // 不同类型的页面,添加至导航栈 + lastItemOrNull + ?.let { it::class.java != targetScreen::class.java } + ?: true -> push(targetScreen) + + // 同类型页面,替换 + else -> replace(targetScreen) + } + return true + } + + /** + * 执行跳转后的操作,可在此进行撤销的逻辑 + * + * @param fromScreen 跳转起始时的页面 + * @param toScreen 跳转目标页面 + * @return 返回值决定是否撤销跳转的操作,返回true表示不撤销,返回false表示撤销 + */ + fun postJump(fromScreen: Screen?, toScreen: Screen): Boolean = true + + /** + * 进行恢复跳转操作前页面的操作 + * + * @param fromScreen 跳转起始时的页面 + */ + fun resetTo(fromScreen: Screen?) {} + + /** + * 获取当前显示的页面 + * + * @return 当前用户可见的页面 + */ + fun getCurrentScreen(): Screen? + + /** + * 执行跳转操作 + * + * @param targetScreen 目标跳转页面 + */ + fun jump(targetScreen: Screen) { + val currentScreen = getCurrentScreen() + + if (!preJump(targetScreen)) return + + if (!doJump(targetScreen)) return + + if (postJump(currentScreen, targetScreen)) return + + resetTo(currentScreen) + } + + /** + * 执行返回操作前的操作 + * + * @return 返回值决定是否继续下一步操作 + */ + fun preBack(currentScreen: Screen?): Boolean = true + + /** + * 执行返回操作 + * + * @return 返回值决定是否继续下一步操作 + */ + fun doBack(currentScreen: Screen?): Boolean { + return pop().not() + } + + + /** + * 执行返回操作后的操作 + */ + fun postBack(fromScreen: Screen?) {} + + /** + * 执行返回操作 + */ + fun back() { + val currentScreen = getCurrentScreen() + if (!preBack(currentScreen)) return + if (!doBack(currentScreen)) return + postBack(currentScreen) + } + + fun getNavigator(): Navigator +} + diff --git a/component/src/main/java/com/lalilu/component/navigation/SheetNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/SheetNavigator.kt deleted file mode 100644 index 9245fd9b9..000000000 --- a/component/src/main/java/com/lalilu/component/navigation/SheetNavigator.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.lalilu.component.navigation - -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.staticCompositionLocalOf -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.stack.Stack -import cafe.adriel.voyager.navigator.Navigator - -interface SheetNavigator : Stack { - val isVisible: Boolean - fun hide() - fun show(screen: Screen? = null) - fun back(enable: Boolean = true) - fun getNavigator(): Navigator -} - -val LocalSheetNavigator: ProvidableCompositionLocal = - staticCompositionLocalOf { error("SheetNavigator not initialized") } - -@Composable -fun BackHandler( - navigator: SheetNavigator = LocalSheetNavigator.current, - onBack: () -> Unit -) { - BackHandler(enabled = navigator.isVisible, onBack = onBack) -} \ No newline at end of file From fa7778c0b1259a1ea61649cc54f9c51221c571d3 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 4 Mar 2024 13:55:04 +0800 Subject: [PATCH 003/213] =?UTF-8?q?[modify]=E6=9B=B4=E6=96=B0=E5=B9=B6?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2compose=5Fbom=E8=87=B3=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E7=9A=84alpha=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- component/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 628f4740d..8a3d03534 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -55,8 +55,8 @@ dependencies { api("me.rosuh:AndroidFilePicker:1.0.1") // compose - api(platform(libs.compose.bom)) -// api(platform(libs.compose.bom.alpha)) +// api(platform(libs.compose.bom)) + api(platform(libs.compose.bom.alpha)) api(libs.activity.compose) api(libs.bundles.compose) api(libs.bundles.compose.debug) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc3382a91..b7d60f6b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ ksp_version = "1.9.20-1.0.14" #serialization_json_version = "1.6.0" koin_version = "3.5.0" -compose_bom_alpha_version = "2023.12.00-alpha04" +compose_bom_alpha_version = "2024.02.00-alpha02" compose_bom_version = "2024.01.00" compose_compiler_version = "1.5.5" accompanist_version = "0.32.0" From 21d337e2357e49b3e2b5e04259a92c9a77b22924 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 4 Mar 2024 14:07:48 +0800 Subject: [PATCH 004/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/component/CustomTransition.kt | 1 + .../compose/component/navigate/NavigationSmartBar.kt | 3 +-- .../lmusic/compose/new_screen/SongDetailScreen.kt | 1 - .../lmusic/compose/screen/guiding/GuidingScreen.kt | 8 ++++---- .../compose/screen/playing/CustomRecyclerView.kt | 10 +--------- .../com/lalilu/component/base/BottomSheetController.kt | 4 ---- .../main/java/com/lalilu/component/card/SongCard.kt | 2 -- .../lalilu/component/extension/ComposeModifierExt.kt | 1 - .../com/lalilu/component/navigation/SheetController.kt | 8 +++++++- .../component/settings/SettingProgressSeekBar.kt | 1 - .../lalilu/component/settings/SettingStateSeekBar.kt | 1 - .../com/lalilu/component/settings/SettingSwitcher.kt | 3 --- 12 files changed, 14 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt index b131da641..c47f9ccbe 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt @@ -24,6 +24,7 @@ fun CustomTransition( ) { AnimatedContent( modifier = modifier, + contentKey = { it.key }, targetState = getScreenFrom(navigator), transitionSpec = { fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)) + slideInVertically { 100 } togetherWith diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt index 04549eaca..09e04e55e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt @@ -11,7 +11,6 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -39,7 +38,7 @@ import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.base.DynamicScreen import com.lalilu.lmusic.utils.extension.measureHeight -@OptIn(ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable fun NavigationSmartBar( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt index d2d02ccb4..bbc5e711b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt @@ -57,7 +57,6 @@ import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.ScreenInfo -import com.lalilu.component.extension.DynamicTipsHost import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.extension.rememberScrollPosition diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt index 40bd2fac0..f1eaeace9 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt @@ -9,7 +9,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -49,9 +49,9 @@ import androidx.compose.ui.unit.sp import cafe.adriel.voyager.navigator.Navigator import com.lalilu.R import com.lalilu.component.base.CustomScreen -import com.lalilu.lmusic.compose.component.CustomTransition import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.extension.rememberIsPad +import com.lalilu.lmusic.compose.component.CustomTransition @Composable @OptIn(ExperimentalAnimationApi::class) @@ -110,8 +110,8 @@ fun GuidingScreen() { horizontalAlignment = Alignment.Start ) { AnimatedContent(targetState = currentScreenTitleRes, transitionSpec = { - (slideInVertically { height -> height } + fadeIn() with - slideOutVertically { height -> -height } + fadeOut()).using( + ((slideInVertically { height -> height } + fadeIn()).togetherWith( + slideOutVertically { height -> -height } + fadeOut())).using( SizeTransform(clip = false) ) }, label = "") { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt index b8b4d42f3..e9b0940a0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt @@ -10,14 +10,12 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.lalilu.common.base.Playable -import com.lalilu.component.extension.DynamicTipsHost import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.extension.collectWithLifeCycleOwner import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lmusic.adapter.NewPlayingAdapter import com.lalilu.lmusic.adapter.ViewEvent -import com.lalilu.lmusic.compose.NavigationWrapper import com.lalilu.lmusic.ui.ComposeNestedScrollRecyclerView import com.lalilu.lmusic.utils.extension.calculateExtraLayoutSpace import com.lalilu.lmusic.utils.extension.getActivity @@ -90,13 +88,7 @@ private fun createAdapter( .setViewEvent { event, item -> when (event) { ViewEvent.OnClick -> playingVM.play(mediaId = item.mediaId, playOrPause = true) - ViewEvent.OnLongClick -> { - GlobalNavigatorImpl.goToDetailOf( - mediaId = item.mediaId, - navigator = NavigationWrapper.navigator, - ) - NavigationWrapper.navigator?.show() - } + ViewEvent.OnLongClick -> GlobalNavigatorImpl.goToDetailOf(mediaId = item.mediaId) ViewEvent.OnSwipeLeft -> { DynamicTipsItem.Static( diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt index 93b521a0c..97af3d5bd 100644 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt @@ -120,10 +120,6 @@ class BottomSheetController internal constructor( } } - override fun getCurrentScreen(): Screen? { - return lastItemOrNull - } - override fun preBack(currentScreen: Screen?): Boolean { // 若当前只剩一个页面,则不清空元素了 if (items.size <= 1) { diff --git a/component/src/main/java/com/lalilu/component/card/SongCard.kt b/component/src/main/java/com/lalilu/component/card/SongCard.kt index 8d987c13f..2a54cd256 100644 --- a/component/src/main/java/com/lalilu/component/card/SongCard.kt +++ b/component/src/main/java/com/lalilu/component/card/SongCard.kt @@ -28,8 +28,6 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.ProvideTextStyle import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.material.ripple.createRippleModifierNode -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt index 9fc5b6884..9aeffe5d0 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.hoverable import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier diff --git a/component/src/main/java/com/lalilu/component/navigation/SheetController.kt b/component/src/main/java/com/lalilu/component/navigation/SheetController.kt index c015919d9..1821ae257 100644 --- a/component/src/main/java/com/lalilu/component/navigation/SheetController.kt +++ b/component/src/main/java/com/lalilu/component/navigation/SheetController.kt @@ -87,7 +87,7 @@ interface EnhanceNavigator : Stack { * * @return 当前用户可见的页面 */ - fun getCurrentScreen(): Screen? + fun getCurrentScreen(): Screen? = lastItemOrNull /** * 执行跳转操作 @@ -141,3 +141,9 @@ interface EnhanceNavigator : Stack { fun getNavigator(): Navigator } +fun createDefaultEnhanceNavigator(navigator: Navigator): EnhanceNavigator { + return object : Stack by navigator, EnhanceNavigator { + override fun getNavigator(): Navigator = navigator + } +} + diff --git a/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt b/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt index e0337f204..5a925065b 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.contentColorFor -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue diff --git a/component/src/main/java/com/lalilu/component/settings/SettingStateSeekBar.kt b/component/src/main/java/com/lalilu/component/settings/SettingStateSeekBar.kt index 953aac9b9..b040380b9 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingStateSeekBar.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingStateSeekBar.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.contentColorFor -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf diff --git a/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt b/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt index 680347f26..1c071ceff 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt @@ -1,7 +1,6 @@ package com.lalilu.component.settings import androidx.annotation.StringRes -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.MarqueeSpacing import androidx.compose.foundation.basicMarquee @@ -18,7 +17,6 @@ import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.contentColorFor -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember @@ -61,7 +59,6 @@ fun SettingSwitcher( enableContentClickable = enableContentClickable ) -@OptIn(ExperimentalFoundationApi::class) @Composable fun SettingSwitcher( modifier: Modifier = Modifier, From 5d08c996af45011f81063ea84bf107429ec2178f Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 5 Mar 2024 00:48:34 +0800 Subject: [PATCH 005/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=B9=B3=E6=9D=BF=E7=9A=84=E5=AF=BC=E8=88=AA=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=92=8C=E5=B8=83=E5=B1=80=EF=BC=8C=E8=A7=A3=E5=86=B3?= =?UTF-8?q?NavigationBar=E4=B8=8D=E6=9B=B4=E6=96=B0=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lmusic/GlobalNavigatorImpl.kt | 16 +-- .../lalilu/lmusic/compose/DrawerWrapper.kt | 4 +- .../lmusic/compose/NavigationWrapper.kt | 91 ++++++---------- .../component/navigate/NavigationBar.kt | 103 +++++++++++++----- .../navigate/NavigationSheetContent.kt | 39 ++++--- .../component/navigate/NavigationSmartBar.kt | 48 +------- .../compose/screen/playing/SeekbarLayout.kt | 4 +- 7 files changed, 144 insertions(+), 161 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt b/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt index e73624b34..4b81751e3 100644 --- a/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt +++ b/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt @@ -1,8 +1,8 @@ package com.lalilu.lmusic import cafe.adriel.voyager.core.screen.Screen +import com.lalilu.component.navigation.EnhanceNavigator import com.lalilu.component.navigation.GlobalNavigator -import com.lalilu.component.navigation.SheetNavigator import com.lalilu.lmusic.compose.NavigationWrapper import com.lalilu.lmusic.compose.new_screen.SongDetailScreen import com.lalilu.lmusic.compose.new_screen.SongsScreen @@ -18,19 +18,19 @@ object GlobalNavigatorImpl : GlobalNavigator, CoroutineScope { */ override fun goToDetailOf( mediaId: String, - navigator: SheetNavigator?, + navigator: EnhanceNavigator?, ) { val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.show(SongDetailScreen(mediaId = mediaId)) + nav.jump(SongDetailScreen(mediaId = mediaId)) } override fun showSongs( mediaIds: List, title: String?, - navigator: SheetNavigator?, + navigator: EnhanceNavigator?, ) { val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.show( + nav.jump( SongsScreen( title = title, mediaIds = mediaIds @@ -41,13 +41,13 @@ object GlobalNavigatorImpl : GlobalNavigator, CoroutineScope { override fun navigateTo( screen: Screen, singleTop: Boolean, - navigator: SheetNavigator? + navigator: EnhanceNavigator? ) { val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.show(screen = screen) + nav.jump(targetScreen = screen) } - override fun goBack(navigator: SheetNavigator?) { + override fun goBack(navigator: EnhanceNavigator?) { val nav = navigator ?: NavigationWrapper.navigator ?: return nav.back() } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt index 8c48d3136..5644d1eff 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt @@ -133,7 +133,7 @@ object DrawerWrapper { ) ) - val lastXSpace = constraints.maxWidth - (spacer?.width ?: 0) - (main?.width ?: 0) + val lastXSpace = constraints.maxWidth - (main?.width ?: 0) val second = measurables.getOrNull(2) ?.measure(constraints.copy(maxWidth = lastXSpace)) @@ -149,7 +149,7 @@ object DrawerWrapper { fraction = animateProgress ) val secondX = lerp( - start = (main?.width ?: 0) + (spacer?.width ?: 0), + start = main?.width ?: 0, stop = 0, fraction = animateProgress ) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt index 84821d673..325e2173e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt @@ -1,11 +1,13 @@ package com.lalilu.lmusic.compose -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.SpringSpec +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -16,23 +18,19 @@ import cafe.adriel.voyager.core.annotation.InternalVoyagerApi import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.compositionUniqueId import com.lalilu.component.base.BottomSheetNavigatorLayout -import com.lalilu.component.base.HiddenBottomSheetScreen -import com.lalilu.component.base.SideSheetNavigatorLayout -import com.lalilu.component.base.TabScreen -import com.lalilu.component.navigation.SheetNavigator +import com.lalilu.component.navigation.EnhanceNavigator +import com.lalilu.component.navigation.SheetController +import com.lalilu.component.navigation.createDefaultEnhanceNavigator import com.lalilu.lmusic.compose.component.navigate.NavigationSheetContent import com.lalilu.lmusic.compose.new_screen.HomeScreen @OptIn(ExperimentalMaterialApi::class) object NavigationWrapper { - var navigator: SheetNavigator? by mutableStateOf(null) + var navigator: EnhanceNavigator? by mutableStateOf(null) + private set + var sheetController: SheetController? by mutableStateOf(null) private set - - // 使用remember避免在该变量内部的state引用触发重组,使其转换为普通的变量 - private val isSheetVisible: Boolean - @Composable - get() = remember { navigator?.isVisible ?: false } @OptIn(InternalVoyagerApi::class) @Composable @@ -40,16 +38,6 @@ object NavigationWrapper { modifier: Modifier = Modifier, forPad: () -> Boolean = { false } ) { - val emptyScreen = remember { - HiddenBottomSheetScreen - } - val animationSpec = remember { - SpringSpec( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = 1000f - ) - } - // 共用Navigator避免切换时导致导航栈丢失 Navigator( HomeScreen, @@ -57,55 +45,42 @@ object NavigationWrapper { key = compositionUniqueId() ) { navigator -> if (forPad()) { - SideSheetNavigatorLayout( - modifier = modifier.fillMaxSize(), - navigator = navigator, - defaultIsVisible = isSheetVisible && navigator.lastItemOrNull !is TabScreen, - scrimColor = Color.Black.copy(alpha = 0.5f), - sheetBackgroundColor = MaterialTheme.colors.background, - animationSpec = animationSpec, - sheetContent = { sheetNavigator -> - NavigationWrapper.navigator = sheetNavigator - NavigationSheetContent( - modifier = modifier, - transitionKeyPrefix = "SideSheet", - sheetNavigator = sheetNavigator, - getScreenFrom = { - sheetNavigator.lastItemOrNull?.takeIf { it !is TabScreen } - ?: emptyScreen - } - ) - }, - content = { sheetNavigator -> - NavigationSheetContent( - modifier = modifier, - transitionKeyPrefix = "Tab", - sheetNavigator = sheetNavigator, - getScreenFrom = { - sheetNavigator.items.lastOrNull { it is TabScreen } - ?: emptyScreen - } - ) + val isVisible by remember(navigator) { derivedStateOf { navigator.items.size > 1 } } + val emptyNavigator = remember(navigator) { + createDefaultEnhanceNavigator(navigator).also { + this@NavigationWrapper.sheetController = null + this@NavigationWrapper.navigator = it } + } + + BackHandler(enabled = isVisible) { + emptyNavigator.back() + } + + NavigationSheetContent( + modifier = modifier, + navigator = emptyNavigator, + transitionKeyPrefix = "forPad" ) } else { BottomSheetNavigatorLayout( modifier = modifier.fillMaxSize(), navigator = navigator, - defaultIsVisible = isSheetVisible, + defaultIsVisible = false, scrimColor = Color.Black.copy(alpha = 0.5f), sheetBackgroundColor = MaterialTheme.colors.background, - animationSpec = animationSpec, + animationSpec = remember { tween(300, easing = FastOutSlowInEasing) }, sheetContent = { sheetNavigator -> - NavigationWrapper.navigator = sheetNavigator + this@NavigationWrapper.navigator = sheetNavigator + this@NavigationWrapper.sheetController = sheetNavigator NavigationSheetContent( modifier = modifier, transitionKeyPrefix = "bottomSheet", - sheetNavigator = sheetNavigator + navigator = sheetNavigator, + sheetController = sheetNavigator ) - }, - content = { } - ) + } + ) { } } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt index c591bc407..9eb2e0c0b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt @@ -30,6 +30,7 @@ import androidx.compose.material.TextButton import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,50 +48,93 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.stack.Stack import com.lalilu.R import com.lalilu.component.base.CustomScreen import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.TabScreen import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.navigation.SheetNavigator +import com.lalilu.component.navigation.EnhanceNavigator +import com.lalilu.component.navigation.SheetController + +sealed class NavigationBarState { + data class ForTabScreen(val tabScreens: List) : NavigationBarState() + data class ForScreen(val screen: DynamicScreen?) : NavigationBarState() +} @Composable -fun NavigationBar( - modifier: Modifier = Modifier, +fun rememberNavigationBarState( tabScreens: () -> List, currentScreen: () -> Screen?, - navigator: SheetNavigator, -) { - val screen by remember { derivedStateOf { currentScreen() } } - val isCurrentTabScreen by remember { derivedStateOf { screen as? TabScreen != null } } - val previousScreen by remember(screen) { - derivedStateOf { navigator.items.getOrNull(navigator.size - 2) as? CustomScreen } +): State { + return remember { + derivedStateOf { + when (val screen = currentScreen()) { + is TabScreen -> NavigationBarState.ForTabScreen(tabScreens()) + is DynamicScreen -> NavigationBarState.ForScreen(screen) + else -> NavigationBarState.ForScreen(null) + } + } } - val previousInfo by remember { derivedStateOf { previousScreen?.getScreenInfo() } } - val previousTitle by remember { +} + +@Composable +fun rememberPreviousScreenTitleRes( + stack: Stack, + currentScreen: Screen? +): State { + val previousScreen by remember(currentScreen) { + derivedStateOf { stack.items.getOrNull(stack.size - 2) as? CustomScreen } + } + val previousInfo by remember { + derivedStateOf { previousScreen?.getScreenInfo() } + } + return remember { derivedStateOf { previousInfo?.title ?: R.string.bottom_sheet_navigate_back } } - val dynamicScreen by remember { derivedStateOf { screen as? DynamicScreen } } - val screenActions = dynamicScreen?.registerActions() ?: emptyList() +} + + +@Composable +fun NavigationBar( + modifier: Modifier = Modifier, + tabScreens: () -> List, + currentScreen: () -> Screen?, + navigator: EnhanceNavigator, + sheetController: SheetController? = null, +) { + val navigationBarState = + rememberNavigationBarState(tabScreens = tabScreens, currentScreen = currentScreen) AnimatedContent( modifier = modifier.fillMaxWidth(), - targetState = isCurrentTabScreen, + targetState = navigationBarState.value, label = "NavigateBarTransform" - ) { tabScreenNow -> - if (tabScreenNow) { - NavigateTabBar( - tabScreens = tabScreens, - currentScreen = { screen }, - onSelectTab = { navigator.show(it) } - ) - } else { - NavigateCommonBar( - previousTitle = { previousTitle }, - screenActions = { screenActions }, - navigator = navigator - ) + ) { state -> + when (state) { + is NavigationBarState.ForTabScreen -> { + NavigateTabBar( + tabScreens = state::tabScreens::get, + currentScreen = currentScreen, + onSelectTab = { navigator.jump(it) } + ) + } + + is NavigationBarState.ForScreen -> { + val actions = state.screen?.registerActions() ?: emptyList() + val previousTitle = rememberPreviousScreenTitleRes( + stack = navigator, + currentScreen = currentScreen() + ) + + NavigateCommonBar( + previousTitle = { previousTitle.value }, + screenActions = { actions }, + navigator = navigator, + sheetController = sheetController + ) + } } } } @@ -127,7 +171,8 @@ fun NavigateCommonBar( modifier: Modifier = Modifier, previousTitle: () -> Int, screenActions: () -> List, - navigator: SheetNavigator + navigator: EnhanceNavigator, + sheetController: SheetController? = null, ) { val itemFitImePadding = remember { mutableStateOf(false) } @@ -222,7 +267,7 @@ fun NavigateCommonBar( backgroundColor = Color(0x25FE4141), contentColor = Color(0xFFFE4141) ), - onClick = { navigator.hide() } + onClick = { sheetController?.hide() } ) { Text( text = stringResource(id = R.string.bottom_sheet_navigate_close), diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt index b221a5beb..0ee035ba8 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.currentComposer import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,9 +20,10 @@ import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator import com.google.accompanist.systemuicontroller.rememberSystemUiController -import com.lalilu.component.base.CustomScreen import com.lalilu.component.base.LocalPaddingValue -import com.lalilu.component.navigation.SheetNavigator +import com.lalilu.component.navigation.EnhanceNavigator +import com.lalilu.component.navigation.LocalSheetController +import com.lalilu.component.navigation.SheetController import com.lalilu.lmusic.compose.TabWrapper import com.lalilu.lmusic.compose.component.CustomTransition @@ -46,25 +48,31 @@ fun ImmerseStatusBar( fun NavigationSheetContent( modifier: Modifier, transitionKeyPrefix: String, - sheetNavigator: SheetNavigator, - getScreenFrom: (Navigator) -> Screen = { sheetNavigator.getNavigator().lastItem }, + navigator: EnhanceNavigator, + sheetController: SheetController? = LocalSheetController.current, + getScreenFrom: (Navigator) -> Screen = { it.lastItem }, ) { - val currentPaddingValue = remember { mutableStateOf(PaddingValues(0.dp)) } - val currentScreen by remember { derivedStateOf { getScreenFrom(sheetNavigator.getNavigator()) } } - val customScreenInfo by remember { derivedStateOf { (currentScreen as? CustomScreen)?.getScreenInfo() } } +// val customScreenInfo by remember { derivedStateOf { (currentScreen as? CustomScreen)?.getScreenInfo() } } - ImmerseStatusBar( - enable = { customScreenInfo?.immerseStatusBar != false }, - isExpended = { sheetNavigator.isVisible } - ) +// ImmerseStatusBar( +// enable = { customScreenInfo?.immerseStatusBar != false }, +// isExpended = { sheetNavigator.isVisible } +// ) Box(modifier = Modifier.fillMaxSize()) { + val currentScreen = remember { mutableStateOf(null) } + val currentPaddingValue = remember { mutableStateOf(PaddingValues(0.dp)) } + CustomTransition( modifier = Modifier.fillMaxSize(), keyPrefix = transitionKeyPrefix, - navigator = sheetNavigator.getNavigator(), + navigator = navigator.getNavigator(), getScreenFrom = getScreenFrom, ) { + if (!currentComposer.skipping) { + currentScreen.value = it + } + CompositionLocalProvider(LocalPaddingValue provides currentPaddingValue) { it.Content() } @@ -74,13 +82,14 @@ fun NavigationSheetContent( .fillMaxWidth() .align(Alignment.BottomCenter), measureHeightState = currentPaddingValue, - currentScreen = { currentScreen } + currentScreen = { currentScreen.value } ) { modifier -> NavigationBar( modifier = modifier.align(Alignment.BottomCenter), tabScreens = { TabWrapper.tabScreen }, - currentScreen = { currentScreen }, - navigator = sheetNavigator + currentScreen = { currentScreen.value }, + navigator = navigator, + sheetController = sheetController ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt index 09e04e55e..e5c247508 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt @@ -1,10 +1,7 @@ package com.lalilu.lmusic.compose.component.navigate -import android.view.MotionEvent -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.togetherWith @@ -14,7 +11,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding @@ -27,18 +23,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.base.DynamicScreen import com.lalilu.lmusic.utils.extension.measureHeight -@OptIn(ExperimentalComposeUiApi::class) @Composable fun NavigationSmartBar( modifier: Modifier = Modifier, @@ -47,53 +38,16 @@ fun NavigationSmartBar( content: @Composable (Modifier) -> Unit ) { val density = LocalDensity.current - val backPressDispatcher = LocalOnBackPressedDispatcherOwner.current val measureMainHeightState = remember { mutableStateOf(PaddingValues(0.dp)) } val screen by remember { derivedStateOf { currentScreen() as? DynamicScreen } } val mainContent by remember { derivedStateOf { runCatching { screen?.mainContentStack?.lastOrNull() }.getOrNull() } } val extraContent by remember { derivedStateOf { runCatching { screen?.extraContentStack?.lastOrNull() }.getOrNull() } } - val isShowMask by remember { derivedStateOf { mainContent?.showMask ?: false } } - val isShowBackground by remember { derivedStateOf { mainContent?.showBackground ?: true } } - - val maskColorUp = animateColorAsState( - targetValue = if (isShowMask) Color.Black.copy(alpha = 0.4f) else Color.Transparent, - label = "" - ) - val maskColorBottom = animateColorAsState( - targetValue = if (isShowMask) Color.Black.copy(alpha = 0.7f) else Color.Transparent, - label = "" - ) - val backgroundColor = animateColorAsState( - targetValue = if (isShowBackground) MaterialTheme.colors.background.copy(alpha = 0.95f) else Color.Transparent, - label = "" - ) - - // Mask遮罩层,点击后即消失 - Spacer( - modifier = Modifier - .background( - brush = Brush.verticalGradient( - colors = listOf( - maskColorUp.value, - maskColorBottom.value - ) - ) - ) - .fillMaxSize() - .pointerInteropFilter { // 监听触摸时,若为ACTION_UP或ACTION_CANCEL则触发返回事件 - if (isShowMask && (it.action == MotionEvent.ACTION_UP || it.action == MotionEvent.ACTION_CANCEL)) { - backPressDispatcher?.onBackPressedDispatcher?.onBackPressed() - } - isShowMask - } - ) - Column( modifier = modifier .fillMaxWidth() - .background(color = backgroundColor.value) + .background(color = MaterialTheme.colors.background.copy(alpha = 0.95f)) .measureHeight { _, height -> measureHeightState.value = PaddingValues(bottom = density.run { height.toDp() }) }, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt index 68b14f325..c66f23a61 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt @@ -103,12 +103,12 @@ fun BoxScope.SeekbarLayout( OnSeekBarScrollToThresholdListener({ 300f }) { override fun onScrollToThreshold() { HapticUtils.haptic(this@apply) - NavigationWrapper.navigator?.show() + NavigationWrapper.sheetController?.show() } override fun onScrollRecover() { HapticUtils.haptic(this@apply) - NavigationWrapper.navigator?.hide() + NavigationWrapper.sheetController?.hide() } }) From e7ae2e9afadd240fda1beac7d2133c713a1c031c Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 13 Mar 2024 00:59:06 +0800 Subject: [PATCH 006/213] =?UTF-8?q?[modify]=E6=B7=BB=E5=8A=A0=E6=BB=91?= =?UTF-8?q?=E5=8A=A8=E8=A7=A6=E5=8F=91Action=E7=9A=84=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=9B=BF=E6=8D=A2SongCard=E7=9A=84=E5=8F=8C=E5=87=BB?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/component/SongListWrapper.kt | 174 +++++++++-------- .../com/lalilu/component/card/SongCard.kt | 7 +- .../lalilu/component/extension/SwipeAction.kt | 177 ++++++++++++++++++ 3 files changed, 283 insertions(+), 75 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/extension/SwipeAction.kt diff --git a/component/src/main/java/com/lalilu/component/SongListWrapper.kt b/component/src/main/java/com/lalilu/component/SongListWrapper.kt index 27d4875ad..696792a88 100644 --- a/component/src/main/java/com/lalilu/component/SongListWrapper.kt +++ b/component/src/main/java/com/lalilu/component/SongListWrapper.kt @@ -2,6 +2,7 @@ package com.lalilu.component import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -28,6 +29,8 @@ import com.lalilu.common.base.Playable import com.lalilu.component.card.SongCard import com.lalilu.component.extension.ItemSelectHelper import com.lalilu.component.extension.LazyListScrollToHelper +import com.lalilu.component.extension.SwipeAction +import com.lalilu.component.extension.SwipeActionRow import com.lalilu.component.extension.rememberFixedStatusBarHeightDp import com.lalilu.component.extension.rememberStickyHelper import com.lalilu.component.extension.stickyHeaderExtent @@ -109,44 +112,56 @@ fun SongListWrapper( key = { it.mediaId }, contentType = { Playable::class } ) { item -> - SongCard( - song = { item }, - modifier = Modifier.animateItemPlacement(), - onClick = { - if (selector?.isSelecting() == true) { - selector.onSelect(item) - } else { - onClickItem(item) + val interactionSource = remember { MutableInteractionSource() } + val swipeAction = remember { + SwipeAction.BySwipe( + iconRes = R.drawable.ic_play_list_2_fill, + titleRes = R.string.select_action_title_select_all, + onAction = { + if (selector?.isSelecting() != true) { + onDoubleClickItem(item) + } } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClickItem(item) - }, - onDoubleClick = { - if (selector?.isSelecting() != true) { + ) + } + + SwipeActionRow( + actionAtLeft = swipeAction, + interactionSource = interactionSource, + ) { + SongCard( + song = { item }, + modifier = Modifier.animateItemPlacement(), + onClick = { + if (selector?.isSelecting() == true) { + selector.onSelect(item) + } else { + onClickItem(item) + } + }, + onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onDoubleClickItem(item) + onLongClickItem(item) + }, + onEnterSelect = { selector?.onSelect(item) }, + isSelected = { selector?.isSelected(item) ?: false }, + isPlaying = { isItemPlaying(item) }, + showPrefix = showPrefixContent, + hasLyric = { hasLyric(item) }, + prefixContent = { modifier -> + Row( + modifier = modifier + .clip(CircleShape) + .background(MaterialTheme.colors.surface) + .padding(start = 4.dp, end = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + prefixContent(item) + } } - }, - onEnterSelect = { selector?.onSelect(item) }, - isSelected = { selector?.isSelected(item) ?: false }, - isPlaying = { isItemPlaying(item) }, - showPrefix = showPrefixContent, - hasLyric = { hasLyric(item) }, - prefixContent = { modifier -> - Row( - modifier = modifier - .clip(CircleShape) - .background(MaterialTheme.colors.surface) - .padding(start = 4.dp, end = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - prefixContent(item) - } - } - ) + ) + } } } } @@ -222,47 +237,60 @@ fun ReorderableSongListWrapper( reorderableLazyListState = reorderableState, key = item.mediaId ) { isDragging -> - SongCard( - song = { item }, - modifier = Modifier.animateItemPlacement(), - dragModifier = Modifier.draggableHandle( - onDragStopped = { onDragMoveEnd(itemsState) } - ), - onClick = { - if (selector?.isSelecting() == true) { - selector.onSelect(item) - } else { - onClickItem(item) + val interactionSource = remember { MutableInteractionSource() } + val swipeAction = remember { + SwipeAction.BySwipe( + iconRes = R.drawable.ic_play_list_2_fill, + titleRes = R.string.select_action_title_select_all, + onAction = { + if (selector?.isSelecting() != true) { + onDoubleClickItem(item) + } } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClickItem(item) - }, - onDoubleClick = { - if (selector?.isSelecting() != true) { + ) + } + + SwipeActionRow( + actionAtLeft = swipeAction, + interactionSource = interactionSource, + ) { + SongCard( + song = { item }, + modifier = Modifier.animateItemPlacement(), + dragModifier = Modifier.draggableHandle( + onDragStopped = { onDragMoveEnd(itemsState) } + ), + interactionSource = interactionSource, + onClick = { + if (selector?.isSelecting() == true) { + selector.onSelect(item) + } else { + onClickItem(item) + } + }, + onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onDoubleClickItem(item) + onLongClickItem(item) + }, + onEnterSelect = { selector?.onSelect(item) }, + isSelected = { selector?.isSelected(item) ?: false }, + isPlaying = { isItemPlaying(item) }, + showPrefix = showPrefixContent, + hasLyric = { hasLyric(item) }, + prefixContent = { modifier -> + Row( + modifier = modifier + .clip(CircleShape) + .background(MaterialTheme.colors.surface) + .padding(start = 4.dp, end = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + prefixContent(item) + } } - }, - onEnterSelect = { selector?.onSelect(item) }, - isSelected = { selector?.isSelected(item) ?: false }, - isPlaying = { isItemPlaying(item) }, - showPrefix = showPrefixContent, - hasLyric = { hasLyric(item) }, - prefixContent = { modifier -> - Row( - modifier = modifier - .clip(CircleShape) - .background(MaterialTheme.colors.surface) - .padding(start = 4.dp, end = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - prefixContent(item) - } - } - ) + ) + } } } } diff --git a/component/src/main/java/com/lalilu/component/card/SongCard.kt b/component/src/main/java/com/lalilu/component/card/SongCard.kt index 2a54cd256..ace941f7b 100644 --- a/component/src/main/java/com/lalilu/component/card/SongCard.kt +++ b/component/src/main/java/com/lalilu/component/card/SongCard.kt @@ -59,6 +59,7 @@ import com.lalilu.component.extension.mimeTypeToIcon fun SongCard( modifier: Modifier = Modifier, dragModifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, song: () -> Playable, onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, @@ -76,6 +77,7 @@ fun SongCard( SongCard( modifier = modifier, dragModifier = dragModifier, + interactionSource = interactionSource, title = { item.title }, subTitle = { item.subTitle }, duration = { item.durationMs }, @@ -94,11 +96,13 @@ fun SongCard( ) } + @OptIn(ExperimentalFoundationApi::class) @Composable fun SongCard( modifier: Modifier = Modifier, dragModifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, title: () -> String, subTitle: () -> String, duration: () -> Long, @@ -115,7 +119,6 @@ fun SongCard( showPrefix: () -> Boolean = { false }, prefixContent: @Composable (Modifier) -> Unit = {} ) { - val interactionSource = remember { MutableInteractionSource() } val bgColor by animateColorAsState( targetValue = if (isSelected()) dayNightTextColor(0.15f) else Color.Transparent, label = "" @@ -214,7 +217,7 @@ fun SongCardContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - PlayingTipIcon(isPlaying = isPlaying) +// PlayingTipIcon(isPlaying = isPlaying) AnimatedVisibility( visible = showPrefix(), modifier = Modifier.wrapContentWidth(), diff --git a/component/src/main/java/com/lalilu/component/extension/SwipeAction.kt b/component/src/main/java/com/lalilu/component/extension/SwipeAction.kt new file mode 100644 index 000000000..856ac960d --- /dev/null +++ b/component/src/main/java/com/lalilu/component/extension/SwipeAction.kt @@ -0,0 +1,177 @@ +package com.lalilu.component.extension + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import kotlin.math.abs + +sealed class SwipeAction { + data class BySwipe( + @DrawableRes val iconRes: Int, + @StringRes val titleRes: Int, + val onAction: () -> Unit + ) : SwipeAction() +} + +@Composable +fun SwipeActionRow( + swipeThreshold: Dp = 100.dp, + maxSwipeThreshold: Dp = swipeThreshold * 2f, + actionAtLeft: SwipeAction.BySwipe? = null, + actionAtRight: SwipeAction.BySwipe? = null, + interactionSource: MutableInteractionSource, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val haptic = LocalHapticFeedback.current + + val coroutineScope = rememberCoroutineScope() + val offset = remember { mutableFloatStateOf(0f) } + val visibleAtLeft = remember { derivedStateOf { offset.floatValue > 0f } } + + val swipeThresholdPx = remember { with(density) { swipeThreshold.toPx() } } + val maxSwipeDistance = remember { with(density) { maxSwipeThreshold.toPx() } } + val arrivedThreshold = remember { derivedStateOf { abs(offset.floatValue) > swipeThresholdPx } } + + val draggableState = rememberDraggableState { dx -> + var result = dx + + val percent = 1f - (abs(offset.floatValue) - 0f) / maxSwipeDistance * 0.5f + if (percent in 0F..1F) result = dx * percent + + val target = offset.floatValue + result + + if ((actionAtLeft != null && target >= 0f) || (actionAtRight != null && target <= 0f)) { + offset.floatValue = target + } + } + + val visibleActions = remember { + derivedStateOf { + when { + offset.floatValue > 0f -> actionAtLeft + offset.floatValue < 0f -> actionAtRight + else -> null + } + } + } + + if (arrivedThreshold.value) { + LaunchedEffect(Unit) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .draggable( + state = draggableState, + interactionSource = interactionSource, + orientation = Orientation.Horizontal, + onDragStopped = { + coroutineScope.launch { + launch { + if (arrivedThreshold.value && visibleActions.value != null) { + visibleActions.value?.onAction?.invoke() + } + } + + launch { + draggableState.drag(MutatePriority.PreventUserInput) { + Animatable(offset.floatValue) + .animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 200), + block = { dragBy(value - offset.floatValue) } + ) + } + } + } + } + ) + .offset { IntOffset.Zero.copy(x = offset.floatValue.toInt()) } + ) { + content() + + if (visibleActions.value != null) { + val bgColor = animateColorAsState( + targetValue = if (arrivedThreshold.value) Color(0xFF1B7E00) else Color(0xFFB35004), + label = "" + ) + + val alignment = if (visibleAtLeft.value) Alignment.End else Alignment.Start + + Row( + modifier = Modifier + .matchParentSize() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + val multiply = if (visibleAtLeft.value) -1 else 1 + + layout(placeable.width, placeable.height) { + placeable.place(x = placeable.width * multiply, y = 0) + } + } + .background(color = bgColor.value.copy(0.15f)) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp, alignment) + ) { + Text( + color = bgColor.value, + text = stringResource(id = visibleActions.value!!.titleRes), + fontSize = 14.sp + ) + + Image( + modifier = Modifier.size(20.dp), + painter = painterResource(id = visibleActions.value!!.iconRes), + contentDescription = stringResource(id = visibleActions.value!!.titleRes), + colorFilter = ColorFilter.tint(color = bgColor.value) + ) + } + } + } +} From 8fb383968027b595758dda9186771f1aa1773c6b Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 13 Mar 2024 01:02:05 +0800 Subject: [PATCH 007/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=EF=BC=8C=E4=BC=98=E5=8C=96BottomSheet=E7=9A=84?= =?UTF-8?q?=E5=BC=B9=E5=87=BA=E5=92=8C=E6=94=B6=E8=B5=B7=E7=9A=84=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/NavigationWrapper.kt | 10 ++++-- .../compose/screen/guiding/GuidingScreen.kt | 1 - .../compose/screen/playing/PlayingLayout.kt | 32 ++++++++++--------- .../component/base/BottomSheetController.kt | 30 ++++++----------- 4 files changed, 34 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt index 325e2173e..b8fc80045 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt @@ -1,7 +1,7 @@ package com.lalilu.lmusic.compose import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.ExperimentalMaterialApi @@ -63,13 +63,19 @@ object NavigationWrapper { transitionKeyPrefix = "forPad" ) } else { + val animateSpec = remember { + tween( + durationMillis = 150, + easing = CubicBezierEasing(0.1f, 0.16f, 0f, 1f) + ) + } BottomSheetNavigatorLayout( modifier = modifier.fillMaxSize(), navigator = navigator, defaultIsVisible = false, scrimColor = Color.Black.copy(alpha = 0.5f), sheetBackgroundColor = MaterialTheme.colors.background, - animationSpec = remember { tween(300, easing = FastOutSlowInEasing) }, + animationSpec = animateSpec, sheetContent = { sheetNavigator -> this@NavigationWrapper.navigator = sheetNavigator this@NavigationWrapper.sheetController = sheetNavigator diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt index f1eaeace9..44d8a6eb0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt @@ -54,7 +54,6 @@ import com.lalilu.component.extension.rememberIsPad import com.lalilu.lmusic.compose.component.CustomTransition @Composable -@OptIn(ExperimentalAnimationApi::class) fun GuidingScreen() { val windowSize = LocalWindowSize.current val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp + diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 63cbb7a4b..d924e1813 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -226,21 +226,23 @@ fun PlayingLayout( { x -> -2f * (x - 0.5f).pow(2) + 0.5f } } - val lyricEntry = playingVM.lyricRepository.currentLyric - .mapLatest { - LyricUtil - .parseLrc(arrayOf(it?.first, it?.second)) - ?.mapIndexed { index, lyricEntry -> - LyricEntry( - index = index, - time = lyricEntry.time, - text = lyricEntry.text, - translate = lyricEntry.secondText - ) - } - ?: emptyList() - } - .collectAsState(initial = emptyList()) + val flow = remember { + playingVM.lyricRepository.currentLyric + .mapLatest { + LyricUtil + .parseLrc(arrayOf(it?.first, it?.second)) + ?.mapIndexed { index, lyricEntry -> + LyricEntry( + index = index, + time = lyricEntry.time, + text = lyricEntry.text, + translate = lyricEntry.secondText + ) + } + ?: emptyList() + } + } + val lyricEntry = flow.collectAsState(initial = emptyList()) val minToMiddleProgress = remember { derivedStateOf { draggable.progressBetween( diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt index 97af3d5bd..03140d103 100644 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt @@ -40,7 +40,6 @@ fun BottomSheetNavigatorLayout( modifier: Modifier = Modifier, navigator: Navigator, hideOnBackPress: Boolean = true, - visibleWhenShow: Boolean = false, defaultIsVisible: Boolean = false, scrimColor: Color = ModalBottomSheetDefaults.scrimColor, sheetShape: Shape = MaterialTheme.shapes.large, @@ -62,7 +61,6 @@ fun BottomSheetNavigatorLayout( val bottomSheetNavigator = remember { BottomSheetController( - visibleWhenShow = visibleWhenShow, navigator = navigator, sheetState = sheetState, coroutineScope = coroutineScope @@ -90,34 +88,24 @@ fun BottomSheetNavigatorLayout( } } -@OptIn(ExperimentalMaterialApi::class) class BottomSheetController internal constructor( - private val visibleWhenShow: Boolean = false, private val navigator: Navigator, private val sheetState: ModalBottomSheetState, private val coroutineScope: CoroutineScope ) : Stack by navigator, SheetController, EnhanceNavigator { override val isVisible: Boolean by derivedStateOf { - if (sheetState.currentValue == sheetState.targetValue && sheetState.progress == 1f) { - return@derivedStateOf sheetState.currentValue == ModalBottomSheetValue.Expanded - } - - if (visibleWhenShow) { - return@derivedStateOf when (sheetState.currentValue) { - ModalBottomSheetValue.Hidden -> sheetState.progress >= 0.05f - ModalBottomSheetValue.Expanded -> sheetState.progress <= 0.95f - - else -> false - } - } + val exitProgress = sheetState.progress( + from = ModalBottomSheetValue.Expanded, + to = ModalBottomSheetValue.Hidden + ) - when (sheetState.currentValue) { - ModalBottomSheetValue.Hidden -> sheetState.progress >= 0.95f - ModalBottomSheetValue.Expanded -> sheetState.progress <= 0.05f + val enterProgress = sheetState.progress( + from = ModalBottomSheetValue.Hidden, + to = ModalBottomSheetValue.Expanded + ) - else -> false - } + enterProgress >= 0.05 || exitProgress < 0.95 } override fun preBack(currentScreen: Screen?): Boolean { From ea5abb768d9edbb053ea62bc5a06c1accbc3f0ac Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 13 Mar 2024 15:12:01 +0800 Subject: [PATCH 008/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96HomeScreen?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lmusic/compose/TabWrapper.kt | 103 ------------------ .../navigate/NavigationSheetContent.kt | 12 +- .../lmusic/compose/new_screen/HomeScreen.kt | 36 +++--- .../lalilu/lmusic/extension/DailyRecommend.kt | 15 ++- .../lalilu/lmusic/extension/HistoryPanel.kt | 90 ++++++--------- .../lalilu/lmusic/extension/LatestPanel.kt | 17 ++- 6 files changed, 80 insertions(+), 193 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/TabWrapper.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/TabWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/TabWrapper.kt deleted file mode 100644 index 00fed13d0..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/TabWrapper.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.lalilu.lmusic.compose - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.currentComposer -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.navigator.tab.TabNavigator -import com.lalilu.component.base.LocalPaddingValue -import com.lalilu.component.base.TabScreen -import com.lalilu.lmusic.compose.component.navigate.NavigateTabBar -import com.lalilu.lmusic.compose.component.navigate.NavigationSmartBar -import com.lalilu.lmusic.compose.new_screen.HomeScreen -import com.lalilu.lmusic.compose.new_screen.SearchScreen -import com.lalilu.lplaylist.screen.PlaylistScreen - -object TabWrapper { - - var navigator: TabNavigator? = null - private set - - val tabScreen: List = listOf( - HomeScreen, - PlaylistScreen, - SearchScreen - ) - - private var screenToShow: TabScreen? = null - - /** - * 延迟显示某页面,避免因重组而丢失该显示页面的事件 - */ - fun postScreen(tabScreen: TabScreen) { - screenToShow = tabScreen - } - - @Composable - fun Content() { - val currentPaddingValue = remember { mutableStateOf(PaddingValues(0.dp)) } - - TabNavigator(tab = HomeScreen) { tabNavigator -> - navigator = tabNavigator - - // 显示待显示的页面 - if (!currentComposer.skipping && screenToShow != null) { - tabNavigator.current = screenToShow!! - screenToShow = null - } - - Box(modifier = Modifier.fillMaxSize()) { - AnimatedContent( - modifier = Modifier.fillMaxSize(), - targetState = tabNavigator.current, - transitionSpec = { - fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)) + slideInVertically { 100 } togetherWith - fadeOut(tween(0)) - }, - label = "" - ) { screen -> - tabNavigator.saveableState("transition", screen) { - CompositionLocalProvider(LocalPaddingValue provides currentPaddingValue) { - screen.Content() - } - } - } - - NavigationSmartBar( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - measureHeightState = currentPaddingValue, - currentScreen = { tabNavigator.current } - ) { modifier -> - NavigateTabBar( - modifier = modifier - .align(Alignment.BottomCenter) - .background(MaterialTheme.colors.background.copy(alpha = 0.95f)), - tabScreens = { tabScreen }, - currentScreen = { tabNavigator.current }, - onSelectTab = { tabNavigator.current = it } - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt index 0ee035ba8..c52b376b2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt @@ -24,8 +24,10 @@ import com.lalilu.component.base.LocalPaddingValue import com.lalilu.component.navigation.EnhanceNavigator import com.lalilu.component.navigation.LocalSheetController import com.lalilu.component.navigation.SheetController -import com.lalilu.lmusic.compose.TabWrapper import com.lalilu.lmusic.compose.component.CustomTransition +import com.lalilu.lmusic.compose.new_screen.HomeScreen +import com.lalilu.lmusic.compose.new_screen.SearchScreen +import com.lalilu.lplaylist.screen.PlaylistScreen @Composable fun ImmerseStatusBar( @@ -86,7 +88,13 @@ fun NavigationSheetContent( ) { modifier -> NavigationBar( modifier = modifier.align(Alignment.BottomCenter), - tabScreens = { TabWrapper.tabScreen }, + tabScreens = { + listOf( + HomeScreen, + PlaylistScreen, + SearchScreen + ) + }, currentScreen = { currentScreen.value }, navigator = navigator, sheetController = sheetController diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt index c0b6389c9..930fd35db 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt @@ -1,9 +1,10 @@ package com.lalilu.lmusic.compose.new_screen +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsIgnoringVisibility import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -13,11 +14,13 @@ import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.TabScreen import com.lalilu.component.extension.singleViewModel -import com.lalilu.lmusic.extension.DailyRecommend import com.lalilu.lmusic.extension.EntryPanel -import com.lalilu.lmusic.extension.HistoryPanel -import com.lalilu.lmusic.extension.LatestPanel +import com.lalilu.lmusic.extension.dailyRecommend +import com.lalilu.lmusic.extension.historyPanel +import com.lalilu.lmusic.extension.latestPanel +import com.lalilu.lmusic.viewmodel.HistoryViewModel import com.lalilu.lmusic.viewmodel.LibraryViewModel +import com.lalilu.lmusic.viewmodel.PlayingViewModel object HomeScreen : DynamicScreen(), TabScreen { override fun getScreenInfo(): ScreenInfo = ScreenInfo( @@ -25,9 +28,12 @@ object HomeScreen : DynamicScreen(), TabScreen { icon = R.drawable.ic_loader_line ) + @OptIn(ExperimentalLayoutApi::class) @Composable override fun Content() { val vm: LibraryViewModel = singleViewModel() + val historyVM: HistoryViewModel = singleViewModel() + val playingVM: PlayingViewModel = singleViewModel() LaunchedEffect(Unit) { vm.checkOrUpdateToday() @@ -35,19 +41,21 @@ object HomeScreen : DynamicScreen(), TabScreen { LLazyColumn( modifier = Modifier.fillMaxWidth(), - contentPadding = WindowInsets.statusBars.asPaddingValues() + contentPadding = WindowInsets.statusBarsIgnoringVisibility.asPaddingValues() ) { - item { - DailyRecommend() - } + dailyRecommend( + libraryVM = vm, + ) - item { - LatestPanel() - } + latestPanel( + libraryVM = vm, + playingVM = playingVM + ) - item { - HistoryPanel() - } + historyPanel( + historyVM = historyVM, + playingVM = playingVM + ) item { EntryPanel() diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index 0f1ca0141..d719c6c31 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -1,26 +1,23 @@ package com.lalilu.lmusic.extension -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lmusic.compose.component.card.RecommendCard2 import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.viewmodel.LibraryViewModel -@Composable -fun DailyRecommend( - vm: LibraryViewModel = singleViewModel(), +fun LazyListScope.dailyRecommend( + libraryVM: LibraryViewModel, ) { - Column { + item { Text( modifier = Modifier .padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 10.dp) @@ -29,8 +26,10 @@ fun DailyRecommend( style = MaterialTheme.typography.h6, color = dayNightTextColor() ) + } + item { RecommendRow( - items = { vm.dailyRecommends.value }, + items = { libraryVM.dailyRecommends.value }, getId = { it.id } ) { RecommendCard2( diff --git a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt index 384c3127c..96f6e268a 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt @@ -1,48 +1,31 @@ package com.lalilu.lmusic.extension -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material.Chip import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.times import com.lalilu.common.base.Playable import com.lalilu.component.card.SongCard -import com.lalilu.component.extension.singleViewModel import com.lalilu.lmedia.entity.LSong import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.HistoryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel - @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) -@Composable -fun HistoryPanel( - playingVM: PlayingViewModel = singleViewModel(), - historyVM: HistoryViewModel = singleViewModel() +fun LazyListScope.historyPanel( + historyVM: HistoryViewModel, + playingVM: PlayingViewModel ) { - val haptic = LocalHapticFeedback.current - val itemsCount = - remember { derivedStateOf { historyVM.historyState.value.size.coerceIn(0, 5) } } - val itemsHeight = animateDpAsState(itemsCount.value * 85.dp, label = "") - - Column { + item { RecommendTitle( title = "最近播放", onClick = { } @@ -55,42 +38,37 @@ fun HistoryPanel( Text(style = MaterialTheme.typography.caption, text = "历史记录") } } + } - LazyColumn( + items( + items = historyVM.historyState.value.take(5), + key = { it.id }, + contentType = { LSong::class } + ) { item -> + val haptic = LocalHapticFeedback.current + + SongCard( modifier = Modifier - .height(itemsHeight.value) - .animateContentSize() - .fillMaxWidth() - ) { - items( - items = historyVM.historyState.value.take(5), - key = { it.id }, - contentType = { LSong::class } - ) { item -> - SongCard( - modifier = Modifier - .animateItemPlacement() - .padding(bottom = 5.dp), - song = { item }, - fixedHeight = { true }, - isSelected = { false }, - onEnterSelect = { }, - isPlaying = { playingVM.isItemPlaying { it.mediaId == item.id } }, - onClick = { - historyVM.requiteHistoryList { - playingVM.play( - mediaId = item.mediaId, - mediaIds = it.map(Playable::mediaId), - playOrPause = true - ) - } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - GlobalNavigatorImpl.goToDetailOf(mediaId = item.id) - } - ) + .animateItemPlacement() + .padding(bottom = 5.dp), + song = { item }, + fixedHeight = { true }, + isSelected = { false }, + onEnterSelect = { }, + isPlaying = { playingVM.isItemPlaying { it.mediaId == item.id } }, + onClick = { + historyVM.requiteHistoryList { + playingVM.play( + mediaId = item.mediaId, + mediaIds = it.map(Playable::mediaId), + playOrPause = true + ) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + GlobalNavigatorImpl.goToDetailOf(mediaId = item.id) } - } + ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt index 3c12db1cf..4b99cc52d 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt @@ -1,16 +1,14 @@ package com.lalilu.lmusic.extension import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.Chip import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lalilu.common.base.Playable -import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lmusic.compose.component.card.RecommendCard import com.lalilu.lmusic.compose.component.card.RecommendRow @@ -20,13 +18,11 @@ import com.lalilu.lmusic.viewmodel.PlayingViewModel @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) - -@Composable -fun LatestPanel( - libraryVM: LibraryViewModel = singleViewModel(), - playingVM: PlayingViewModel = singleViewModel() +fun LazyListScope.latestPanel( + libraryVM: LibraryViewModel, + playingVM: PlayingViewModel ) { - Column { + item { RecommendTitle( title = "最近添加", onClick = { } @@ -38,7 +34,8 @@ fun LatestPanel( ) } } - + } + item { RecommendRow( items = { libraryVM.recentlyAdded.value }, getId = { it.id } From 9a37ba51df3cd360c68dc5570d09fa967fbf0bd3 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 13 Mar 2024 15:50:10 +0800 Subject: [PATCH 009/213] =?UTF-8?q?[modify]=E9=99=90=E5=88=B6Dialog?= =?UTF-8?q?=E7=9A=84=E6=9C=80=E5=A4=A7=E5=AE=BD=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/component/extension/DialogHost.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/extension/DialogHost.kt b/component/src/main/java/com/lalilu/component/extension/DialogHost.kt index a170f5933..2a431d967 100644 --- a/component/src/main/java/com/lalilu/component/extension/DialogHost.kt +++ b/component/src/main/java/com/lalilu/component/extension/DialogHost.kt @@ -136,8 +136,8 @@ object DialogWrapper : DialogHost, DialogContext { AnyPopDialog( modifier = Modifier - .fillMaxWidth() - .padding(top = 0.dp) + .wrapContentHeight() + .widthIn(max = 560.dp) .background(color = backgroundColor ?: MaterialTheme.colors.background), isActiveClose = isActiveClose, properties = properties, From 0394090e519d2dd274e535814d32f93aa1ea5942 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 14 Mar 2024 11:20:17 +0800 Subject: [PATCH 010/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E9=A1=B5=E5=BA=95=E9=83=A8=E7=9A=84=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=8E=92=E5=B8=83=EF=BC=8C=E4=BC=98=E5=8C=96=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/playing/LyricViewToolbar.kt | 30 +++++-------------- .../compose/screen/playing/PlayingLayout.kt | 2 -- .../com/lalilu/lmusic/extension/SleepTimer.kt | 3 +- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt index 235e11245..45fb7127e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt @@ -1,10 +1,6 @@ package com.lalilu.lmusic.compose.component.playing -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -32,6 +28,7 @@ import com.lalilu.component.settings.SettingProgressSeekBar import com.lalilu.component.settings.SettingStateSeekBar import com.lalilu.component.settings.SettingSwitcher import com.lalilu.lmusic.datastore.SettingsSp +import com.lalilu.lmusic.extension.SleepTimerSmallEntry import org.koin.compose.koinInject private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.Transparent) { @@ -54,6 +51,11 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T title = "歌词文字大小", valueRange = 14..36 ) + SettingSwitcher( + title = "歌词模糊效果", + subTitle = "为歌词添加一点模糊效果", + state = settingsSp.isEnableBlurEffect, + ) SettingSwitcher( title = "歌词页展开时隐藏其他组件", subTitle = "简化界面显示效果", @@ -74,7 +76,6 @@ fun LyricViewToolbar( settingsSp: SettingsSp = koinInject() ) { var isDrawTranslation by settingsSp.isDrawTranslation - var isEnableBlurEffect by settingsSp.isEnableBlurEffect Row( modifier = Modifier @@ -83,13 +84,12 @@ fun LyricViewToolbar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - val iconAlpha1 = animateFloatAsState( - targetValue = if (isEnableBlurEffect) 1f else 0.5f, label = "" - ) val iconAlpha2 = animateFloatAsState( targetValue = if (isDrawTranslation) 1f else 0.5f, label = "" ) + SleepTimerSmallEntry() + IconButton(onClick = { DialogWrapper.push(LyricViewActionDialog) }) { Icon( painter = painterResource(id = R.drawable.ic_text), @@ -98,20 +98,6 @@ fun LyricViewToolbar( ) } - AnimatedContent( - targetState = isEnableBlurEffect, - transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "" - ) { enable -> - IconButton(onClick = { isEnableBlurEffect = !enable }) { - Icon( - modifier = Modifier.graphicsLayer { alpha = iconAlpha1.value }, - painter = painterResource(id = if (enable) R.drawable.drop_line else R.drawable.blur_off_line), - contentDescription = "", - tint = Color.White - ) - } - } - IconButton(onClick = { isDrawTranslation = !isDrawTranslation }) { Icon( modifier = Modifier.graphicsLayer { alpha = iconAlpha2.value }, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index d924e1813..07bba1587 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -49,7 +49,6 @@ import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lmusic.extension.SleepTimerSmallEntry import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplayer.LPlayer import com.lalilu.lplayer.extensions.PlayerAction @@ -205,7 +204,6 @@ fun PlayingLayout( isUserTouchEnable = { draggable.state.value == DragAnchor.Min || draggable.state.value == DragAnchor.Max }, isExtraVisible = { draggable.state.value == DragAnchor.Max }, onClick = { scrollToTopEvent.value = System.currentTimeMillis() }, - fixContent = { SleepTimerSmallEntry() }, extraContent = { LyricViewToolbar() } ) } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt index 570d0d64f..d2ca4108a 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt @@ -60,6 +60,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import org.koin.compose.koinInject import java.time.LocalTime +import com.lalilu.component.R as ComponentR data class CustomCountDownTimer( @@ -131,7 +132,7 @@ private val SleepTimerDialog = DialogItem.Dynamic(backgroundColor = Color.Transp fun SleepTimerSmallEntry() { IconButton(onClick = { DialogWrapper.push(SleepTimerDialog) }) { Icon( - painter = painterResource(id = StatusBarLyric.API.R.drawable.ic_clock_black_24dp), + painter = painterResource(id = ComponentR.drawable.ic_time_line), contentDescription = "", tint = Color.White ) From ca90cb4ace52c0748a4236c60a8acfc192f3fcb6 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 14 Mar 2024 18:10:10 +0800 Subject: [PATCH 011/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4=E5=B9=B3?= =?UTF-8?q?=E6=9D=BF=E7=AB=AF=E9=A6=96=E9=A1=B5=E7=9A=84=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/new_screen/HomeScreen.kt | 70 ++++++++++++++----- .../lalilu/lmusic/extension/DailyRecommend.kt | 27 +++++++ .../java/com/lalilu/component/LLazyColumn.kt | 4 +- 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt index 930fd35db..6a90b6c38 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt @@ -1,13 +1,21 @@ package com.lalilu.lmusic.compose.new_screen +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import com.lalilu.R import com.lalilu.component.LLazyColumn import com.lalilu.component.base.DynamicScreen @@ -16,6 +24,7 @@ import com.lalilu.component.base.TabScreen import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.extension.EntryPanel import com.lalilu.lmusic.extension.dailyRecommend +import com.lalilu.lmusic.extension.dailyRecommendVertical import com.lalilu.lmusic.extension.historyPanel import com.lalilu.lmusic.extension.latestPanel import com.lalilu.lmusic.viewmodel.HistoryViewModel @@ -31,34 +40,59 @@ object HomeScreen : DynamicScreen(), TabScreen { @OptIn(ExperimentalLayoutApi::class) @Composable override fun Content() { + val density = LocalDensity.current val vm: LibraryViewModel = singleViewModel() val historyVM: HistoryViewModel = singleViewModel() val playingVM: PlayingViewModel = singleViewModel() + val paddingTop = WindowInsets.statusBarsIgnoringVisibility.asPaddingValues() + .calculateTopPadding() LaunchedEffect(Unit) { vm.checkOrUpdateToday() } - LLazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = WindowInsets.statusBarsIgnoringVisibility.asPaddingValues() - ) { - dailyRecommend( - libraryVM = vm, - ) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val expended = with(density) { constraints.maxWidth.toDp() } > 500.dp - latestPanel( - libraryVM = vm, - playingVM = playingVM - ) + Row(modifier = Modifier.fillMaxSize()) { + if (expended) { + LLazyColumn( + modifier = Modifier + .fillMaxHeight() + .wrapContentWidth(), + contentPadding = PaddingValues(top = paddingTop, start = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + dailyRecommendVertical(libraryVM = vm) + } + } - historyPanel( - historyVM = historyVM, - playingVM = playingVM - ) + LLazyColumn( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentPadding = PaddingValues(top = paddingTop) + ) { + if (!expended) { + dailyRecommend( + libraryVM = vm, + ) + } - item { - EntryPanel() + latestPanel( + libraryVM = vm, + playingVM = playingVM + ) + + historyPanel( + historyVM = historyVM, + playingVM = playingVM + ) + + item { + EntryPanel() + } + } } } } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index d719c6c31..be58ddc0a 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -3,7 +3,9 @@ package com.lalilu.lmusic.extension import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.ui.Modifier @@ -40,3 +42,28 @@ fun LazyListScope.dailyRecommend( } } } + + +fun LazyListScope.dailyRecommendVertical( + libraryVM: LibraryViewModel, +) { + item { + Text( + modifier = Modifier.wrapContentWidth(), + text = "每日推荐", + style = MaterialTheme.typography.h6, + color = dayNightTextColor() + ) + } + items( + items = libraryVM.dailyRecommends.value, + key = { it.id }, + contentType = { "dailyRecommendsCard" } + ) { + RecommendCard2( + item = { it }, + contentModifier = Modifier.size(width = 250.dp, height = 250.dp), + onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + ) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/LLazyColumn.kt b/component/src/main/java/com/lalilu/component/LLazyColumn.kt index a96ae6da9..df8a2ca87 100644 --- a/component/src/main/java/com/lalilu/component/LLazyColumn.kt +++ b/component/src/main/java/com/lalilu/component/LLazyColumn.kt @@ -5,8 +5,8 @@ import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState @@ -47,7 +47,7 @@ fun LLazyColumn( item { Spacer( modifier = Modifier - .fillMaxWidth() + .width(1.dp) .height(padding.calculateBottomPadding() + 20.dp) ) } From 17a515ebd0118f5dabb614f9769dd8ae5cf77098 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 14 Mar 2024 18:10:36 +0800 Subject: [PATCH 012/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96BottomSheetCo?= =?UTF-8?q?ntroller=E7=9A=84isVisible=E5=88=A4=E6=96=AD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/component/base/BottomSheetController.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt index 03140d103..0e60a954a 100644 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt @@ -95,17 +95,10 @@ class BottomSheetController internal constructor( ) : Stack by navigator, SheetController, EnhanceNavigator { override val isVisible: Boolean by derivedStateOf { - val exitProgress = sheetState.progress( - from = ModalBottomSheetValue.Expanded, - to = ModalBottomSheetValue.Hidden - ) - - val enterProgress = sheetState.progress( + sheetState.progress( from = ModalBottomSheetValue.Hidden, to = ModalBottomSheetValue.Expanded - ) - - enterProgress >= 0.05 || exitProgress < 0.95 + ) >= 0.95 } override fun preBack(currentScreen: Screen?): Boolean { From 93d8fd9a32718d88deb2a0070f237cd413dc9c3f Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 14 Mar 2024 18:11:06 +0800 Subject: [PATCH 013/213] =?UTF-8?q?[modify]=E5=88=9D=E6=AD=A5=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E7=8A=B6=E6=80=81=E6=A0=8F=E6=B2=89=E6=B5=B8=E6=95=88?= =?UTF-8?q?=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/component/navigate/NavigationSheetContent.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt index c52b376b2..8ba586745 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt @@ -54,12 +54,9 @@ fun NavigationSheetContent( sheetController: SheetController? = LocalSheetController.current, getScreenFrom: (Navigator) -> Screen = { it.lastItem }, ) { -// val customScreenInfo by remember { derivedStateOf { (currentScreen as? CustomScreen)?.getScreenInfo() } } - -// ImmerseStatusBar( -// enable = { customScreenInfo?.immerseStatusBar != false }, -// isExpended = { sheetNavigator.isVisible } -// ) + ImmerseStatusBar( + isExpended = { sheetController?.isVisible ?: false } + ) Box(modifier = Modifier.fillMaxSize()) { val currentScreen = remember { mutableStateOf(null) } From 5c34f3ffc174d777b836844d205f669c5a215ec7 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Mar 2024 00:36:51 +0800 Subject: [PATCH 014/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=AF=8F=E6=97=A5=E6=8E=A8=E8=8D=90=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=9A=84=E7=82=B9=E5=87=BB=E4=BA=8B=E4=BB=B6=E5=92=8C=E5=85=83?= =?UTF-8?q?=E7=B4=A0=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/extension/DailyRecommend.kt | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index be58ddc0a..69daca74a 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -1,33 +1,41 @@ package com.lalilu.lmusic.extension -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items +import androidx.compose.material.Chip +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.lalilu.component.extension.dayNightTextColor import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lmusic.compose.component.card.RecommendCard2 import com.lalilu.lmusic.compose.component.card.RecommendRow +import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.LibraryViewModel +@OptIn(ExperimentalMaterialApi::class) fun LazyListScope.dailyRecommend( libraryVM: LibraryViewModel, ) { item { - Text( - modifier = Modifier - .padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 10.dp) - .fillMaxWidth(), - text = "每日推荐", - style = MaterialTheme.typography.h6, - color = dayNightTextColor() - ) + RecommendTitle( + title = "每日推荐", + onClick = { + val ids = libraryVM.dailyRecommends.value.map { it.mediaId } + GlobalNavigatorImpl.showSongs(ids) + } + ) { + Chip(onClick = { libraryVM.forceUpdate() }) { + Text( + style = MaterialTheme.typography.caption, + text = "换一换" + ) + } + } } item { RecommendRow( @@ -44,16 +52,27 @@ fun LazyListScope.dailyRecommend( } +@OptIn(ExperimentalMaterialApi::class) fun LazyListScope.dailyRecommendVertical( libraryVM: LibraryViewModel, ) { item { - Text( - modifier = Modifier.wrapContentWidth(), - text = "每日推荐", - style = MaterialTheme.typography.h6, - color = dayNightTextColor() - ) + RecommendTitle( + modifier = Modifier.width(250.dp), + paddingValues = PaddingValues(), + title = "每日推荐", + onClick = { + val ids = libraryVM.dailyRecommends.value.map { it.mediaId } + GlobalNavigatorImpl.showSongs(ids) + } + ) { + Chip(onClick = { libraryVM.forceUpdate() }) { + Text( + style = MaterialTheme.typography.caption, + text = "换一换" + ) + } + } } items( items = libraryVM.dailyRecommends.value, From ac8b0da79d71d9680d8ab789337080409316c23b Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Mar 2024 00:38:13 +0800 Subject: [PATCH 015/213] =?UTF-8?q?[modify]=E6=8F=90=E5=8F=96RecommendTitl?= =?UTF-8?q?e=E7=BB=84=E4=BB=B6=E7=9A=84Padding=E4=B8=BA=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/component/card/RecommendTitle.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/card/RecommendTitle.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/card/RecommendTitle.kt index a905f1598..31d9f67b4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/card/RecommendTitle.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/card/RecommendTitle.kt @@ -29,6 +29,7 @@ import com.lalilu.lmusic.utils.recomposeHighlighter fun RecommendTitle( modifier: Modifier = Modifier, title: String, + paddingValues: PaddingValues = PaddingValues(horizontal = 20.dp), onClick: () -> Unit = {}, extraContent: @Composable RowScope.() -> Unit = {}, ) { @@ -36,7 +37,7 @@ fun RecommendTitle( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 20.dp) + .padding(paddingValues) .recomposeHighlighter(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -52,8 +53,18 @@ fun RecommendTitle( } @Composable -fun RecommendTitle(modifier: Modifier = Modifier, title: String, onClick: () -> Unit = {}) { - RecommendTitle(modifier = modifier, title = title, onClick = onClick) { +fun RecommendTitle( + modifier: Modifier = Modifier, + title: String, + paddingValues: PaddingValues = PaddingValues(horizontal = 20.dp), + onClick: () -> Unit = {} +) { + RecommendTitle( + modifier = modifier, + title = title, + paddingValues = paddingValues, + onClick = onClick + ) { Icon( painter = painterResource(id = R.drawable.ic_arrow_right_s_line), contentDescription = "", From c1f8fb7722a264803665cd170df8859163a7cf7d Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Mar 2024 00:38:38 +0800 Subject: [PATCH 016/213] =?UTF-8?q?[modify]=E6=8F=90=E5=8F=96NavigatorHead?= =?UTF-8?q?er=E7=BB=84=E4=BB=B6=E7=9A=84Padding=E4=B8=BA=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/component/base/NavigatorHeader.kt | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt b/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt index c60e2993f..b8c5ee26e 100644 --- a/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt +++ b/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding @@ -23,11 +24,18 @@ fun NavigatorHeader( @StringRes titleRes: Int, @StringRes subTitleRes: Int, titleScale: Float = 1f, + paddingValues: PaddingValues = PaddingValues( + top = 26.dp, + bottom = 20.dp, + start = 20.dp, + end = 20.dp + ), extraContent: @Composable RowScope.() -> Unit = {} ) = NavigatorHeader( modifier = modifier, title = stringResource(id = titleRes), subTitle = stringResource(id = subTitleRes), + paddingValues = paddingValues, titleScale = titleScale, extraContent = extraContent ) @@ -38,38 +46,49 @@ fun NavigatorHeader( title: String, subTitle: String, titleScale: Float = 1f, + paddingValues: PaddingValues = PaddingValues( + top = 26.dp, + bottom = 20.dp, + start = 20.dp, + end = 20.dp + ), extraContent: @Composable RowScope.() -> Unit = {} -) { - NavigatorHeader( - modifier = modifier, - title = title, - titleScale = titleScale, - rowExtraContent = extraContent, - columnExtraContent = { - if (subTitle.isNotBlank()) { - Text( - text = subTitle, - fontSize = 14.sp, - color = contentColorFor(backgroundColor = MaterialTheme.colors.background) - .copy(alpha = 0.5f) - ) - } +) = NavigatorHeader( + modifier = modifier, + title = title, + titleScale = titleScale, + rowExtraContent = extraContent, + paddingValues = paddingValues, + columnExtraContent = { + if (subTitle.isNotBlank()) { + Text( + text = subTitle, + fontSize = 14.sp, + color = contentColorFor(backgroundColor = MaterialTheme.colors.background) + .copy(alpha = 0.5f) + ) } - ) -} + } +) @Composable fun NavigatorHeader( modifier: Modifier = Modifier, title: String, titleScale: Float = 1f, + paddingValues: PaddingValues = PaddingValues( + top = 26.dp, + bottom = 20.dp, + start = 20.dp, + end = 20.dp + ), columnExtraContent: @Composable ColumnScope.() -> Unit = {}, rowExtraContent: @Composable RowScope.() -> Unit = {} ) { Row( verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(20.dp), - modifier = modifier.padding(top = 26.dp, bottom = 20.dp, start = 20.dp, end = 20.dp) + modifier = modifier.padding(paddingValues) ) { Column( modifier = Modifier.weight(1f), From 5870d003d82fb309b76d03dbbc39bdf37689cfa8 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Mar 2024 00:39:39 +0800 Subject: [PATCH 017/213] =?UTF-8?q?[modify]=E5=B0=86=E9=92=88=E5=AF=B9?= =?UTF-8?q?=E5=B9=B3=E6=9D=BF=E7=AB=AF=E9=80=82=E9=85=8D=E7=9A=84=E6=96=AD?= =?UTF-8?q?=E7=82=B9=E9=80=BB=E8=BE=91=E5=B0=81=E8=A3=85=E6=88=90TwoColumn?= =?UTF-8?q?WithPad=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/component/TwoColumnWithPad.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 component/src/main/java/com/lalilu/component/TwoColumnWithPad.kt diff --git a/component/src/main/java/com/lalilu/component/TwoColumnWithPad.kt b/component/src/main/java/com/lalilu/component/TwoColumnWithPad.kt new file mode 100644 index 000000000..fae3dd099 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/TwoColumnWithPad.kt @@ -0,0 +1,63 @@ +package com.lalilu.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TwoColumnWithPad( + modifier: Modifier = Modifier, + density: Density = LocalDensity.current, + minWidthBreakPoint: Dp = 500.dp, + modifierForPad: Modifier = Modifier, + modifierForNormal: Modifier = Modifier, + arrangementForPad: Arrangement.Vertical = Arrangement.Top, + arrangementForNormal: Arrangement.Vertical = Arrangement.Top, + columnForPad: LazyListScope.() -> Unit = {}, + columnForNormal: LazyListScope.(isPad: Boolean) -> Unit = {}, +) { + val paddingTop = WindowInsets.statusBarsIgnoringVisibility.asPaddingValues() + .calculateTopPadding() + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val isPad = with(density) { constraints.maxWidth.toDp() } > minWidthBreakPoint + + Row(modifier = Modifier.fillMaxSize()) { + if (isPad) { + LLazyColumn( + modifier = modifierForPad + .fillMaxHeight() + .wrapContentWidth(), + contentPadding = PaddingValues(top = paddingTop, start = 20.dp), + verticalArrangement = arrangementForPad, + content = columnForPad, + ) + } + LLazyColumn( + modifier = modifierForNormal + .fillMaxSize() + .weight(1f), + contentPadding = PaddingValues(top = paddingTop), + content = { columnForNormal(isPad) }, + verticalArrangement = arrangementForNormal + ) + } + } +} \ No newline at end of file From 363f3bed2efa6b0e60b19db3b36a762f1834d30e Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Mar 2024 01:03:57 +0800 Subject: [PATCH 018/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E5=96=84HomeScreen=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/new_screen/HomeScreen.kt | 83 ++++++------------- 1 file changed, 25 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt index 6a90b6c38..e6ef3193d 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt @@ -1,23 +1,11 @@ package com.lalilu.lmusic.compose.new_screen import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBarsIgnoringVisibility -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.lalilu.R -import com.lalilu.component.LLazyColumn +import com.lalilu.component.TwoColumnWithPad import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.TabScreen @@ -37,63 +25,42 @@ object HomeScreen : DynamicScreen(), TabScreen { icon = R.drawable.ic_loader_line ) - @OptIn(ExperimentalLayoutApi::class) @Composable override fun Content() { - val density = LocalDensity.current - val vm: LibraryViewModel = singleViewModel() + val libraryVM: LibraryViewModel = singleViewModel() val historyVM: HistoryViewModel = singleViewModel() val playingVM: PlayingViewModel = singleViewModel() - val paddingTop = WindowInsets.statusBarsIgnoringVisibility.asPaddingValues() - .calculateTopPadding() LaunchedEffect(Unit) { - vm.checkOrUpdateToday() + libraryVM.checkOrUpdateToday() } - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val expended = with(density) { constraints.maxWidth.toDp() } > 500.dp - - Row(modifier = Modifier.fillMaxSize()) { - if (expended) { - LLazyColumn( - modifier = Modifier - .fillMaxHeight() - .wrapContentWidth(), - contentPadding = PaddingValues(top = paddingTop, start = 20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - dailyRecommendVertical(libraryVM = vm) - } + TwoColumnWithPad( + arrangementForPad = Arrangement.spacedBy(10.dp), + columnForPad = { + dailyRecommendVertical(libraryVM = libraryVM) + }, + columnForNormal = { isPad -> + if (!isPad) { + dailyRecommend( + libraryVM = libraryVM, + ) } - LLazyColumn( - modifier = Modifier - .fillMaxSize() - .weight(1f), - contentPadding = PaddingValues(top = paddingTop) - ) { - if (!expended) { - dailyRecommend( - libraryVM = vm, - ) - } - - latestPanel( - libraryVM = vm, - playingVM = playingVM - ) + latestPanel( + libraryVM = libraryVM, + playingVM = playingVM + ) - historyPanel( - historyVM = historyVM, - playingVM = playingVM - ) + historyPanel( + historyVM = historyVM, + playingVM = playingVM + ) - item { - EntryPanel() - } + item { + EntryPanel() } } - } + ) } -} \ No newline at end of file +} From 04c29d9b07a049205532fe03f1684853c458a67d Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Mar 2024 01:04:34 +0800 Subject: [PATCH 019/213] =?UTF-8?q?[create]=E5=B0=81=E8=A3=85=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=9B=BE=E7=89=87=E4=BD=9C=E4=B8=BA=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E7=9A=84Box=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/detail/ImageBgBox.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt new file mode 100644 index 000000000..c364f8bb7 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt @@ -0,0 +1,46 @@ +package com.lalilu.lmusic.compose.screen.detail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.AsyncImage +import coil.request.ImageRequest + + +@Composable +fun ImageBgBox( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopCenter, + imageData: Any? = null, + imageModifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit = {}, +) { + val context = LocalContext.current + val model = remember(imageData) { + imageData?.let { + ImageRequest.Builder(context) + .data(it) + .crossfade(true) + .build() + } + } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = contentAlignment + ) { + AsyncImage( + modifier = imageModifier, + model = model, + contentScale = ContentScale.Crop, + contentDescription = "" + ) + content() + } +} \ No newline at end of file From 1ab872dc49983a3fa5df73fc523a370924abd382 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Mar 2024 01:04:56 +0800 Subject: [PATCH 020/213] =?UTF-8?q?[modify]=E6=B7=BB=E5=8A=A0forceUpdate?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lmusic/viewmodel/LibraryViewModel.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt index 106c5ebfa..d1ecd187a 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt @@ -9,6 +9,7 @@ import com.lalilu.lmusic.datastore.TempSp import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch import java.util.Calendar @OptIn(ExperimentalCoroutinesApi::class) @@ -22,15 +23,22 @@ class LibraryViewModel( .flatMapLatest { LMedia.flowMapBy(it ?: emptyList()) } .toState(emptyList(), viewModelScope) - fun checkOrUpdateToday() { + fun checkOrUpdateToday() = viewModelScope.launch { val today = Calendar.getInstance().get(Calendar.DAY_OF_YEAR) if (today != tempSp.dayOfYear.value || dailyRecommends.value.isEmpty()) { val ids = LMedia.get().shuffled().take(10).map { it.id } - if (ids.isEmpty()) return + if (ids.isEmpty()) return@launch tempSp.dayOfYear.value = today tempSp.dailyRecommends.value = ids } } + + fun forceUpdate() = viewModelScope.launch { + val ids = LMedia.get().shuffled().take(10).map { it.id } + if (ids.isEmpty()) return@launch + + tempSp.dailyRecommends.value = ids + } } \ No newline at end of file From 10aa8b8c134293d908cbd09dd7b4da677aef6375 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Mar 2024 01:07:11 +0800 Subject: [PATCH 021/213] =?UTF-8?q?[modify]=E6=8B=86=E5=88=86=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E5=B8=83=E5=B1=80=E9=80=BB=E8=BE=91=EF=BC=8C=E5=88=9D?= =?UTF-8?q?=E6=AD=A5=E5=AE=8C=E5=96=84=E5=B9=B3=E6=9D=BF=E7=AB=AF=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/new_screen/SongDetailScreen.kt | 324 ++++++++++-------- 1 file changed, 186 insertions(+), 138 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt index bbc5e711b..b399eb824 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt @@ -12,15 +12,17 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio 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.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Chip @@ -36,6 +38,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.graphicsLayer @@ -52,7 +55,7 @@ import coil.request.ImageRequest import com.lalilu.R import com.lalilu.component.IconButton import com.lalilu.component.IconTextButton -import com.lalilu.component.LLazyColumn +import com.lalilu.component.TwoColumnWithPad import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenAction @@ -71,10 +74,10 @@ import com.lalilu.lmusic.compose.component.card.SongInformationCard import com.lalilu.lmusic.compose.presenter.DetailScreenAction import com.lalilu.lmusic.compose.presenter.DetailScreenIsPlayingPresenter import com.lalilu.lmusic.compose.presenter.DetailScreenLikeBtnPresenter +import com.lalilu.lmusic.compose.screen.detail.ImageBgBox import com.lalilu.lmusic.utils.extension.EDGE_BOTTOM import com.lalilu.lmusic.utils.extension.checkActivityIsExist import com.lalilu.lmusic.utils.extension.edgeTransparent -import com.lalilu.lmusic.utils.recomposeHighlighter import com.lalilu.lplayer.extensions.QueueAction import org.koin.compose.koinInject @@ -152,28 +155,25 @@ data class SongDetailScreen( .collectAsState(initial = null) DetailScreen( - mediaId = { mediaId }, - getSong = { song.value } + mediaId = mediaId, + song = song.value ) } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) @Composable private fun DetailScreen( - mediaId: () -> String, - getSong: () -> LSong? + mediaId: String, + song: LSong? ) { val navigator: GlobalNavigator = koinInject() - val context = LocalContext.current - val song = getSong() if (song == null) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Text(text = "[Error]加载失败 #${mediaId()}") + Text(text = "[Error]加载失败 #${mediaId}") } return } @@ -187,163 +187,211 @@ private fun DetailScreen( } } - val intent = remember(song) { - Intent().apply { - component = ComponentName( - "com.xjcheng.musictageditor", - "com.xjcheng.musictageditor.SongDetailActivity" - ) - action = "android.intent.action.VIEW" - data = song.uri - } + ImageBgBox( + imageData = song, + imageModifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .blur(20.dp) + .edgeTransparent(position = EDGE_BOTTOM, percent = 1.5f) + .graphicsLayer { alpha = bgAlpha.value } + ) { + TwoColumnWithPad( + minWidthBreakPoint = 600.dp, + modifierForPad = Modifier.width(325.dp), + arrangementForNormal = Arrangement.spacedBy(16.dp), + columnForPad = { + songHeadContent(song, navigator) + }, + columnForNormal = { isPad -> + if (!isPad) { + songHeadContent(song, navigator) + } + + songAlbumInfoCard(song, navigator) + + songActionsCard(song, navigator) + + songDetailInfoCard(song, navigator) + } + ) } +} - Box( - modifier = Modifier - .fillMaxSize() - .recomposeHighlighter(), - contentAlignment = Alignment.TopCenter - ) { + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) +fun LazyListScope.songHeadContent( + song: LSong, + navigator: GlobalNavigator +) { + item { AsyncImage( modifier = Modifier .fillMaxWidth() - .aspectRatio(1f) - .edgeTransparent(position = EDGE_BOTTOM, percent = 1.5f) - .graphicsLayer { alpha = bgAlpha.value }, - model = ImageRequest.Builder(context) + .aspectRatio(1f), + model = ImageRequest.Builder(LocalContext.current) .data(song) .crossfade(true) .build(), contentScale = ContentScale.Crop, contentDescription = "" ) - - LLazyColumn( - modifier = Modifier - .fillMaxSize() - .recomposeHighlighter(), - verticalArrangement = Arrangement.spacedBy(15.dp) - ) { - item { - Spacer(modifier = Modifier.height(150.dp)) - } - - item { - NavigatorHeader( - title = song.name, - columnExtraContent = { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - song.artists.forEach { - Chip( - onClick = { navigator.navigateTo(ArtistDetailScreen(artistName = it.name)) }, - colors = ChipDefaults.outlinedChipColors(), - ) { - Text( - text = it.name, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = contentColorFor(backgroundColor = MaterialTheme.colors.background) - .copy(alpha = 0.7f) - ) - } - } - } - } - ) - } - - song.album?.let { - item { - Surface( - modifier = Modifier.padding(start = 20.dp, end = 20.dp), - shape = RoundedCornerShape(20.dp), - onClick = { navigator.navigateTo(AlbumDetailScreen(albumId = it.id)) } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp) + } + item { + NavigatorHeader( + title = song.name, + paddingValues = PaddingValues(vertical = 20.dp), + columnExtraContent = { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + song.artists.forEach { + Chip( + onClick = { + navigator.navigateTo(ArtistDetailScreen(artistName = it.name)) + }, + colors = ChipDefaults.outlinedChipColors(), ) { - RecommendCardCover( - width = { 125.dp }, - height = { 125.dp }, - imageData = { it } + Text( + text = it.name, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = contentColorFor(backgroundColor = MaterialTheme.colors.background) + .copy(alpha = 0.7f) ) - Column( - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = it.name, - style = MaterialTheme.typography.subtitle1, - color = dayNightTextColor() - ) - it.artistName?.let { it1 -> - Text( - text = it1, - style = MaterialTheme.typography.subtitle2, - color = dayNightTextColor(0.5f) - ) - } - } } } } } + ) + } +} - item { +@OptIn(ExperimentalMaterialApi::class) +fun LazyListScope.songAlbumInfoCard( + song: LSong, + navigator: GlobalNavigator +) { + song.album?.let { + item { + Surface( + modifier = Modifier.padding(start = 20.dp, end = 20.dp), + shape = RoundedCornerShape(20.dp), + onClick = { navigator.navigateTo(AlbumDetailScreen(albumId = it.id)) } + ) { Row( modifier = Modifier .fillMaxWidth() - .wrapContentHeight() - .padding(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(15.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - if (context.checkActivityIsExist(intent)) { - IconTextButton( - text = "音乐标签编辑", - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(10.dp), - color = Color(0xFF3EA22C), - onClick = { - if (context.checkActivityIsExist(intent)) { - context.startActivity(intent) - } else { - Toast.makeText(context, "未安装[音乐标签]", Toast.LENGTH_SHORT) - .show() - } - } + RecommendCardCover( + width = { 125.dp }, + height = { 125.dp }, + imageData = { it } + ) + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = it.name, + style = MaterialTheme.typography.subtitle1, + color = dayNightTextColor() ) - } - IconTextButton( - text = "搜索LrcShare", - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(10.dp), - color = Color(0xFF3EA22C), - onClick = { - navigator.navigateTo( - SearchLyricScreen( - mediaId = song.id, - keywords = song.name - ) + it.artistName?.let { it1 -> + Text( + text = it1, + style = MaterialTheme.typography.subtitle2, + color = dayNightTextColor(0.5f) ) } - ) + } } } + } + } +} + +fun LazyListScope.songActionsCard( + song: LSong, + navigator: GlobalNavigator +) { + item { + val context = LocalContext.current + val intent = remember(song) { + Intent().apply { + component = ComponentName( + "com.xjcheng.musictageditor", + "com.xjcheng.musictageditor.SongDetailActivity" + ) + action = "android.intent.action.VIEW" + data = song.uri + } + } - item { - SongInformationCard( - modifier = Modifier.fillMaxWidth(), - song = song + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (context.checkActivityIsExist(intent)) { + IconTextButton( + text = "音乐标签编辑", + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(10.dp), + color = Color(0xFF3EA22C), + onClick = { + if (context.checkActivityIsExist(intent)) { + context.startActivity(intent) + } else { + Toast.makeText( + context, + "未安装[音乐标签]", + Toast.LENGTH_SHORT + ) + .show() + } + } ) } + IconTextButton( + text = "搜索LrcShare", + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(10.dp), + color = Color(0xFF3EA22C), + onClick = { + navigator.navigateTo( + SearchLyricScreen( + mediaId = song.id, + keywords = song.name + ) + ) + } + ) } } +} + +fun LazyListScope.songDetailInfoCard( + song: LSong, + navigator: GlobalNavigator +) { + item { + SongInformationCard( + modifier = Modifier.fillMaxWidth(), + song = song + ) + } +} + +fun LazyListScope.songLyricCard( + song: LSong +) { + } \ No newline at end of file From a6a99e1e1275531c94ee80b33d891f614ee43eec Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 19 Mar 2024 23:58:37 +0800 Subject: [PATCH 022/213] =?UTF-8?q?[modify]=E6=8F=90=E5=8F=96columnExtraSp?= =?UTF-8?q?ace=E3=80=81rowExtraSpace=E4=B8=BA=E5=8F=AF=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E7=9A=84=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/lalilu/component/base/NavigatorHeader.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt b/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt index b8c5ee26e..c840883af 100644 --- a/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt +++ b/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -82,21 +83,24 @@ fun NavigatorHeader( start = 20.dp, end = 20.dp ), + columnExtraSpace: Dp = 15.dp, + rowExtraSpace: Dp = 20.dp, columnExtraContent: @Composable ColumnScope.() -> Unit = {}, rowExtraContent: @Composable RowScope.() -> Unit = {} ) { Row( verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(20.dp), + horizontalArrangement = Arrangement.spacedBy(rowExtraSpace), modifier = modifier.padding(paddingValues) ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(15.dp) + verticalArrangement = Arrangement.spacedBy(columnExtraSpace) ) { Text( text = title, fontSize = 26.sp * titleScale, + lineHeight = 26.sp * titleScale * 1.5f, color = contentColorFor(backgroundColor = MaterialTheme.colors.background) ) columnExtraContent() From 0e676ad1c1023226b89cc63218f0ca37350f92da Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 19 Mar 2024 23:59:06 +0800 Subject: [PATCH 023/213] =?UTF-8?q?[modify]=E7=A7=BB=E9=99=A4=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=AE=8C=E5=96=84=E5=B9=B3?= =?UTF-8?q?=E6=9D=BF=E7=AB=AF=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/new_screen/SongDetailScreen.kt | 130 +++++++++--------- 1 file changed, 67 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt index b399eb824..829c7ddd1 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Chip import androidx.compose.material.ChipDefaults @@ -34,14 +33,12 @@ import androidx.compose.material.Text import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -62,7 +59,6 @@ import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.ScreenInfo import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.rememberScrollPosition import com.lalilu.component.navigation.GlobalNavigator import com.lalilu.lalbum.screen.AlbumDetailScreen import com.lalilu.lartist.screen.ArtistDetailScreen @@ -178,34 +174,25 @@ private fun DetailScreen( return } - val listState = rememberLazyListState() - val scrollPosition = rememberScrollPosition(state = listState) - val bgAlpha = remember { - derivedStateOf { - return@derivedStateOf 1f - (scrollPosition.value / 500f) - .coerceIn(0f, 0.8f) - } - } - ImageBgBox( imageData = song, imageModifier = Modifier .fillMaxWidth() - .aspectRatio(1f) + .fillMaxHeight() .blur(20.dp) .edgeTransparent(position = EDGE_BOTTOM, percent = 1.5f) - .graphicsLayer { alpha = bgAlpha.value } ) { TwoColumnWithPad( minWidthBreakPoint = 600.dp, modifierForPad = Modifier.width(325.dp), + arrangementForPad = Arrangement.spacedBy(16.dp), arrangementForNormal = Arrangement.spacedBy(16.dp), columnForPad = { - songHeadContent(song, navigator) + songHeadContent(song = song, navigator = navigator, isPad = true) }, columnForNormal = { isPad -> if (!isPad) { - songHeadContent(song, navigator) + songHeadContent(song = song, navigator = navigator, isPad = false) } songAlbumInfoCard(song, navigator) @@ -221,26 +208,38 @@ private fun DetailScreen( @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) fun LazyListScope.songHeadContent( + isPad: Boolean = false, song: LSong, navigator: GlobalNavigator ) { item { - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - model = ImageRequest.Builder(LocalContext.current) - .data(song) - .crossfade(true) - .build(), - contentScale = ContentScale.Crop, - contentDescription = "" - ) + Surface( + modifier = Modifier.padding( + horizontal = if (isPad) 0.dp else 20.dp, + vertical = if (isPad) 0.dp else 10.dp + ), + elevation = 2.dp, + shape = RoundedCornerShape(10.dp) + ) { + AsyncImage( + modifier = Modifier + .fillMaxWidth(), + model = ImageRequest.Builder(LocalContext.current) + .data(song) + .crossfade(true) + .build(), + contentScale = ContentScale.FillWidth, + contentDescription = "" + ) + } } item { NavigatorHeader( title = song.name, - paddingValues = PaddingValues(vertical = 20.dp), + columnExtraSpace = 5.dp, + paddingValues = PaddingValues( + horizontal = if (isPad) 0.dp else 20.dp + ), columnExtraContent = { FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { song.artists.forEach { @@ -328,52 +327,57 @@ fun LazyListScope.songActionsCard( } } - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(15.dp), - verticalAlignment = Alignment.CenterVertically, + Surface( + modifier = Modifier.padding(horizontal = 20.dp), + shape = RoundedCornerShape(20.dp) ) { - if (context.checkActivityIsExist(intent)) { + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (context.checkActivityIsExist(intent)) { + IconTextButton( + text = "音乐标签编辑", + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(10.dp), + color = Color(0xFF3EA22C), + onClick = { + if (context.checkActivityIsExist(intent)) { + context.startActivity(intent) + } else { + Toast.makeText( + context, + "未安装[音乐标签]", + Toast.LENGTH_SHORT + ) + .show() + } + } + ) + } IconTextButton( - text = "音乐标签编辑", + text = "搜索LrcShare", modifier = Modifier .weight(1f) .height(48.dp), shape = RoundedCornerShape(10.dp), color = Color(0xFF3EA22C), onClick = { - if (context.checkActivityIsExist(intent)) { - context.startActivity(intent) - } else { - Toast.makeText( - context, - "未安装[音乐标签]", - Toast.LENGTH_SHORT + navigator.navigateTo( + SearchLyricScreen( + mediaId = song.id, + keywords = song.name ) - .show() - } + ) } ) } - IconTextButton( - text = "搜索LrcShare", - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(10.dp), - color = Color(0xFF3EA22C), - onClick = { - navigator.navigateTo( - SearchLyricScreen( - mediaId = song.id, - keywords = song.name - ) - ) - } - ) } } } From d55d3cda4d3b95fa9aac7a2b05c4ff04f5371586 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 29 Mar 2024 13:25:29 +0800 Subject: [PATCH 024/213] =?UTF-8?q?[modify]=E6=8B=86=E5=88=86PlayingLayout?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=B5=8C=E5=A5=97=E6=BB=9A=E5=8A=A8=E5=92=8C?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E9=80=BB=E8=BE=91=EF=BC=8C=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E5=9C=A8RecyclerView=E4=B8=8A=E5=BF=AB=E9=80=9F=E6=BB=91?= =?UTF-8?q?=E5=8A=A8=E5=AF=BC=E8=87=B4=E8=A7=A6=E6=91=B8=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E8=A2=AB=E5=BA=95=E9=83=A8=E7=9A=84LyricLayout=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/BlurBackground.kt | 4 +- .../screen/playing/NestedScrollBaseLayout.kt | 173 +++++++ .../compose/screen/playing/PlayingLayout.kt | 474 +++++++----------- 3 files changed, 358 insertions(+), 293 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt index 039b50d34..090e845b7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.palette.graphics.Palette -import coil.compose.SubcomposeAsyncImage +import coil.compose.AsyncImage import coil.request.ImageRequest import com.lalilu.common.getAutomaticColor import com.lalilu.lmusic.utils.StackBlurUtils @@ -50,7 +50,7 @@ fun BlurBackground( val srcRect = remember { Rect() } val targetRect = remember { Rect() } - SubcomposeAsyncImage( + AsyncImage( modifier = Modifier .fillMaxSize() .drawWithContent { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt new file mode 100644 index 000000000..0f85ee138 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt @@ -0,0 +1,173 @@ +package com.lalilu.lmusic.compose.screen.playing + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Velocity +import kotlin.math.roundToInt + +enum class ScrollingItemType { + LyricView, + RecyclerView +} + +@Composable +fun NestedScrollBaseLayout( + draggable: CustomAnchoredDraggableState, + isLyricScrollEnable: MutableState, + scrollingItemType: () -> ScrollingItemType? = { null }, + toolbarContent: @Composable () -> Unit = {}, + dynamicHeaderContent: @Composable (Constraints) -> Unit = { }, + recyclerViewContent: @Composable () -> Unit = {}, + overlayContent: @Composable BoxScope.() -> Unit = {}, +) { + val haptic = LocalHapticFeedback.current + + + BackHandler(draggable.state.value == DragAnchor.Max) { + if (draggable.state.value == DragAnchor.Max) { + draggable.animateToState(DragAnchor.Middle) + } + } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override suspend fun onPreFling(available: Velocity): Velocity { + // 若非RecyclerView的滚动,则消费y轴上的所有速度,避免嵌套滚动事件继续 + if (scrollingItemType() == null) { + draggable.fling(available.y) + return available + } + + return super.onPreFling(available) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + if (consumed.y != 0f && available.y == 0f) { + draggable.fling(0f) + } + return super.onPostFling(consumed, available) + } + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // 取消正在进行的动画事件 + draggable.tryCancel() + + return when (scrollingItemType()) { + /** + * 若是LyricView的滑动事件,则需判断当前LyricView是否处于可拖动歌词的状态 + * + */ + ScrollingItemType.LyricView -> { + if ( + !isLyricScrollEnable.value + && available.y > 0 + && source == NestedScrollSource.Drag + && draggable.position.floatValue.toInt() + == draggable.getPositionByAnchor(DragAnchor.Max) + ) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + isLyricScrollEnable.value = true + } + + if (isLyricScrollEnable.value) { + super.onPreScroll(available, source) + } else { + if (source == NestedScrollSource.Drag) { + draggable.scrollBy(available.y) + } + available + } + } + + /** + * 若是RecyclerView的滑动事件,则区分上滑和下滑的情况 + * 上滑:首先交由draggable消费,剩余的传递给后续的RecyclerView消费 + * 下滑:直接全部传递给后续的RecyclerView消费 + */ + ScrollingItemType.RecyclerView -> { + if (available.y < 0) available.copy(y = draggable.scrollBy(available.y)) + else super.onPreScroll(available, source) + } + + // 前面的条件都不满足,则将该事件全部消费,避免未知的子组件产生动作 + else -> available + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset = when (scrollingItemType()) { + ScrollingItemType.LyricView -> { + if (isLyricScrollEnable.value) { + super.onPreScroll(available, source) + } else { + draggable.scrollBy(available.y) + available + } + } + + ScrollingItemType.RecyclerView -> { + if (available.y > 0) available.copy(y = draggable.scrollBy(available.y)) + else super.onPostScroll(consumed, available, source) + } + + else -> super.onPostScroll(consumed, available, source) + } + } + } + + BoxWithConstraints { + Layout( + modifier = Modifier + .nestedScroll(nestedScrollConnection), + content = { + toolbarContent() + dynamicHeaderContent(constraints) + recyclerViewContent() + } + ) { measurables, constraints -> + val toolbar = measurables[0].measure(constraints) + val background = measurables[1].measure(constraints) + + val cConstraints = constraints + .copy(maxHeight = constraints.maxHeight - toolbar.height) + val recyclerView = measurables[2].measure(cConstraints) + + draggable.updateAnchor( + min = toolbar.height, + middle = constraints.maxWidth + .coerceAtMost(constraints.maxHeight / 2), // 限制中间的锚点不能超过容器高度的一半 + max = constraints.maxHeight + ) + + layout( + width = constraints.maxWidth, + height = constraints.maxHeight + ) { + val animateOffset = draggable.position.floatValue.roundToInt() + .coerceIn(toolbar.height, constraints.maxHeight) + + background.place(0, animateOffset - background.height) + toolbar.place(0, animateOffset - toolbar.height) + recyclerView.place(0, animateOffset) + } + } + + overlayContent() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 07bba1587..c9b04492c 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -2,13 +2,12 @@ package com.lalilu.lmusic.compose.screen.playing import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.DecelerateInterpolator -import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -26,24 +25,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.dirror.lyricviewx.LyricUtil import com.google.accompanist.systemuicontroller.rememberSystemUiController -import com.lalilu.common.HapticUtils import com.lalilu.component.extension.hideControl import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar @@ -56,8 +45,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.mapLatest import org.koin.compose.koinInject import kotlin.math.pow -import kotlin.math.roundToInt - @OptIn(ExperimentalCoroutinesApi::class) @Composable @@ -65,7 +52,6 @@ fun PlayingLayout( playingVM: PlayingViewModel = singleViewModel(), settingsSp: SettingsSp = koinInject() ) { - val view = LocalView.current val haptic = LocalHapticFeedback.current val systemUiController = rememberSystemUiController() val lyricLayoutLazyListState = rememberLazyListState() @@ -75,11 +61,11 @@ fun PlayingLayout( val backgroundColor = remember { mutableStateOf(Color.DarkGray) } val animateColor = animateColorAsState(targetValue = backgroundColor.value, label = "") val scrollToTopEvent = remember { mutableStateOf(0L) } - val seekbarTime = remember { mutableLongStateOf(0L) } + val draggable = rememberCustomAnchoredDraggableState { oldState, newState -> if (newState == DragAnchor.MiddleXMax && oldState != DragAnchor.MiddleXMax) { - HapticUtils.haptic(view, HapticUtils.Strength.HAPTIC_STRONG) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) } if (newState != DragAnchor.Max) { isLyricScrollEnable.value = false @@ -96,301 +82,207 @@ fun PlayingLayout( systemUiController.isStatusBarVisible = !hideComponent.value } - BackHandler(draggable.state.value == DragAnchor.Max) { - if (draggable.state.value == DragAnchor.Max) { - draggable.animateToState(DragAnchor.Middle) - } - } - - val nestedScrollConnection = remember { - object : NestedScrollConnection { - override suspend fun onPreFling(available: Velocity): Velocity { - // 若非RecyclerView的滚动,则消费y轴上的所有速度,避免嵌套滚动事件继续 - if (!recyclerViewScrollState.value && !isLyricScrollEnable.value) { - draggable.fling(available.y) - return available - } - - return super.onPreFling(available) + NestedScrollBaseLayout( + draggable = draggable, + isLyricScrollEnable = isLyricScrollEnable, + scrollingItemType = { + when { + lyricLayoutLazyListState.isScrollInProgress -> ScrollingItemType.LyricView + recyclerViewScrollState.value -> ScrollingItemType.RecyclerView + else -> null } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - if (consumed.y != 0f && available.y == 0f) { - draggable.fling(0f) - } - return super.onPostFling(consumed, available) + }, + toolbarContent = { + Column( + modifier = Modifier + .hideControl( + enable = { hideComponent.value }, + intercept = { true } + ) + .fillMaxWidth() + .statusBarsPadding() + .padding(bottom = 10.dp) + ) { + PlayingToolbar( + isItemPlaying = { mediaId -> playingVM.isItemPlaying { it.mediaId == mediaId } }, + isUserTouchEnable = { draggable.state.value == DragAnchor.Min || draggable.state.value == DragAnchor.Max }, + isExtraVisible = { draggable.state.value == DragAnchor.Max }, + onClick = { scrollToTopEvent.value = System.currentTimeMillis() }, + extraContent = { LyricViewToolbar() } + ) } - - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - // 取消正在进行的动画事件 - draggable.tryCancel() - - return when { - lyricLayoutLazyListState.isScrollInProgress -> { - if ( - !isLyricScrollEnable.value - && available.y > 0 - && source == NestedScrollSource.Drag - && draggable.position.floatValue.toInt() - == draggable.getPositionByAnchor(DragAnchor.Max) - ) { - HapticUtils.haptic(view, HapticUtils.Strength.HAPTIC_STRONG) - isLyricScrollEnable.value = true - } - - if (isLyricScrollEnable.value) { - super.onPreScroll(available, source) - } else { - if (source == NestedScrollSource.Drag) { - draggable.scrollBy(available.y) - } - available - } - } - - recyclerViewScrollState.value -> { - if (available.y < 0) available.copy(y = draggable.scrollBy(available.y)) - else super.onPreScroll(available, source) - } - - // 前面的条件都不满足,则将该事件全部消费,避免未知的子组件产生动作 - else -> available + }, + dynamicHeaderContent = { constraints -> + Box( + modifier = Modifier + .fillMaxSize() + .clipToBounds() + .background(color = animateColor.value) + ) { + val adInterpolator = remember { AccelerateDecelerateInterpolator() } + val dInterpolator = remember { DecelerateInterpolator() } + val transition: (Float) -> Float = remember { + { x -> -2f * (x - 0.5f).pow(2) + 0.5f } } - } - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - return when { - lyricLayoutLazyListState.isScrollInProgress -> { - if (isLyricScrollEnable.value) { - super.onPreScroll(available, source) - } else { - draggable.scrollBy(available.y) - available + val flow = remember { + playingVM.lyricRepository.currentLyric + .mapLatest { + LyricUtil + .parseLrc(arrayOf(it?.first, it?.second)) + ?.mapIndexed { index, lyricEntry -> + LyricEntry( + index = index, + time = lyricEntry.time, + text = lyricEntry.text, + translate = lyricEntry.secondText + ) + } + ?: emptyList() } + } + val lyricEntry = flow.collectAsState(initial = emptyList()) + val minToMiddleProgress = remember { + derivedStateOf { + draggable.progressBetween( + from = DragAnchor.Min, + to = DragAnchor.Middle, + offset = draggable.position.floatValue + ) } - - recyclerViewScrollState.value -> { - if (available.y > 0) available.copy(y = draggable.scrollBy(available.y)) - else super.onPostScroll(consumed, available, source) + } + val middleToMaxProgress = remember { + derivedStateOf { + draggable.progressBetween( + from = DragAnchor.Middle, + to = DragAnchor.Max, + offset = draggable.position.floatValue + ) } - - else -> super.onPostScroll(consumed, available, source) } - } - } - } - BoxWithConstraints { - Layout( - modifier = Modifier - .nestedScroll(nestedScrollConnection), - content = { - Column( + BlurBackground( modifier = Modifier - .hideControl( - enable = { hideComponent.value }, - intercept = { true } - ) .fillMaxWidth() - .statusBarsPadding() - .padding(bottom = 10.dp) - ) { - PlayingToolbar( - isItemPlaying = { mediaId -> playingVM.isItemPlaying { it.mediaId == mediaId } }, - isUserTouchEnable = { draggable.state.value == DragAnchor.Min || draggable.state.value == DragAnchor.Max }, - isExtraVisible = { draggable.state.value == DragAnchor.Max }, - onClick = { scrollToTopEvent.value = System.currentTimeMillis() }, - extraContent = { LyricViewToolbar() } - ) - } + .aspectRatio(1f) + .graphicsLayer { + val maxHeight = constraints.maxHeight + val maxWidth = constraints.maxWidth + + // min至middle阶段中的位移 + val minToMiddleInterpolated = + dInterpolator.getInterpolation(minToMiddleProgress.value) + val minToMiddleOffset = + lerp(-size.width / 2f, 0f, minToMiddleInterpolated) + + // middle至max阶段中的位移 + val middleToMaxInterpolated = + dInterpolator.getInterpolation(middleToMaxProgress.value) + val middleToMaxOffset = + lerp(0f, (maxHeight - maxWidth) / 2f, middleToMaxInterpolated) + + // 用于补偿修正因layout时根据draggable的值进行布局的位移 + val fixOffset = maxHeight - draggable.position.floatValue + + // 添加凸显滑动时的动画的位移 + val progressTransited = transition(middleToMaxProgress.value) + val additionalOffset = progressTransited * 200f + + // 计算父级容器的长宽比,计算需要覆盖父级容器的的缩放比例的值scale + val aspectRatio = maxHeight.toFloat() / maxWidth.toFloat() + val scale = lerp(1f, aspectRatio, middleToMaxProgress.value) + + translationY = + minToMiddleOffset + middleToMaxOffset + fixOffset + additionalOffset + alpha = minToMiddleProgress.value + scaleY = scale + scaleX = scale + }, + blurProgress = { middleToMaxProgress.value }, + onBackgroundColorFetched = { backgroundColor.value = it }, + imageData = { + playingVM.playing.value + ?: com.lalilu.component.R.drawable.ic_music_2_line_100dp + } + ) - Box( + LyricLayout( modifier = Modifier .fillMaxSize() - .drawWithContent { - clipRect(0f, 0f, size.width, draggable.position.floatValue) { - drawRect(animateColor.value) - this@drawWithContent.drawContent() - } - } - ) { - val adInterpolator = remember { AccelerateDecelerateInterpolator() } - val dInterpolator = remember { DecelerateInterpolator() } - val transition: (Float) -> Float = remember { - { x -> -2f * (x - 0.5f).pow(2) + 0.5f } - } - - val flow = remember { - playingVM.lyricRepository.currentLyric - .mapLatest { - LyricUtil - .parseLrc(arrayOf(it?.first, it?.second)) - ?.mapIndexed { index, lyricEntry -> - LyricEntry( - index = index, - time = lyricEntry.time, - text = lyricEntry.text, - translate = lyricEntry.secondText - ) - } - ?: emptyList() - } - } - val lyricEntry = flow.collectAsState(initial = emptyList()) - val minToMiddleProgress = remember { - derivedStateOf { - draggable.progressBetween( - from = DragAnchor.Min, - to = DragAnchor.Middle, - offset = draggable.position.floatValue - ) - } - } - val middleToMaxProgress = remember { - derivedStateOf { - draggable.progressBetween( - from = DragAnchor.Middle, - to = DragAnchor.Max, - offset = draggable.position.floatValue - ) - } - } + .graphicsLayer { + val interpolation = + adInterpolator.getInterpolation(middleToMaxProgress.value) + val progressIncrease = (2 * interpolation - 1F).coerceAtLeast(0F) - BlurBackground( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .graphicsLayer { - val progress = middleToMaxProgress.value - val dProgress = dInterpolator.getInterpolation(progress) - val minTop = - (this@BoxWithConstraints.constraints.maxHeight - this@BoxWithConstraints.constraints.maxWidth) / 2f - val offsetTop = lerp(0f, minTop, dProgress) + val fixOffset = size.height - draggable.position.floatValue - val aspectRatio = - this@BoxWithConstraints.constraints.maxHeight.toFloat() / this@BoxWithConstraints.constraints.maxWidth.toFloat() - val scale = lerp(1f, aspectRatio, progress) + val progressTransited = transition(middleToMaxProgress.value) + val additionalOffset = progressTransited * 200f * 3f - val floatProgress = transition(middleToMaxProgress.value) - val translation = floatProgress * 200f - - alpha = minToMiddleProgress.value - translationY = offsetTop + translation - scaleY = scale - scaleX = scale - }, - blurProgress = { middleToMaxProgress.value }, - onBackgroundColorFetched = { backgroundColor.value = it }, - imageData = { - playingVM.playing.value - ?: com.lalilu.component.R.drawable.ic_music_2_line_100dp - } - ) - - LyricLayout( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - val interpolation = - adInterpolator.getInterpolation(middleToMaxProgress.value) - val progressIncrease = (2 * interpolation - 1F).coerceAtLeast(0F) - - val floatProgress = transition(middleToMaxProgress.value) - val translation = floatProgress * 200f - - translationY = translation * 3f - alpha = progressIncrease - }, - lyricEntry = lyricEntry, - listState = lyricLayoutLazyListState, - currentTime = { seekbarTime.longValue }, - maxWidth = { this@BoxWithConstraints.constraints.maxWidth }, - textSize = rememberTextSizeFromInt { settingsSp.lyricTextSize.value }, - textAlign = rememberTextAlignFromGravity { settingsSp.lyricGravity.value }, - fontFamily = rememberFontFamilyFromPath { settingsSp.lyricTypefacePath.value }, - isBlurredEnable = { !isLyricScrollEnable.value && settingsSp.isEnableBlurEffect.value }, - isTranslationShow = { settingsSp.isDrawTranslation.value }, - isUserClickEnable = { draggable.state.value == DragAnchor.Max }, - isUserScrollEnable = { isLyricScrollEnable.value }, - onPositionReset = { - if (isLyricScrollEnable.value) { - isLyricScrollEnable.value = false - } - }, - onItemClick = { - if (isLyricScrollEnable.value) { - isLyricScrollEnable.value = false - } - LPlayer.controller.doAction(PlayerAction.SeekTo(it.time)) - }, - onItemLongClick = { - if (draggable.state.value == DragAnchor.Max) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - isLyricScrollEnable.value = !isLyricScrollEnable.value - } + translationY = additionalOffset + fixOffset + alpha = progressIncrease }, - ) - } - - CustomRecyclerView( - modifier = Modifier.clipToBounds(), - scrollToTopEvent = { scrollToTopEvent.value }, - onScrollStart = { recyclerViewScrollState.value = true }, - onScrollTouchUp = { }, - onScrollIdle = { - recyclerViewScrollState.value = false - draggable.fling(0f) - } + lyricEntry = lyricEntry, + listState = lyricLayoutLazyListState, + currentTime = { seekbarTime.longValue }, + maxWidth = { constraints.maxWidth }, + textSize = rememberTextSizeFromInt { settingsSp.lyricTextSize.value }, + textAlign = rememberTextAlignFromGravity { settingsSp.lyricGravity.value }, + fontFamily = rememberFontFamilyFromPath { settingsSp.lyricTypefacePath.value }, + isBlurredEnable = { !isLyricScrollEnable.value && settingsSp.isEnableBlurEffect.value }, + isTranslationShow = { settingsSp.isDrawTranslation.value }, + isUserClickEnable = { draggable.state.value == DragAnchor.Max }, + isUserScrollEnable = { isLyricScrollEnable.value }, + onPositionReset = { + if (isLyricScrollEnable.value) { + isLyricScrollEnable.value = false + } + }, + onItemClick = { + if (isLyricScrollEnable.value) { + isLyricScrollEnable.value = false + } + LPlayer.controller.doAction(PlayerAction.SeekTo(it.time)) + }, + onItemLongClick = { + if (draggable.state.value == DragAnchor.Max) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + isLyricScrollEnable.value = !isLyricScrollEnable.value + } + }, ) } - ) { measurables, constraints -> - val minHeader = measurables[0].measure(constraints) - - val picture = measurables[1].measure(constraints) - - val cConstraints = - constraints.copy(maxHeight = constraints.maxHeight - minHeader.height) - val column = measurables[2].measure(cConstraints) - - draggable.updateAnchor( - min = minHeader.height, - middle = constraints.maxWidth, - max = constraints.maxHeight + }, + recyclerViewContent = { + CustomRecyclerView( + modifier = Modifier.clipToBounds(), + scrollToTopEvent = { scrollToTopEvent.value }, + onScrollStart = { recyclerViewScrollState.value = true }, + onScrollTouchUp = { }, + onScrollIdle = { + recyclerViewScrollState.value = false + draggable.fling(0f) + } + ) + }, + overlayContent = { + val animateProgress = animateFloatAsState( + targetValue = if (!isLyricScrollEnable.value) 100f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "" ) - layout( - width = constraints.maxWidth, - height = constraints.maxHeight - ) { - val animateOffset = draggable.position.floatValue.roundToInt() - .coerceIn(minHeader.height, constraints.maxHeight) - - picture.place(0, 0) - minHeader.place(0, animateOffset - minHeader.height) - column.place(0, animateOffset) - } + SeekbarLayout( + modifier = Modifier + .align(Alignment.BottomCenter) + .graphicsLayer { + alpha = animateProgress.value / 100f + translationY = (1f - animateProgress.value / 100f) * 500f + }, + seekBarModifier = Modifier.hideControl(enable = { hideComponent.value }), + onValueChange = { seekbarTime.longValue = it }, + animateColor = animateColor + ) } - - val animateProgress = animateFloatAsState( - targetValue = if (!isLyricScrollEnable.value) 100f else 0f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "" - ) - - SeekbarLayout( - modifier = Modifier - .align(Alignment.BottomCenter) - .graphicsLayer { - alpha = animateProgress.value / 100f - translationY = (1f - animateProgress.value / 100f) * 500f - }, - seekBarModifier = Modifier.hideControl(enable = { hideComponent.value }), - onValueChange = { seekbarTime.longValue = it }, - animateColor = animateColor - ) - } + ) } \ No newline at end of file From 038f030c4b2ecda6cc4a180ad4e3c11be727f706 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 4 Apr 2024 18:15:05 +0800 Subject: [PATCH 025/213] =?UTF-8?q?[fix]=E4=BF=AE=E5=A4=8D=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E7=9A=84=E6=95=88=E6=9E=9C=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/screen/playing/NestedScrollBaseLayout.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt index 0f85ee138..8d6e584c4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt @@ -46,7 +46,7 @@ fun NestedScrollBaseLayout( object : NestedScrollConnection { override suspend fun onPreFling(available: Velocity): Velocity { // 若非RecyclerView的滚动,则消费y轴上的所有速度,避免嵌套滚动事件继续 - if (scrollingItemType() == null) { + if (ScrollingItemType.RecyclerView != scrollingItemType() && !isLyricScrollEnable.value) { draggable.fling(available.y) return available } From 14e635b7fbf27464f6c1d9d799a086fbee26fe66 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 29 Apr 2024 23:12:10 +0800 Subject: [PATCH 026/213] =?UTF-8?q?[create]=E5=A4=8D=E5=88=B6ModalBottomSh?= =?UTF-8?q?eetLayout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/base/ModalSideSheetLayout.kt | 155 --- .../component/override/AnchoredDraggable.kt | 903 ++++++++++++++++++ .../override/ModalBottomSheetLayout.kt | 610 ++++++++++++ 3 files changed, 1513 insertions(+), 155 deletions(-) delete mode 100644 component/src/main/java/com/lalilu/component/base/ModalSideSheetLayout.kt create mode 100644 component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt create mode 100644 component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt diff --git a/component/src/main/java/com/lalilu/component/base/ModalSideSheetLayout.kt b/component/src/main/java/com/lalilu/component/base/ModalSideSheetLayout.kt deleted file mode 100644 index 337b7db97..000000000 --- a/component/src/main/java/com/lalilu/component/base/ModalSideSheetLayout.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.lalilu.component.base - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.TweenSpec -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.detectTapGestures -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetDefaults -import androidx.compose.material.Surface -import androidx.compose.material.SwipeableDefaults -import androidx.compose.material.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.onClick -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp -import kotlin.math.roundToInt - -class ModalSideSheetState( - initialState: Boolean = false -) { - var isVisible: Boolean by mutableStateOf(initialState) -} - -@Composable -fun rememberModalSideSheetState( - initialState: Boolean = false -): ModalSideSheetState { - return remember { - ModalSideSheetState( - initialState = initialState - ) - } -} - -@Composable -@ExperimentalMaterialApi -fun ModalSideSheetLayout( - sheetContent: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - alignment: Alignment = Alignment.CenterStart, - sheetState: ModalSideSheetState = rememberModalSideSheetState(), - sheetShape: Shape = MaterialTheme.shapes.large, - sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, - sheetBackgroundColor: Color = MaterialTheme.colors.surface, - sheetContentColor: Color = contentColorFor(sheetBackgroundColor), - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - scrimColor: Color = ModalBottomSheetDefaults.scrimColor, - content: @Composable () -> Unit -) { - val scope = rememberCoroutineScope() - val maxModalSheetWidth = 450.dp - val maxModalSheetWidthPx = LocalDensity.current.run { maxModalSheetWidth.roundToPx() } - - val progress = animateFloatAsState( - targetValue = if (sheetState.isVisible) 100f else 0f, - animationSpec = animationSpec, - label = "isVisibleProgress" - ) - - BoxWithConstraints( - modifier.clipToBounds() - ) { - Box(Modifier.fillMaxSize()) { - content() - Scrim( - visible = sheetState.isVisible, - onDismiss = { sheetState.isVisible = false }, - color = scrimColor, - ) - } - - Surface( - Modifier - .align(alignment) // We offset from the top so we'll center from there - .widthIn(max = maxModalSheetWidth) - .fillMaxWidth() - .offset { - val multiply = if (alignment == Alignment.CenterEnd) 1f else -1f - val offsetX = lerp( - start = 0f, - stop = maxModalSheetWidthPx.toFloat(), - fraction = (1f - (progress.value / 100f)) * multiply - ) - IntOffset(offsetX.roundToInt(), 0) - }, - shape = sheetShape, - elevation = sheetElevation, - color = sheetBackgroundColor, - contentColor = sheetContentColor - ) { - Column(content = sheetContent) - } - } -} - -@Composable -private fun Scrim( - color: Color, - onDismiss: () -> Unit, - visible: Boolean -) { - if (color.isSpecified) { - val alpha by animateFloatAsState( - targetValue = if (visible) 1f else 0f, - animationSpec = TweenSpec(), - label = "" - ) - val closeSheet = "关闭菜单" - val dismissModifier = if (visible) { - Modifier - .pointerInput(onDismiss) { detectTapGestures { onDismiss() } } - .semantics(mergeDescendants = true) { - contentDescription = closeSheet - onClick { onDismiss(); true } - } - } else { - Modifier - } - - Canvas( - Modifier - .fillMaxSize() - .then(dismissModifier) - ) { - drawRect(color = color, alpha = alpha) - } - } -} diff --git a/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt b/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt new file mode 100644 index 000000000..f43141df1 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt @@ -0,0 +1,903 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.lalilu.component.override + +import androidx.annotation.FloatRange +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.animate +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.gestures.DragScope +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.offset +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntSize +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Structure that represents the anchors of a [AnchoredDraggableState]. + * + * See the DraggableAnchors factory method to construct drag anchors using a default implementation. + */ +@ExperimentalMaterialApi +internal interface DraggableAnchors { + + /** + * Get the anchor position for an associated [value] + * + * @return The position of the anchor, or [Float.NaN] if the anchor does not exist + */ + fun positionOf(value: T): Float + + /** + * Whether there is an anchor position associated with the [value] + * + * @param value The value to look up + * @return true if there is an anchor for this value, false if there is no anchor for this value + */ + fun hasAnchorFor(value: T): Boolean + + /** + * Find the closest anchor to the [position]. + * + * @param position The position to start searching from + * + * @return The closest anchor or null if the anchors are empty + */ + fun closestAnchor(position: Float): T? + + /** + * Find the closest anchor to the [position], in the specified direction. + * + * @param position The position to start searching from + * @param searchUpwards Whether to search upwards from the current position or downwards + * + * @return The closest anchor or null if the anchors are empty + */ + fun closestAnchor(position: Float, searchUpwards: Boolean): T? + + /** + * The smallest anchor, or [Float.NEGATIVE_INFINITY] if the anchors are empty. + */ + fun minAnchor(): Float + + /** + * The biggest anchor, or [Float.POSITIVE_INFINITY] if the anchors are empty. + */ + fun maxAnchor(): Float + + /** + * The amount of anchors + */ + val size: Int +} + +/** + * [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and + * corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable + * [DraggableAnchors] instance later on. + */ +@ExperimentalMaterialApi +internal class DraggableAnchorsConfig { + + internal val anchors = mutableMapOf() + + /** + * Set the anchor position for [this] anchor. + * + * @param position The anchor position. + */ + @Suppress("BuilderSetStyle") + infix fun T.at(position: Float) { + anchors[this] = position + } +} + +/** + * Create a new [DraggableAnchors] instance using a builder function. + * + * @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors + * @return A new [DraggableAnchors] instance with the anchor positions set by the `builder` + * function. + */ +@ExperimentalMaterialApi +internal fun DraggableAnchors( + builder: DraggableAnchorsConfig.() -> Unit +): DraggableAnchors = MapDraggableAnchors(DraggableAnchorsConfig().apply(builder).anchors) + +/** + * Enable drag gestures between a set of predefined values. + * + * When a drag is detected, the offset of the [AnchoredDraggableState] will be updated with the drag + * delta. You should use this offset to move your content accordingly (see [Modifier.offset]). + * When the drag ends, the offset will be animated to one of the anchors and when that anchor is + * reached, the value of the [AnchoredDraggableState] will also be updated to the value + * corresponding to the new anchor. + * + * Dragging is constrained between the minimum and maximum anchors. + * + * @param state The associated [AnchoredDraggableState]. + * @param orientation The orientation in which the [anchoredDraggable] can be dragged. + * @param enabled Whether this [anchoredDraggable] is enabled and should react to the user's input. + * @param reverseDirection Whether to reverse the direction of the drag, so a top to bottom + * drag will behave like bottom to top, and a left to right drag will behave like right to left. + * @param interactionSource Optional [MutableInteractionSource] that will passed on to + * the internal [Modifier.draggable]. + * @param startDragImmediately when set to false, [draggable] will start dragging only when the + * gesture crosses the touchSlop. This is useful to prevent users from "catching" an animating + * widget when pressing on it. See [draggable] to learn more about startDragImmediately. + */ +@ExperimentalMaterialApi +internal fun Modifier.anchoredDraggable( + state: AnchoredDraggableState, + orientation: Orientation, + enabled: Boolean = true, + reverseDirection: Boolean = false, + interactionSource: MutableInteractionSource? = null, + startDragImmediately: Boolean = state.isAnimationRunning +) = draggable( + state = state.draggableState, + orientation = orientation, + enabled = enabled, + interactionSource = interactionSource, + reverseDirection = reverseDirection, + startDragImmediately = startDragImmediately, + onDragStopped = { velocity -> launch { state.settle(velocity) } } +) + +/** + * Scope used for suspending anchored drag blocks. Allows to set [AnchoredDraggableState.offset] to + * a new value. + * + * @see [AnchoredDraggableState.anchoredDrag] to learn how to start the anchored drag and get the + * access to this scope. + */ +@ExperimentalMaterialApi +internal interface AnchoredDragScope { + /** + * Assign a new value for an offset value for [AnchoredDraggableState]. + * + * @param newOffset new value for [AnchoredDraggableState.offset]. + * @param lastKnownVelocity last known velocity (if known) + */ + fun dragTo( + newOffset: Float, + lastKnownVelocity: Float = 0f + ) +} + +/** + * State of the [anchoredDraggable] modifier. + * Use the constructor overload with anchors if the anchors are defined in composition, or update + * the anchors using [updateAnchors]. + * + * This contains necessary information about any ongoing drag or animation and provides methods + * to change the state either immediately or by starting an animation. + * + * @param initialValue The initial value of the state. + * @param positionalThreshold The positional threshold, in px, to be used when calculating the + * target state while a drag is in progress and when settling after the drag ends. This is the + * distance from the start of a transition. It will be, depending on the direction of the + * interaction, added or subtracted from/to the origin offset. It should always be a positive value. + * @param velocityThreshold The velocity threshold (in px per second) that the end velocity has to + * exceed in order to animate to the next state, even if the [positionalThreshold] has not been + * reached. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + */ +@Stable +@ExperimentalMaterialApi +internal class AnchoredDraggableState( + initialValue: T, + internal val positionalThreshold: (totalDistance: Float) -> Float, + internal val velocityThreshold: () -> Float, + val animationSpec: AnimationSpec, + internal val confirmValueChange: (newValue: T) -> Boolean = { true } +) { + + /** + * Construct an [AnchoredDraggableState] instance with anchors. + * + * @param initialValue The initial value of the state. + * @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state + * change. + * @param positionalThreshold The positional threshold, in px, to be used when calculating the + * target state while a drag is in progress and when settling after the drag ends. This is the + * distance from the start of a transition. It will be, depending on the direction of the + * interaction, added or subtracted from/to the origin offset. It should always be a positive + * value. + * @param velocityThreshold The velocity threshold (in px per second) that the end velocity has + * to exceed in order to animate to the next state, even if the [positionalThreshold] has not + * been reached. + */ + @ExperimentalMaterialApi + constructor( + initialValue: T, + anchors: DraggableAnchors, + positionalThreshold: (totalDistance: Float) -> Float, + velocityThreshold: () -> Float, + animationSpec: AnimationSpec, + confirmValueChange: (newValue: T) -> Boolean = { true } + ) : this( + initialValue, + positionalThreshold, + velocityThreshold, + animationSpec, + confirmValueChange + ) { + this.anchors = anchors + trySnapTo(initialValue) + } + + private val dragMutex = MutatorMutex() + + internal val draggableState = object : DraggableState { + + private val dragScope = object : DragScope { + override fun dragBy(pixels: Float) { + with(anchoredDragScope) { + dragTo(newOffsetForDelta(pixels)) + } + } + } + + override suspend fun drag( + dragPriority: MutatePriority, + block: suspend DragScope.() -> Unit + ) { + this@AnchoredDraggableState.anchoredDrag(dragPriority) { + with(dragScope) { block() } + } + } + + override fun dispatchRawDelta(delta: Float) { + this@AnchoredDraggableState.dispatchRawDelta(delta) + } + } + + /** + * The current value of the [AnchoredDraggableState]. + */ + var currentValue: T by mutableStateOf(initialValue) + private set + + /** + * The target value. This is the closest value to the current offset, taking into account + * positional thresholds. If no interactions like animations or drags are in progress, this + * will be the current value. + */ + val targetValue: T by derivedStateOf { + dragTarget ?: run { + val currentOffset = offset + if (!currentOffset.isNaN()) { + computeTarget(currentOffset, currentValue, velocity = 0f) + } else currentValue + } + } + + /** + * The closest value in the swipe direction from the current offset, not considering thresholds. + * If an [anchoredDrag] is in progress, this will be the target of that anchoredDrag (if + * specified). + */ + internal val closestValue: T by derivedStateOf { + dragTarget ?: run { + val currentOffset = offset + if (!currentOffset.isNaN()) { + computeTargetWithoutThresholds(currentOffset, currentValue) + } else currentValue + } + } + + /** + * The current offset, or [Float.NaN] if it has not been initialized yet. + * + * The offset will be initialized when the anchors are first set through [updateAnchors]. + * + * Strongly consider using [requireOffset] which will throw if the offset is read before it is + * initialized. This helps catch issues early in your workflow. + */ + var offset: Float by mutableFloatStateOf(Float.NaN) + private set + + /** + * Require the current offset. + * + * @see offset + * + * @throws IllegalStateException If the offset has not been initialized yet + */ + fun requireOffset(): Float { + check(!offset.isNaN()) { + "The offset was read before being initialized. Did you access the offset in a phase " + + "before layout, like effects or composition?" + } + return offset + } + + /** + * Whether an animation is currently in progress. + */ + val isAnimationRunning: Boolean get() = dragTarget != null + + /** + * The fraction of the progress going from [currentValue] to [closestValue], within [0f..1f] + * bounds, or 1f if the [AnchoredDraggableState] is in a settled state. + */ + @get:FloatRange(from = 0.0, to = 1.0) + val progress: Float by derivedStateOf(structuralEqualityPolicy()) { + val a = anchors.positionOf(currentValue) + val b = anchors.positionOf(closestValue) + val distance = abs(b - a) + if (!distance.isNaN() && distance > 1e-6f) { + val progress = (this.requireOffset() - a) / (b - a) + // If we are very close to 0f or 1f, we round to the closest + if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress + } else 1f + } + + /** + * The velocity of the last known animation. Gets reset to 0f when an animation completes + * successfully, but does not get reset when an animation gets interrupted. + * You can use this value to provide smooth reconciliation behavior when re-targeting an + * animation. + */ + var lastVelocity: Float by mutableFloatStateOf(0f) + private set + + private var dragTarget: T? by mutableStateOf(null) + + var anchors: DraggableAnchors by mutableStateOf(emptyDraggableAnchors()) + private set + + /** + * Update the anchors. If there is no ongoing [anchoredDrag] operation, snap to the [newTarget], + * otherwise restart the ongoing [anchoredDrag] operation (e.g. an animation) with the new + * anchors. + * + * If your anchors depend on the size of the layout, updateAnchors should be called in the + * layout (placement) phase, e.g. through Modifier.onSizeChanged. This ensures that the + * state is set up within the same frame. + * For static anchors, or anchors with different data dependencies, [updateAnchors] is safe to + * be called from side effects or layout. + * + * @param newAnchors The new anchors. + * @param newTarget The new target, by default the closest anchor or the current target if there + * are no anchors. + */ + fun updateAnchors( + newAnchors: DraggableAnchors, + newTarget: T = if (!offset.isNaN()) { + newAnchors.closestAnchor(offset) ?: targetValue + } else targetValue + ) { + if (anchors != newAnchors) { + anchors = newAnchors + // Attempt to snap. If nobody is holding the lock, we can immediately update the offset. + // If anybody is holding the lock, we send a signal to restart the ongoing work with the + // updated anchors. + val snapSuccessful = trySnapTo(newTarget) + if (!snapSuccessful) { + dragTarget = newTarget + } + } + } + + /** + * Find the closest anchor, taking into account the [velocityThreshold] and + * [positionalThreshold], and settle at it with an animation. + * + * If the [velocity] is lower than the [velocityThreshold], the closest anchor by distance and + * [positionalThreshold] will be the target. If the [velocity] is higher than the + * [velocityThreshold], the [positionalThreshold] will not be considered and the next + * anchor in the direction indicated by the sign of the [velocity] will be the target. + */ + suspend fun settle(velocity: Float) { + val previousValue = this.currentValue + val targetValue = computeTarget( + offset = requireOffset(), + currentValue = previousValue, + velocity = velocity + ) + if (confirmValueChange(targetValue)) { + animateTo(targetValue, velocity) + } else { + // If the user vetoed the state change, rollback to the previous state. + animateTo(previousValue, velocity) + } + } + + private fun computeTarget( + offset: Float, + currentValue: T, + velocity: Float + ): T { + val currentAnchors = anchors + val currentAnchorPosition = currentAnchors.positionOf(currentValue) + val velocityThresholdPx = velocityThreshold() + return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) { + currentValue + } else if (currentAnchorPosition < offset) { + // Swiping from lower to upper (positive). + if (velocity >= velocityThresholdPx) { + currentAnchors.closestAnchor(offset, true)!! + } else { + val upper = currentAnchors.closestAnchor(offset, true)!! + val distance = abs(currentAnchors.positionOf(upper) - currentAnchorPosition) + val relativeThreshold = abs(positionalThreshold(distance)) + val absoluteThreshold = abs(currentAnchorPosition + relativeThreshold) + if (offset < absoluteThreshold) currentValue else upper + } + } else { + // Swiping from upper to lower (negative). + if (velocity <= -velocityThresholdPx) { + currentAnchors.closestAnchor(offset, false)!! + } else { + val lower = currentAnchors.closestAnchor(offset, false)!! + val distance = abs(currentAnchorPosition - currentAnchors.positionOf(lower)) + val relativeThreshold = abs(positionalThreshold(distance)) + val absoluteThreshold = abs(currentAnchorPosition - relativeThreshold) + if (offset < 0) { + // For negative offsets, larger absolute thresholds are closer to lower anchors + // than smaller ones. + if (abs(offset) < absoluteThreshold) currentValue else lower + } else { + if (offset > absoluteThreshold) currentValue else lower + } + } + } + } + + private fun computeTargetWithoutThresholds( + offset: Float, + currentValue: T, + ): T { + val currentAnchors = anchors + val currentAnchorPosition = currentAnchors.positionOf(currentValue) + return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) { + currentValue + } else if (currentAnchorPosition < offset) { + currentAnchors.closestAnchor(offset, true) ?: currentValue + } else { + currentAnchors.closestAnchor(offset, false) ?: currentValue + } + } + + private val anchoredDragScope: AnchoredDragScope = object : AnchoredDragScope { + override fun dragTo(newOffset: Float, lastKnownVelocity: Float) { + offset = newOffset + lastVelocity = lastKnownVelocity + } + } + + /** + * Call this function to take control of drag logic and perform anchored drag with the latest + * anchors. + * + * All actions that change the [offset] of this [AnchoredDraggableState] must be performed + * within an [anchoredDrag] block (even if they don't call any other methods on this object) + * in order to guarantee that mutual exclusion is enforced. + * + * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing + * drag, the ongoing drag will be cancelled. + * + * If the [anchors] change while the [block] is being executed, it will be cancelled and + * re-executed with the latest anchors and target. This allows you to target the correct + * state. + * + * @param dragPriority of the drag operation + * @param block perform anchored drag given the current anchor provided + */ + suspend fun anchoredDrag( + dragPriority: MutatePriority = MutatePriority.Default, + block: suspend AnchoredDragScope.(anchors: DraggableAnchors) -> Unit + ) { + try { + dragMutex.mutate(dragPriority) { + restartable(inputs = { anchors }) { latestAnchors -> + anchoredDragScope.block(latestAnchors) + } + } + } finally { + val closest = anchors.closestAnchor(offset) + if (closest != null && + abs(offset - anchors.positionOf(closest)) <= 0.5f && + confirmValueChange.invoke(closest) + ) { + currentValue = closest + } + } + } + + /** + * Call this function to take control of drag logic and perform anchored drag with the latest + * anchors and target. + * + * All actions that change the [offset] of this [AnchoredDraggableState] must be performed + * within an [anchoredDrag] block (even if they don't call any other methods on this object) + * in order to guarantee that mutual exclusion is enforced. + * + * This overload allows the caller to hint the target value that this [anchoredDrag] is intended + * to arrive to. This will set [AnchoredDraggableState.targetValue] to provided value so + * consumers can reflect it in their UIs. + * + * If the [anchors] or [AnchoredDraggableState.targetValue] change while the [block] is being + * executed, it will be cancelled and re-executed with the latest anchors and target. This + * allows you to target the correct state. + * + * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing + * drag, the ongoing drag will be cancelled. + * + * @param targetValue hint the target value that this [anchoredDrag] is intended to arrive to + * @param dragPriority of the drag operation + * @param block perform anchored drag given the current anchor provided + */ + suspend fun anchoredDrag( + targetValue: T, + dragPriority: MutatePriority = MutatePriority.Default, + block: suspend AnchoredDragScope.(anchors: DraggableAnchors, targetValue: T) -> Unit + ) { + if (anchors.hasAnchorFor(targetValue)) { + try { + dragMutex.mutate(dragPriority) { + dragTarget = targetValue + restartable( + inputs = { anchors to this@AnchoredDraggableState.targetValue } + ) { (latestAnchors, latestTarget) -> + anchoredDragScope.block(latestAnchors, latestTarget) + } + } + } finally { + dragTarget = null + val closest = anchors.closestAnchor(offset) + if (closest != null && + abs(offset - anchors.positionOf(closest)) <= 0.5f && + confirmValueChange.invoke(closest) + ) { + currentValue = closest + } + } + } else { + // Todo: b/283467401, revisit this behavior + currentValue = targetValue + } + } + + internal fun newOffsetForDelta(delta: Float) = + ((if (offset.isNaN()) 0f else offset) + delta) + .coerceIn(anchors.minAnchor(), anchors.maxAnchor()) + + /** + * Drag by the [delta], coerce it in the bounds and dispatch it to the [AnchoredDraggableState]. + * + * @return The delta the consumed by the [AnchoredDraggableState] + */ + fun dispatchRawDelta(delta: Float): Float { + val newOffset = newOffsetForDelta(delta) + val oldOffset = if (offset.isNaN()) 0f else offset + offset = newOffset + return newOffset - oldOffset + } + + /** + * Attempt to snap synchronously. Snapping can happen synchronously when there is no other drag + * transaction like a drag or an animation is progress. If there is another interaction in + * progress, the suspending [snapTo] overload needs to be used. + * + * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous + */ + private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate { + with(anchoredDragScope) { + val targetOffset = anchors.positionOf(targetValue) + if (!targetOffset.isNaN()) { + dragTo(targetOffset) + dragTarget = null + } + currentValue = targetValue + } + } + + companion object { + /** + * The default [Saver] implementation for [AnchoredDraggableState]. + */ + @ExperimentalMaterialApi + fun Saver( + animationSpec: AnimationSpec, + confirmValueChange: (T) -> Boolean, + positionalThreshold: (distance: Float) -> Float, + velocityThreshold: () -> Float, + ) = Saver, T>( + save = { it.currentValue }, + restore = { + AnchoredDraggableState( + initialValue = it, + animationSpec = animationSpec, + confirmValueChange = confirmValueChange, + positionalThreshold = positionalThreshold, + velocityThreshold = velocityThreshold + ) + } + ) + } +} + +/** + * Snap to a [targetValue] without any animation. + * If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.currentValue] will + * be updated to the [targetValue] without updating the offset. + * + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. + * + * @param targetValue The target value of the animation + */ +@ExperimentalMaterialApi +internal suspend fun AnchoredDraggableState.snapTo(targetValue: T) { + anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + val targetOffset = anchors.positionOf(latestTarget) + if (!targetOffset.isNaN()) dragTo(targetOffset) + } +} + +/** + * Animate to a [targetValue]. + * If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.currentValue] will + * be updated to the [targetValue] without updating the offset. + * + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. + * + * @param targetValue The target value of the animation + * @param velocity The velocity the animation should start with + */ +@ExperimentalMaterialApi +internal suspend fun AnchoredDraggableState.animateTo( + targetValue: T, + velocity: Float = this.lastVelocity, +) { + anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + val targetOffset = anchors.positionOf(latestTarget) + if (!targetOffset.isNaN()) { + var prev = if (offset.isNaN()) 0f else offset + animate(prev, targetOffset, velocity, animationSpec) { value, velocity -> + // Our onDrag coerces the value within the bounds, but an animation may + // overshoot, for example a spring animation or an overshooting interpolator + // We respect the user's intention and allow the overshoot, but still use + // DraggableState's drag for its mutex. + dragTo(value, velocity) + prev = value + } + } + } +} + +/** + * Contains useful defaults for [anchoredDraggable] and [AnchoredDraggableState]. + */ +@Stable +@ExperimentalMaterialApi +internal object AnchoredDraggableDefaults { + /** + * The default animation used by [AnchoredDraggableState]. + */ + @get:ExperimentalMaterialApi + @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") + @ExperimentalMaterialApi + val AnimationSpec = SpringSpec() +} + +private class AnchoredDragFinishedSignal : CancellationException() { + override fun fillInStackTrace(): Throwable { + stackTrace = emptyArray() + return this + } +} + +private suspend fun restartable(inputs: () -> I, block: suspend (I) -> Unit) { + try { + coroutineScope { + var previousDrag: Job? = null + snapshotFlow(inputs) + .collect { latestInputs -> + previousDrag?.apply { + cancel(AnchoredDragFinishedSignal()) + join() + } + previousDrag = launch(start = CoroutineStart.UNDISPATCHED) { + block(latestInputs) + this@coroutineScope.cancel(AnchoredDragFinishedSignal()) + } + } + } + } catch (anchoredDragFinished: AnchoredDragFinishedSignal) { + // Ignored + } +} + +private fun emptyDraggableAnchors() = MapDraggableAnchors(emptyMap()) + +@OptIn(ExperimentalMaterialApi::class) +private class MapDraggableAnchors(private val anchors: Map) : DraggableAnchors { + + override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN + override fun hasAnchorFor(value: T) = anchors.containsKey(value) + + override fun closestAnchor(position: Float): T? = anchors.minByOrNull { + abs(position - it.value) + }?.key + + override fun closestAnchor( + position: Float, + searchUpwards: Boolean + ): T? { + return anchors.minByOrNull { (_, anchor) -> + val delta = if (searchUpwards) anchor - position else position - anchor + if (delta < 0) Float.POSITIVE_INFINITY else delta + }?.key + } + + override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN + + override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN + + override val size: Int + get() = anchors.size + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MapDraggableAnchors<*>) return false + + return anchors == other.anchors + } + + override fun hashCode() = 31 * anchors.hashCode() + + override fun toString() = "MapDraggableAnchors($anchors)" +} + +/** + * This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout + * node's size and offsetting it. + * It considers lookahead and reports the appropriate size and measurement for the appropriate + * phase. + * + * @param state The state the anchors should be attached to + * @param orientation The orientation the component should be offset in + * @param anchors Lambda to calculate the anchors based on this layout's size and the incoming + * constraints. These can be useful to avoid subcomposition. + */ +@ExperimentalMaterialApi +internal fun Modifier.draggableAnchors( + state: AnchoredDraggableState, + orientation: Orientation, + anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, +) = this then DraggableAnchorsElement(state, anchors, orientation) + +@OptIn(ExperimentalMaterialApi::class) +private class DraggableAnchorsElement( + private val state: AnchoredDraggableState, + private val anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, + private val orientation: Orientation +) : ModifierNodeElement>() { + + override fun create() = DraggableAnchorsNode(state, anchors, orientation) + + override fun update(node: DraggableAnchorsNode) { + node.state = state + node.anchors = anchors + node.orientation = orientation + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + + other as DraggableAnchorsElement<*> + + if (state != other.state) return false + if (anchors != other.anchors) return false + if (orientation != other.orientation) return false + + return true + } + + override fun hashCode(): Int { + var result = state.hashCode() + result = 31 * result + anchors.hashCode() + result = 31 * result + orientation.hashCode() + return result + } + + override fun InspectorInfo.inspectableProperties() { + debugInspectorInfo { + properties["state"] = state + properties["anchors"] = anchors + properties["orientation"] = orientation + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +private class DraggableAnchorsNode( + var state: AnchoredDraggableState, + var anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, + var orientation: Orientation +) : Modifier.Node(), LayoutModifierNode { + private var didLookahead: Boolean = false + + override fun onDetach() { + didLookahead = false + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints + ): MeasureResult { + val placeable = measurable.measure(constraints) + // If we are in a lookahead pass, we only want to update the anchors here and not in + // post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead), + // update the anchors in the main pass. + if (!isLookingAhead || !didLookahead) { + val size = IntSize(placeable.width, placeable.height) + val newAnchorResult = anchors(size, constraints) + state.updateAnchors(newAnchorResult.first, newAnchorResult.second) + } + didLookahead = isLookingAhead || didLookahead + return layout(placeable.width, placeable.height) { + // In a lookahead pass, we use the position of the current target as this is where any + // ongoing animations would move. If the component is in a settled state, lookahead + // and post-lookahead will converge. + val offset = if (isLookingAhead) { + state.anchors.positionOf(state.targetValue) + } else state.requireOffset() + val xOffset = if (orientation == Orientation.Horizontal) offset else 0f + val yOffset = if (orientation == Orientation.Vertical) offset else 0f + placeable.place(xOffset.roundToInt(), yOffset.roundToInt()) + } + } +} diff --git a/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt new file mode 100644 index 000000000..8c370ebd9 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt @@ -0,0 +1,610 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.lalilu.component.override + +import androidx.annotation.FloatRange +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.contentColorFor +import com.lalilu.component.override.ModalBottomSheetState.Companion.Saver +import com.lalilu.component.override.ModalBottomSheetValue.Expanded +import com.lalilu.component.override.ModalBottomSheetValue.HalfExpanded +import com.lalilu.component.override.ModalBottomSheetValue.Hidden +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.collapse +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.expand +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +/** + * Possible values of [ModalBottomSheetState]. + */ +enum class ModalBottomSheetValue { + /** + * The bottom sheet is not visible. + */ + Hidden, + + /** + * The bottom sheet is visible at full height. + */ + Expanded, + + /** + * The bottom sheet is partially visible at 50% of the screen height. This state is only + * enabled if the height of the bottom sheet is more than 50% of the screen height. + */ + HalfExpanded +} + +/** + * State of the [ModalBottomSheetLayout] composable. + * + * @param initialValue The initial value of the state. Must not be set to + * [ModalBottomSheetValue.HalfExpanded] if [isSkipHalfExpanded] is set to true. + * @param density The density that this state can use to convert values to and from dp. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param isSkipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should + * be skipped. If true, the sheet will always expand to the [Expanded] state and move to the + * [Hidden] state when hiding the sheet, either programmatically or by user interaction. + * Must not be set to true if the initialValue is [ModalBottomSheetValue.HalfExpanded]. + * If supplied with [ModalBottomSheetValue.HalfExpanded] for the initialValue, an + * [IllegalArgumentException] will be thrown. + */ +@OptIn(ExperimentalMaterialApi::class) +class ModalBottomSheetState( + initialValue: ModalBottomSheetValue, + density: Density, + confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true }, + internal val animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, + internal val isSkipHalfExpanded: Boolean = false, +) { + + internal val anchoredDraggableState = AnchoredDraggableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmValueChange = confirmValueChange, + positionalThreshold = { + with(density) { + ModalBottomSheetPositionalThreshold.toPx() + } + }, + velocityThreshold = { with(density) { ModalBottomSheetVelocityThreshold.toPx() } } + ) + + /** + * The current value of the [ModalBottomSheetState]. + */ + val currentValue: ModalBottomSheetValue + get() = anchoredDraggableState.currentValue + + /** + * The target value the state will settle at once the current interaction ends, or the + * [currentValue] if there is no interaction in progress. + */ + val targetValue: ModalBottomSheetValue + get() = anchoredDraggableState.targetValue + + /** + * The fraction of the progress, within [0f..1f] bounds, or 1f if the [AnchoredDraggableState] + * is in a settled state. + */ + @Deprecated( + message = "Please use the progress function to query progress explicitly between targets.", + replaceWith = ReplaceWith("progress(from = , to = )") + ) + @get:FloatRange(from = 0.0, to = 1.0) + @ExperimentalMaterialApi + val progress: Float + get() = anchoredDraggableState.progress + + /** + * The fraction of the offset between [from] and [to], as a fraction between [0f..1f], or 1f if + * [from] is equal to [to]. + * + * @param from The starting value used to calculate the distance + * @param to The end value used to calculate the distance + */ + @FloatRange(from = 0.0, to = 1.0) + fun progress( + from: ModalBottomSheetValue, + to: ModalBottomSheetValue + ): Float { + val fromOffset = anchoredDraggableState.anchors.positionOf(from) + val toOffset = anchoredDraggableState.anchors.positionOf(to) + val currentOffset = anchoredDraggableState.offset.coerceIn( + min(fromOffset, toOffset), // fromOffset might be > toOffset + max(fromOffset, toOffset) + ) + val fraction = (currentOffset - fromOffset) / (toOffset - fromOffset) + return if (fraction.isNaN()) 1f else abs(fraction) + } + + /** + * Whether the bottom sheet is visible. + */ + val isVisible: Boolean + get() = anchoredDraggableState.currentValue != Hidden + + internal val hasHalfExpandedState: Boolean + get() = anchoredDraggableState.anchors.hasAnchorFor(HalfExpanded) + + init { + if (isSkipHalfExpanded) { + require(initialValue != HalfExpanded) { + "The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" + + " true." + } + } + } + + /** + * Show the bottom sheet with animation and suspend until it's shown. If the sheet is taller + * than 50% of the parent's height, the bottom sheet will be half expanded. Otherwise it will be + * fully expanded. + */ + suspend fun show() { + val hasExpandedState = anchoredDraggableState.anchors.hasAnchorFor(Expanded) + val targetValue = when (currentValue) { + Hidden -> if (hasHalfExpandedState) HalfExpanded else Expanded + else -> if (hasExpandedState) Expanded else Hidden + } + animateTo(targetValue) + } + + /** + * Half expand the bottom sheet if half expand is enabled with animation and suspend until it + * animation is complete or cancelled. + */ + internal suspend fun halfExpand() { + if (!hasHalfExpandedState) { + return + } + animateTo(HalfExpanded) + } + + /** + * Hide the bottom sheet with animation and suspend until it if fully hidden or animation has + * been cancelled. + */ + suspend fun hide() = animateTo(Hidden) + + /** + * Fully expand the bottom sheet with animation and suspend until it if fully expanded or + * animation has been cancelled. + */ + internal suspend fun expand() { + if (!anchoredDraggableState.anchors.hasAnchorFor(Expanded)) { + return + } + animateTo(Expanded) + } + + internal suspend fun animateTo( + target: ModalBottomSheetValue, + velocity: Float = anchoredDraggableState.lastVelocity + ) = anchoredDraggableState.animateTo(target, velocity) + + internal suspend fun snapTo(target: ModalBottomSheetValue) = + anchoredDraggableState.snapTo(target) + + internal fun requireOffset() = anchoredDraggableState.requireOffset() + + companion object { + /** + * The default [Saver] implementation for [ModalBottomSheetState]. + * Saves the [currentValue] and recreates a [ModalBottomSheetState] with the saved value as + * initial value. + */ + fun Saver( + animationSpec: AnimationSpec, + confirmValueChange: (ModalBottomSheetValue) -> Boolean, + skipHalfExpanded: Boolean, + density: Density + ): Saver = Saver( + save = { it.currentValue }, + restore = { + ModalBottomSheetState( + initialValue = it, + density = density, + animationSpec = animationSpec, + isSkipHalfExpanded = skipHalfExpanded, + confirmValueChange = confirmValueChange + ) + } + ) + } +} + +/** + * Create a [ModalBottomSheetState] and [remember] it. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + * @param skipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should + * be skipped. If true, the sheet will always expand to the [Expanded] state and move to the + * [Hidden] state when hiding the sheet, either programmatically or by user interaction. + * Must not be set to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded]. + * If supplied with [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an + * [IllegalArgumentException] will be thrown. + */ +@Composable +fun rememberModalBottomSheetState( + initialValue: ModalBottomSheetValue, + animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, + confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true }, + skipHalfExpanded: Boolean = false, +): ModalBottomSheetState { + val density = LocalDensity.current + // Key the rememberSaveable against the initial value. If it changed we don't want to attempt + // to restore as the restored value could have been saved with a now invalid set of anchors. + // b/152014032 + return key(initialValue) { + rememberSaveable( + initialValue, animationSpec, skipHalfExpanded, confirmValueChange, density, + saver = Saver( + density = density, + animationSpec = animationSpec, + skipHalfExpanded = skipHalfExpanded, + confirmValueChange = confirmValueChange + ) + ) { + ModalBottomSheetState( + density = density, + initialValue = initialValue, + animationSpec = animationSpec, + isSkipHalfExpanded = skipHalfExpanded, + confirmValueChange = confirmValueChange + ) + } + } +} + +/** + * Material Design modal bottom sheet. + * + * Modal bottom sheets present a set of choices while blocking interaction with the rest of the + * screen. They are an alternative to inline menus and simple dialogs, providing + * additional room for content, iconography, and actions. + * + * ![Modal bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material/modal-bottom-sheet.png) + * + * A simple example of a modal bottom sheet looks like this: + * + * @sample androidx.compose.material.samples.ModalBottomSheetSample + * + * @param sheetContent The content of the bottom sheet. + * @param modifier Optional [Modifier] for the entire component. + * @param sheetState The state of the bottom sheet. + * @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures. + * @param sheetShape The shape of the bottom sheet. + * @param sheetElevation The elevation of the bottom sheet. + * @param sheetBackgroundColor The background color of the bottom sheet. + * @param sheetContentColor The preferred content color provided by the bottom sheet to its + * children. Defaults to the matching content color for [sheetBackgroundColor], or if that is not + * a color from the theme, this will keep the same content color set above the bottom sheet. + * @param scrimColor The color of the scrim that is applied to the rest of the screen when the + * bottom sheet is visible. If the color passed is [Color.Unspecified], then a scrim will no + * longer be applied and the bottom sheet will not block interaction with the rest of the screen + * when visible. + * @param content The content of rest of the screen. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ModalBottomSheetLayout( + sheetContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + sheetState: ModalBottomSheetState = + rememberModalBottomSheetState(Hidden), + sheetGesturesEnabled: Boolean = true, + sheetShape: Shape = MaterialTheme.shapes.large, + sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, + sheetBackgroundColor: Color = MaterialTheme.colors.surface, + sheetContentColor: Color = contentColorFor(sheetBackgroundColor), + scrimColor: Color = ModalBottomSheetDefaults.scrimColor, + content: @Composable () -> Unit +) { + val scope = rememberCoroutineScope() + val orientation = Orientation.Vertical + Box(modifier) { + Box(Modifier.fillMaxSize()) { + content() + Scrim( + color = scrimColor, + onDismiss = { + if (sheetState.anchoredDraggableState.confirmValueChange(Hidden)) { + scope.launch { sheetState.hide() } + } + }, + visible = sheetState.anchoredDraggableState.targetValue != Hidden + ) + } + Surface( + Modifier + .align(Alignment.TopCenter) // We offset from the top so we'll center from there + .widthIn(max = MaxModalBottomSheetWidth) + .fillMaxWidth() + .then( + if (sheetGesturesEnabled) { + Modifier.nestedScroll( + remember(sheetState.anchoredDraggableState, orientation) { + ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + state = sheetState.anchoredDraggableState, + orientation = orientation + ) + } + ) + } else Modifier + ) + .modalBottomSheetAnchors(sheetState) + .anchoredDraggable( + state = sheetState.anchoredDraggableState, + orientation = orientation, + enabled = sheetGesturesEnabled && + sheetState.anchoredDraggableState.currentValue != Hidden, + ) + .then( + if (sheetGesturesEnabled) { + Modifier.semantics { + if (sheetState.isVisible) { + dismiss { + if ( + sheetState.anchoredDraggableState.confirmValueChange(Hidden) + ) { + scope.launch { sheetState.hide() } + } + true + } + if (sheetState.anchoredDraggableState.currentValue + == HalfExpanded + ) { + expand { + if (sheetState.anchoredDraggableState.confirmValueChange( + Expanded + ) + ) { + scope.launch { sheetState.expand() } + } + true + } + } else if (sheetState.hasHalfExpandedState) { + collapse { + if (sheetState.anchoredDraggableState.confirmValueChange( + HalfExpanded + ) + ) { + scope.launch { sheetState.halfExpand() } + } + true + } + } + } + } + } else Modifier + ), + shape = sheetShape, + elevation = sheetElevation, + color = sheetBackgroundColor, + contentColor = sheetContentColor + ) { + Column(content = sheetContent) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +private fun Modifier.modalBottomSheetAnchors(sheetState: ModalBottomSheetState) = draggableAnchors( + state = sheetState.anchoredDraggableState, + orientation = Orientation.Vertical +) { sheetSize, constraints -> + val fullHeight = constraints.maxHeight.toFloat() + val newAnchors = DraggableAnchors { + Hidden at fullHeight + val halfHeight = fullHeight / 2f + if (!sheetState.isSkipHalfExpanded && sheetSize.height > halfHeight) { + HalfExpanded at halfHeight + } + if (sheetSize.height != 0) { + Expanded at max(0f, fullHeight - sheetSize.height) + } + } + // If we are setting the anchors for the first time and have an anchor for + // the current (initial) value, prefer that + val isInitialized = sheetState.anchoredDraggableState.anchors.size > 0 + val previousValue = sheetState.currentValue + val newTarget = if (!isInitialized && newAnchors.hasAnchorFor(previousValue)) { + previousValue + } else { + when (sheetState.targetValue) { + Hidden -> Hidden + HalfExpanded, Expanded -> { + val hasHalfExpandedState = newAnchors.hasAnchorFor(HalfExpanded) + val newTarget = if (hasHalfExpandedState) { + HalfExpanded + } else if (newAnchors.hasAnchorFor(Expanded)) { + Expanded + } else { + Hidden + } + newTarget + } + } + } + return@draggableAnchors newAnchors to newTarget +} + +@Composable +private fun Scrim( + color: Color, + onDismiss: () -> Unit, + visible: Boolean +) { + if (color.isSpecified) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = TweenSpec() + ) + val closeSheet = "CloseSheet" + val dismissModifier = if (visible) { + Modifier + .pointerInput(onDismiss) { detectTapGestures { onDismiss() } } + .semantics(mergeDescendants = true) { + contentDescription = closeSheet + onClick { onDismiss(); true } + } + } else { + Modifier + } + + Canvas( + Modifier + .fillMaxSize() + .then(dismissModifier) + ) { + drawRect(color = color, alpha = alpha) + } + } +} + +/** + * Contains useful Defaults for [ModalBottomSheetLayout]. + */ +object ModalBottomSheetDefaults { + + /** + * The default elevation used by [ModalBottomSheetLayout]. + */ + val Elevation = 16.dp + + /** + * The default scrim color used by [ModalBottomSheetLayout]. + */ + val scrimColor: Color + @Composable + get() = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + + /** + * The default animation spec used by [ModalBottomSheetState]. + */ + val AnimationSpec: AnimationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) +} + +@OptIn(ExperimentalMaterialApi::class) +private fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + state: AnchoredDraggableState<*>, + orientation: Orientation +): NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + state.dispatchRawDelta(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag) { + state.dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = available.toFloat() + val currentOffset = state.requireOffset() + return if (toFling < 0 && currentOffset > state.anchors.minAnchor()) { + state.settle(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + state.settle(velocity = available.toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset( + x = if (orientation == Orientation.Horizontal) this else 0f, + y = if (orientation == Orientation.Vertical) this else 0f + ) + + @JvmName("velocityToFloat") + private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y + + @JvmName("offsetToFloat") + private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y +} + +private val ModalBottomSheetPositionalThreshold = 56.dp +private val ModalBottomSheetVelocityThreshold = 125.dp +private val MaxModalBottomSheetWidth = 640.dp From 31efdee1347835bc8e54183f045de13e5862af9f Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 30 Apr 2024 01:24:08 +0800 Subject: [PATCH 027/213] =?UTF-8?q?[modify]=E4=B8=BAModalBottomSheetLayout?= =?UTF-8?q?=E7=9A=84ModalBottomSheetState=E6=B7=BB=E5=8A=A0enable=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E6=95=B4=E4=BD=93=E5=8A=9F=E8=83=BD=E7=9A=84=E5=BC=80?= =?UTF-8?q?=E5=90=AF=EF=BC=8C=E4=BC=98=E5=8C=96=E5=AE=8C=E5=96=84=E4=B8=8E?= =?UTF-8?q?BottomSheetNavigator=E7=9B=B8=E5=85=B3=E7=9A=84=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/DrawerWrapper.kt | 175 +++-------------- .../lalilu/lmusic/compose/LayoutWrapper.kt | 14 +- .../lmusic/compose/NavigationWrapper.kt | 81 +++----- .../component/navigate/NavigationBar.kt | 24 +-- .../navigate/NavigationSheetContent.kt | 19 +- .../compose/screen/playing/SeekbarLayout.kt | 4 +- ...tController.kt => BottomSheetNavigator.kt} | 118 ++++++------ .../com/lalilu/component/base/CustomScreen.kt | 2 +- .../lalilu/component/extension/ComposeExt.kt | 9 - .../component/navigation/SheetController.kt | 27 --- .../override/ModalBottomSheetLayout.kt | 182 ++++++++++-------- 11 files changed, 241 insertions(+), 414 deletions(-) rename component/src/main/java/com/lalilu/component/base/{BottomSheetController.kt => BottomSheetNavigator.kt} (56%) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt index 5644d1eff..288a3add7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt @@ -1,184 +1,73 @@ package com.lalilu.lmusic.compose -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.DraggableState -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp +import com.lalilu.component.base.LocalWindowSize object DrawerWrapper { - val reverseLayout = mutableStateOf(false) - val offsetX = mutableStateOf(0f) - - @Composable - fun DefaultSpacerContent() { - Box( - modifier = Modifier - .fillMaxHeight() - .width(20.dp) - .draggable( - orientation = Orientation.Horizontal, - state = DraggableState { deltaX -> - // TODO 若已经到达边界了,则不应再记录会超出范围的值 - offsetX.value += deltaX * if (reverseLayout.value) -1f else 1f - } - ) - ) { - Spacer( - modifier = Modifier - .align(Alignment.Center) - .fillMaxHeight(0.2f) - .width(4.dp) - .background( - color = Color.DarkGray, - shape = RoundedCornerShape(4.dp) - ) - ) - } - } - @Composable fun Content( - isPad: () -> Boolean = { false }, - isLandscape: () -> Boolean = { false }, + windowClass: WindowSizeClass = LocalWindowSize.current, mainContent: @Composable () -> Unit, - spacerContent: @Composable () -> Unit = { DefaultSpacerContent() }, secondContent: @Composable () -> Unit, ) { - val minWidthForMainContent = LocalDensity.current.run { 360.dp.toPx() } - val maxWidthForMainContent = LocalDensity.current.run { 480.dp.toPx() } - - val animateProgress = animateFloatAsState( - label = "reverseLayout", - targetValue = if (reverseLayout.value) 1f else 0f, - animationSpec = spring( - dampingRatio = 0.9f, - stiffness = Spring.StiffnessLow - ) - ) - - val policy = remember(isPad(), isLandscape()) { - when { - isPad() && isLandscape() -> drawerMeasurePolicy( - minWidthForMainContent = minWidthForMainContent, - animateProgress = animateProgress.value + val density = LocalDensity.current + val mainContentWidthPx = remember { density.run { 360.dp.toPx() }.toInt() } + val horizontalPaddingPx = remember { density.run { 16.dp.toPx() }.toInt() } + + val policy = remember(windowClass.widthSizeClass) { + when (windowClass.widthSizeClass) { + WindowWidthSizeClass.Expanded -> expendedPolicy( + horizontalPaddingPx = horizontalPaddingPx, + mainContentWidthPx = mainContentWidthPx ) - isPad() -> boxMeasurePolicy(targetIndex = listOf(0, 2)) - - // 普通手机端则Fixed,避免宽高变化影响界面 - else -> fixedMeasurePolicy( - isLandscape = isLandscape(), - targetIndex = listOf(0, 2) - ) + else -> boxPolicy() } } Layout( content = { mainContent() - spacerContent() secondContent() }, measurePolicy = policy ) } - private fun boxMeasurePolicy( - targetIndex: List = listOf(0) - ) = MeasurePolicy { measurables, constraints -> - val placeable = targetIndex.mapNotNull { measurables.getOrNull(it) } - .map { it.measure(constraints) } - - layout( - width = constraints.maxWidth, - height = constraints.maxHeight - ) { - placeable.onEach { it.place(0, 0) } - } - } - - private fun drawerMeasurePolicy( - minWidthForMainContent: Float, - animateProgress: Float - ) = MeasurePolicy { measurables, constraints -> - // TODO 限制targetWidth的最大和最小值 - val targetWidth = minWidthForMainContent + offsetX.value + private fun expendedPolicy( + horizontalPaddingPx: Int, + mainContentWidthPx: Int, + ): MeasurePolicy = MeasurePolicy { measurables, constraints -> + val mainPlaceable = measurables[0] + .measure(constraints.copy(maxWidth = mainContentWidthPx)) - val spacer = measurables.getOrNull(1)?.measure(constraints) - val main = measurables.getOrNull(0) - ?.measure( - constraints.copy( - maxWidth = targetWidth.toInt(), - minWidth = targetWidth.toInt() - ) - ) - - val lastXSpace = constraints.maxWidth - (main?.width ?: 0) - val second = measurables.getOrNull(2) - ?.measure(constraints.copy(maxWidth = lastXSpace)) + val secondWidth = + constraints.maxWidth - mainContentWidthPx - horizontalPaddingPx + val secondPlaceable = measurables[1] + .measure(constraints.copy(maxWidth = secondWidth)) layout(constraints.maxWidth, constraints.maxHeight) { - val mainX = lerp( - start = 0, - stop = (second?.width ?: 0) + (spacer?.width ?: 0), - fraction = animateProgress - ) - val spaceX = lerp( - start = main?.width ?: 0, - stop = second?.width ?: 0, - fraction = animateProgress - ) - val secondX = lerp( - start = main?.width ?: 0, - stop = 0, - fraction = animateProgress - ) + mainPlaceable.place(horizontalPaddingPx, 0) - main?.place(x = mainX, y = 0, zIndex = 20f) - spacer?.place(x = spaceX, y = 0, zIndex = 10f) - second?.place(x = secondX, y = 0, zIndex = 0f) + secondPlaceable.place(horizontalPaddingPx + mainContentWidthPx, 0) } } - private fun fixedMeasurePolicy( - targetIndex: List = listOf(0), - isLandscape: Boolean - ) = MeasurePolicy { measurables, constraints -> - val cConstraint = if (isLandscape) constraints.copy( - maxHeight = constraints.maxWidth, - maxWidth = constraints.maxHeight, - minHeight = constraints.minWidth, - minWidth = constraints.minHeight - ) else constraints - - val placeable = targetIndex.mapNotNull { measurables.getOrNull(it) } - .map { it.measure(cConstraint) } + private fun boxPolicy(): MeasurePolicy = MeasurePolicy { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } - layout( - width = cConstraint.maxWidth, - height = cConstraint.maxHeight - ) { - placeable.onEach { it.place(0, 0) } + layout(constraints.maxWidth, constraints.maxHeight) { + placeables.forEach { placeable -> + placeable.place(0, 0) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt index 73ebf08e0..ad5e28295 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt @@ -2,38 +2,28 @@ package com.lalilu.lmusic.compose import android.content.res.Configuration import androidx.compose.foundation.layout.BoxScope -import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalConfiguration -import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.extension.DialogWrapper import com.lalilu.component.extension.DynamicTipsHost -import com.lalilu.component.extension.rememberIsPad import com.lalilu.lmusic.compose.screen.ShowScreen import com.lalilu.lmusic.compose.screen.playing.PlayingLayout object LayoutWrapper { @Composable - fun BoxScope.Content(windowSize: WindowSizeClass = LocalWindowSize.current) { + fun BoxScope.Content() { val configuration = LocalConfiguration.current - val isPad by windowSize.rememberIsPad() val isLandscape by remember(configuration.orientation) { derivedStateOf { configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } } DrawerWrapper.Content( - isPad = { isPad }, - isLandscape = { isLandscape }, mainContent = { PlayingLayout() }, - secondContent = { - NavigationWrapper.Content( - forPad = { isPad && isLandscape } - ) - } + secondContent = { NavigationWrapper.Content() } ) if (isLandscape) { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt index b8fc80045..865f0c903 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt @@ -1,93 +1,58 @@ package com.lalilu.lmusic.compose -import androidx.activity.compose.BackHandler import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import cafe.adriel.voyager.core.annotation.InternalVoyagerApi -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.navigator.compositionUniqueId +import com.lalilu.component.base.BottomSheetNavigator import com.lalilu.component.base.BottomSheetNavigatorLayout -import com.lalilu.component.navigation.EnhanceNavigator -import com.lalilu.component.navigation.SheetController -import com.lalilu.component.navigation.createDefaultEnhanceNavigator +import com.lalilu.component.base.LocalWindowSize import com.lalilu.lmusic.compose.component.navigate.NavigationSheetContent import com.lalilu.lmusic.compose.new_screen.HomeScreen @OptIn(ExperimentalMaterialApi::class) object NavigationWrapper { - var navigator: EnhanceNavigator? by mutableStateOf(null) - private set - var sheetController: SheetController? by mutableStateOf(null) + var navigator: BottomSheetNavigator? by mutableStateOf(null) private set - @OptIn(InternalVoyagerApi::class) @Composable fun Content( modifier: Modifier = Modifier, - forPad: () -> Boolean = { false } ) { + val windowSizeClass = LocalWindowSize.current // 共用Navigator避免切换时导致导航栈丢失 - Navigator( - HomeScreen, - onBackPressed = null, - key = compositionUniqueId() - ) { navigator -> - if (forPad()) { - val isVisible by remember(navigator) { derivedStateOf { navigator.items.size > 1 } } - val emptyNavigator = remember(navigator) { - createDefaultEnhanceNavigator(navigator).also { - this@NavigationWrapper.sheetController = null - this@NavigationWrapper.navigator = it - } - } - - BackHandler(enabled = isVisible) { - emptyNavigator.back() - } + val animateSpec = remember { + tween( + durationMillis = 150, + easing = CubicBezierEasing(0.1f, 0.16f, 0f, 1f) + ) + } + BottomSheetNavigatorLayout( + modifier = modifier.fillMaxSize(), + defaultScreen = HomeScreen, + scrimColor = Color.Black.copy(alpha = 0.5f), + sheetBackgroundColor = MaterialTheme.colors.background, + enableBottomSheetMode = { windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded }, + animationSpec = animateSpec, + sheetContent = { sheetNavigator -> + this@NavigationWrapper.navigator = sheetNavigator NavigationSheetContent( modifier = modifier, - navigator = emptyNavigator, - transitionKeyPrefix = "forPad" + transitionKeyPrefix = "bottomSheet", + navigator = sheetNavigator ) - } else { - val animateSpec = remember { - tween( - durationMillis = 150, - easing = CubicBezierEasing(0.1f, 0.16f, 0f, 1f) - ) - } - BottomSheetNavigatorLayout( - modifier = modifier.fillMaxSize(), - navigator = navigator, - defaultIsVisible = false, - scrimColor = Color.Black.copy(alpha = 0.5f), - sheetBackgroundColor = MaterialTheme.colors.background, - animationSpec = animateSpec, - sheetContent = { sheetNavigator -> - this@NavigationWrapper.navigator = sheetNavigator - this@NavigationWrapper.sheetController = sheetNavigator - NavigationSheetContent( - modifier = modifier, - transitionKeyPrefix = "bottomSheet", - navigator = sheetNavigator, - sheetController = sheetNavigator - ) - } - ) { } } - } + ) { } } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt index 9eb2e0c0b..6c41ec183 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt @@ -50,13 +50,13 @@ import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.stack.Stack import com.lalilu.R +import com.lalilu.component.base.BottomSheetNavigator import com.lalilu.component.base.CustomScreen import com.lalilu.component.base.DynamicScreen +import com.lalilu.component.base.LocalBottomSheetNavigator import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.TabScreen import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.navigation.EnhanceNavigator -import com.lalilu.component.navigation.SheetController sealed class NavigationBarState { data class ForTabScreen(val tabScreens: List) : NavigationBarState() @@ -81,11 +81,11 @@ fun rememberNavigationBarState( @Composable fun rememberPreviousScreenTitleRes( - stack: Stack, + stack: Stack?, currentScreen: Screen? ): State { val previousScreen by remember(currentScreen) { - derivedStateOf { stack.items.getOrNull(stack.size - 2) as? CustomScreen } + derivedStateOf { stack?.items?.getOrNull(stack.size - 2) as? CustomScreen } } val previousInfo by remember { derivedStateOf { previousScreen?.getScreenInfo() } @@ -101,8 +101,7 @@ fun NavigationBar( modifier: Modifier = Modifier, tabScreens: () -> List, currentScreen: () -> Screen?, - navigator: EnhanceNavigator, - sheetController: SheetController? = null, + navigator: BottomSheetNavigator? = LocalBottomSheetNavigator.current ) { val navigationBarState = rememberNavigationBarState(tabScreens = tabScreens, currentScreen = currentScreen) @@ -117,7 +116,7 @@ fun NavigationBar( NavigateTabBar( tabScreens = state::tabScreens::get, currentScreen = currentScreen, - onSelectTab = { navigator.jump(it) } + onSelectTab = { navigator?.jump(it) } ) } @@ -130,9 +129,7 @@ fun NavigationBar( NavigateCommonBar( previousTitle = { previousTitle.value }, - screenActions = { actions }, - navigator = navigator, - sheetController = sheetController + screenActions = { actions } ) } } @@ -171,8 +168,7 @@ fun NavigateCommonBar( modifier: Modifier = Modifier, previousTitle: () -> Int, screenActions: () -> List, - navigator: EnhanceNavigator, - sheetController: SheetController? = null, + navigator: BottomSheetNavigator? = LocalBottomSheetNavigator.current ) { val itemFitImePadding = remember { mutableStateOf(false) } @@ -191,7 +187,7 @@ fun NavigateCommonBar( shape = RectangleShape, contentPadding = PaddingValues(start = 16.dp, end = 24.dp), colors = ButtonDefaults.textButtonColors(contentColor = contentColor), - onClick = { navigator.back() } + onClick = { navigator?.back() } ) { Image( painter = painterResource(id = R.drawable.ic_arrow_left_s_line), @@ -267,7 +263,7 @@ fun NavigateCommonBar( backgroundColor = Color(0x25FE4141), contentColor = Color(0xFFFE4141) ), - onClick = { sheetController?.hide() } + onClick = { navigator?.hide() } ) { Text( text = stringResource(id = R.string.bottom_sheet_navigate_close), diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt index 8ba586745..d4864a13a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt @@ -20,10 +20,10 @@ import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.lalilu.component.base.BottomSheetNavigator import com.lalilu.component.base.LocalPaddingValue -import com.lalilu.component.navigation.EnhanceNavigator -import com.lalilu.component.navigation.LocalSheetController -import com.lalilu.component.navigation.SheetController +import com.lalilu.component.base.LocalWindowSize +import com.lalilu.component.extension.rememberIsPad import com.lalilu.lmusic.compose.component.CustomTransition import com.lalilu.lmusic.compose.new_screen.HomeScreen import com.lalilu.lmusic.compose.new_screen.SearchScreen @@ -34,7 +34,9 @@ fun ImmerseStatusBar( enable: () -> Boolean = { true }, isExpended: () -> Boolean = { false }, ) { - val result by remember { derivedStateOf { isExpended() && enable() } } + val windowSize = LocalWindowSize.current + val isPad by windowSize.rememberIsPad() + val result by remember { derivedStateOf { (isExpended() && enable()) || isPad } } val systemUiController = rememberSystemUiController() val isDarkModeNow = isSystemInDarkTheme() @@ -50,12 +52,11 @@ fun ImmerseStatusBar( fun NavigationSheetContent( modifier: Modifier, transitionKeyPrefix: String, - navigator: EnhanceNavigator, - sheetController: SheetController? = LocalSheetController.current, + navigator: BottomSheetNavigator, getScreenFrom: (Navigator) -> Screen = { it.lastItem }, ) { ImmerseStatusBar( - isExpended = { sheetController?.isVisible ?: false } + isExpended = { navigator.isVisible } ) Box(modifier = Modifier.fillMaxSize()) { @@ -92,9 +93,7 @@ fun NavigationSheetContent( SearchScreen ) }, - currentScreen = { currentScreen.value }, - navigator = navigator, - sheetController = sheetController + currentScreen = { currentScreen.value } ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt index c66f23a61..68b14f325 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt @@ -103,12 +103,12 @@ fun BoxScope.SeekbarLayout( OnSeekBarScrollToThresholdListener({ 300f }) { override fun onScrollToThreshold() { HapticUtils.haptic(this@apply) - NavigationWrapper.sheetController?.show() + NavigationWrapper.navigator?.show() } override fun onScrollRecover() { HapticUtils.haptic(this@apply) - NavigationWrapper.sheetController?.hide() + NavigationWrapper.navigator?.hide() } }) diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt similarity index 56% rename from component/src/main/java/com/lalilu/component/base/BottomSheetController.kt rename to component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt index 0e60a954a..00ef5e05a 100644 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetController.kt +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt @@ -1,106 +1,126 @@ package com.lalilu.component.base -import android.annotation.SuppressLint import androidx.activity.compose.BackHandler import androidx.compose.animation.core.AnimationSpec import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetDefaults -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetState -import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.contentColorFor -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.stack.Stack import cafe.adriel.voyager.navigator.CurrentScreen import cafe.adriel.voyager.navigator.Navigator import com.lalilu.component.navigation.EnhanceNavigator -import com.lalilu.component.navigation.LocalSheetController -import com.lalilu.component.navigation.SheetController +import com.lalilu.component.override.ModalBottomSheetDefaults +import com.lalilu.component.override.ModalBottomSheetLayout +import com.lalilu.component.override.ModalBottomSheetState +import com.lalilu.component.override.ModalBottomSheetValue +import com.lalilu.component.override.rememberModalBottomSheetState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -typealias BottomSheetNavigatorContent = @Composable (bottomSheetNavigator: BottomSheetController) -> Unit +typealias BottomSheetNavigatorContent = @Composable (bottomSheetNavigator: BottomSheetNavigator) -> Unit + +val LocalBottomSheetNavigator: ProvidableCompositionLocal = + staticCompositionLocalOf { null } -@SuppressLint("UnnecessaryComposedModifier") @ExperimentalMaterialApi @Composable fun BottomSheetNavigatorLayout( modifier: Modifier = Modifier, - navigator: Navigator, - hideOnBackPress: Boolean = true, - defaultIsVisible: Boolean = false, + defaultScreen: Screen, scrimColor: Color = ModalBottomSheetDefaults.scrimColor, sheetShape: Shape = MaterialTheme.shapes.large, - sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, + sheetElevation: Dp = 0.dp, sheetBackgroundColor: Color = MaterialTheme.colors.surface, sheetContentColor: Color = contentColorFor(sheetBackgroundColor), sheetGesturesEnabled: Boolean = true, skipHalfExpanded: Boolean = true, + enableBottomSheetMode: () -> Boolean = { true }, animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, sheetContent: BottomSheetNavigatorContent = { CurrentScreen() }, content: BottomSheetNavigatorContent ) { val coroutineScope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, // initialValue 不可动态修改,重组时与预取效果不符 + initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = skipHalfExpanded, + enableBottomSheetMode = enableBottomSheetMode, animationSpec = animationSpec ) - val bottomSheetNavigator = remember { - BottomSheetController( - navigator = navigator, - sheetState = sheetState, - coroutineScope = coroutineScope - ) - } + Navigator(screen = defaultScreen, onBackPressed = null) { navigator -> + val bottomSheetNavigator = remember { + BottomSheetNavigator( + navigator = navigator, + sheetState = sheetState, + coroutineScope = coroutineScope + ) + } - CompositionLocalProvider(LocalSheetController provides bottomSheetNavigator) { - ModalBottomSheetLayout( - modifier = modifier, - scrimColor = scrimColor, - sheetState = sheetState, - sheetShape = sheetShape, - sheetElevation = sheetElevation, - sheetBackgroundColor = sheetBackgroundColor, - sheetContentColor = sheetContentColor, - sheetGesturesEnabled = sheetGesturesEnabled, - sheetContent = { - BackHandler(enabled = bottomSheetNavigator.isVisible && hideOnBackPress) { - bottomSheetNavigator.back() - } - sheetContent(bottomSheetNavigator) - }, - content = { content(bottomSheetNavigator) } - ) + CompositionLocalProvider(LocalBottomSheetNavigator provides bottomSheetNavigator) { + ModalBottomSheetLayout( + modifier = modifier, + scrimColor = scrimColor, + sheetState = sheetState, + sheetShape = sheetShape, + sheetElevation = sheetElevation, + sheetBackgroundColor = sheetBackgroundColor, + sheetContentColor = sheetContentColor, + sheetGesturesEnabled = sheetGesturesEnabled, + sheetContent = { + BackHandler(enabled = bottomSheetNavigator.isVisible) { + bottomSheetNavigator.back() + } + sheetContent(bottomSheetNavigator) + }, + content = { content(bottomSheetNavigator) } + ) + } } } -class BottomSheetController internal constructor( +class BottomSheetNavigator internal constructor( private val navigator: Navigator, private val sheetState: ModalBottomSheetState, private val coroutineScope: CoroutineScope -) : Stack by navigator, SheetController, EnhanceNavigator { +) : Stack by navigator, EnhanceNavigator { + + val isVisible: Boolean by derivedStateOf { + if (!sheetState.enabled) { + return@derivedStateOf items.size > 1 + } - override val isVisible: Boolean by derivedStateOf { sheetState.progress( from = ModalBottomSheetValue.Hidden, to = ModalBottomSheetValue.Expanded ) >= 0.95 } + fun hide() { + if (isVisible) { + coroutineScope.launch { sheetState.hide() } + } + } + + fun show() { + if (!isVisible) { + coroutineScope.launch { sheetState.show() } + } + } + override fun preBack(currentScreen: Screen?): Boolean { // 若当前只剩一个页面,则不清空元素了 if (items.size <= 1) { @@ -119,17 +139,5 @@ class BottomSheetController internal constructor( return true } - override fun hide() { - if (isVisible) { - coroutineScope.launch { sheetState.hide() } - } - } - - override fun show() { - if (!isVisible) { - coroutineScope.launch { sheetState.show() } - } - } - override fun getNavigator(): Navigator = navigator } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt index 3aae58ede..7fd59044d 100644 --- a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt +++ b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt @@ -1,5 +1,6 @@ package com.lalilu.component.base +import androidx.activity.compose.BackHandler import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable @@ -15,7 +16,6 @@ import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions -import com.lalilu.component.navigation.BackHandler import kotlinx.coroutines.CoroutineScope /** diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt index ebf1ec497..5e69bbe1a 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt @@ -65,15 +65,6 @@ fun rememberScreenHeightInPx(): Int { } } -@OptIn(ExperimentalMaterialApi::class) -fun SwipeProgress.watchForOffset( - betweenFirst: ModalBottomSheetValue, betweenSecond: ModalBottomSheetValue, elseValue: Float = 1f -): Float = when { - from == betweenFirst && to == betweenSecond -> 1f - (fraction * 3) - from == betweenSecond && to == betweenFirst -> fraction * 3 - else -> elseValue -}.coerceIn(0f, 1f) - /** * 根据屏幕的长宽类型来判断设备是否平板 * 依据是:平板没有一条边会是Compact的 diff --git a/component/src/main/java/com/lalilu/component/navigation/SheetController.kt b/component/src/main/java/com/lalilu/component/navigation/SheetController.kt index 1821ae257..6756880be 100644 --- a/component/src/main/java/com/lalilu/component/navigation/SheetController.kt +++ b/component/src/main/java/com/lalilu/component/navigation/SheetController.kt @@ -1,31 +1,10 @@ package com.lalilu.component.navigation -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.staticCompositionLocalOf import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.stack.Stack import cafe.adriel.voyager.navigator.Navigator import com.lalilu.component.base.TabScreen -interface SheetController { - val isVisible: Boolean - fun hide() - fun show() -} - -val LocalSheetController: ProvidableCompositionLocal = - staticCompositionLocalOf { null } - -@Composable -fun BackHandler( - navigator: SheetController? = LocalSheetController.current, - onBack: () -> Unit -) { - BackHandler(enabled = navigator?.isVisible ?: false, onBack = onBack) -} - interface EnhanceNavigator : Stack { /** @@ -141,9 +120,3 @@ interface EnhanceNavigator : Stack { fun getNavigator(): Navigator } -fun createDefaultEnhanceNavigator(navigator: Navigator): EnhanceNavigator { - return object : Stack by navigator, EnhanceNavigator { - override fun getNavigator(): Navigator = navigator - } -} - diff --git a/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt index 8c370ebd9..7be757c71 100644 --- a/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt +++ b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt @@ -35,11 +35,8 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.contentColorFor -import com.lalilu.component.override.ModalBottomSheetState.Companion.Saver -import com.lalilu.component.override.ModalBottomSheetValue.Expanded -import com.lalilu.component.override.ModalBottomSheetValue.HalfExpanded -import com.lalilu.component.override.ModalBottomSheetValue.Hidden import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember @@ -67,10 +64,10 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max import kotlin.math.min -import kotlinx.coroutines.launch /** * Possible values of [ModalBottomSheetState]. @@ -112,6 +109,7 @@ enum class ModalBottomSheetValue { class ModalBottomSheetState( initialValue: ModalBottomSheetValue, density: Density, + enableBottomSheetMode: () -> Boolean = { true }, confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true }, internal val animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, internal val isSkipHalfExpanded: Boolean = false, @@ -129,6 +127,8 @@ class ModalBottomSheetState( velocityThreshold = { with(density) { ModalBottomSheetVelocityThreshold.toPx() } } ) + val enabled: Boolean by derivedStateOf(enableBottomSheetMode) + /** * The current value of the [ModalBottomSheetState]. */ @@ -181,15 +181,15 @@ class ModalBottomSheetState( * Whether the bottom sheet is visible. */ val isVisible: Boolean - get() = anchoredDraggableState.currentValue != Hidden + get() = anchoredDraggableState.currentValue != ModalBottomSheetValue.Hidden internal val hasHalfExpandedState: Boolean - get() = anchoredDraggableState.anchors.hasAnchorFor(HalfExpanded) + get() = anchoredDraggableState.anchors.hasAnchorFor(ModalBottomSheetValue.HalfExpanded) init { if (isSkipHalfExpanded) { - require(initialValue != HalfExpanded) { - "The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" + + require(initialValue != ModalBottomSheetValue.HalfExpanded) { + "The initial value must not be set to ModalBottomSheetValue.HalfExpanded if skipHalfExpanded is set to" + " true." } } @@ -201,10 +201,11 @@ class ModalBottomSheetState( * fully expanded. */ suspend fun show() { - val hasExpandedState = anchoredDraggableState.anchors.hasAnchorFor(Expanded) + val hasExpandedState = + anchoredDraggableState.anchors.hasAnchorFor(ModalBottomSheetValue.Expanded) val targetValue = when (currentValue) { - Hidden -> if (hasHalfExpandedState) HalfExpanded else Expanded - else -> if (hasExpandedState) Expanded else Hidden + ModalBottomSheetValue.Hidden -> if (hasHalfExpandedState) ModalBottomSheetValue.HalfExpanded else ModalBottomSheetValue.Expanded + else -> if (hasExpandedState) ModalBottomSheetValue.Expanded else ModalBottomSheetValue.Hidden } animateTo(targetValue) } @@ -217,24 +218,24 @@ class ModalBottomSheetState( if (!hasHalfExpandedState) { return } - animateTo(HalfExpanded) + animateTo(ModalBottomSheetValue.HalfExpanded) } /** * Hide the bottom sheet with animation and suspend until it if fully hidden or animation has * been cancelled. */ - suspend fun hide() = animateTo(Hidden) + suspend fun hide() = animateTo(ModalBottomSheetValue.Hidden) /** * Fully expand the bottom sheet with animation and suspend until it if fully expanded or * animation has been cancelled. */ internal suspend fun expand() { - if (!anchoredDraggableState.anchors.hasAnchorFor(Expanded)) { + if (!anchoredDraggableState.anchors.hasAnchorFor(ModalBottomSheetValue.Expanded)) { return } - animateTo(Expanded) + animateTo(ModalBottomSheetValue.Expanded) } internal suspend fun animateTo( @@ -255,6 +256,7 @@ class ModalBottomSheetState( */ fun Saver( animationSpec: AnimationSpec, + enableBottomSheetMode: () -> Boolean, confirmValueChange: (ModalBottomSheetValue) -> Boolean, skipHalfExpanded: Boolean, density: Density @@ -264,6 +266,7 @@ class ModalBottomSheetState( ModalBottomSheetState( initialValue = it, density = density, + enableBottomSheetMode = enableBottomSheetMode, animationSpec = animationSpec, isSkipHalfExpanded = skipHalfExpanded, confirmValueChange = confirmValueChange @@ -289,6 +292,7 @@ class ModalBottomSheetState( @Composable fun rememberModalBottomSheetState( initialValue: ModalBottomSheetValue, + enableBottomSheetMode: () -> Boolean = { true }, animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true }, skipHalfExpanded: Boolean = false, @@ -300,9 +304,10 @@ fun rememberModalBottomSheetState( return key(initialValue) { rememberSaveable( initialValue, animationSpec, skipHalfExpanded, confirmValueChange, density, - saver = Saver( + saver = ModalBottomSheetState.Saver( density = density, animationSpec = animationSpec, + enableBottomSheetMode = enableBottomSheetMode, skipHalfExpanded = skipHalfExpanded, confirmValueChange = confirmValueChange ) @@ -311,6 +316,7 @@ fun rememberModalBottomSheetState( density = density, initialValue = initialValue, animationSpec = animationSpec, + enableBottomSheetMode = enableBottomSheetMode, isSkipHalfExpanded = skipHalfExpanded, confirmValueChange = confirmValueChange ) @@ -353,7 +359,7 @@ fun ModalBottomSheetLayout( sheetContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, sheetState: ModalBottomSheetState = - rememberModalBottomSheetState(Hidden), + rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), sheetGesturesEnabled: Boolean = true, sheetShape: Shape = MaterialTheme.shapes.large, sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, @@ -370,75 +376,84 @@ fun ModalBottomSheetLayout( Scrim( color = scrimColor, onDismiss = { - if (sheetState.anchoredDraggableState.confirmValueChange(Hidden)) { + if (sheetState.anchoredDraggableState.confirmValueChange(ModalBottomSheetValue.Hidden)) { scope.launch { sheetState.hide() } } }, - visible = sheetState.anchoredDraggableState.targetValue != Hidden + visible = sheetState.anchoredDraggableState.targetValue != ModalBottomSheetValue.Hidden ) } Surface( Modifier .align(Alignment.TopCenter) // We offset from the top so we'll center from there - .widthIn(max = MaxModalBottomSheetWidth) .fillMaxWidth() .then( - if (sheetGesturesEnabled) { - Modifier.nestedScroll( - remember(sheetState.anchoredDraggableState, orientation) { - ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( - state = sheetState.anchoredDraggableState, - orientation = orientation - ) - } - ) - } else Modifier - ) - .modalBottomSheetAnchors(sheetState) - .anchoredDraggable( - state = sheetState.anchoredDraggableState, - orientation = orientation, - enabled = sheetGesturesEnabled && - sheetState.anchoredDraggableState.currentValue != Hidden, - ) - .then( - if (sheetGesturesEnabled) { - Modifier.semantics { - if (sheetState.isVisible) { - dismiss { - if ( - sheetState.anchoredDraggableState.confirmValueChange(Hidden) - ) { - scope.launch { sheetState.hide() } - } - true - } - if (sheetState.anchoredDraggableState.currentValue - == HalfExpanded - ) { - expand { - if (sheetState.anchoredDraggableState.confirmValueChange( - Expanded + if (sheetState.enabled) { + Modifier + .widthIn(max = MaxModalBottomSheetWidth) + .then( + if (sheetGesturesEnabled) { + Modifier.nestedScroll( + remember(sheetState.anchoredDraggableState, orientation) { + ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + state = sheetState.anchoredDraggableState, + orientation = orientation ) - ) { - scope.launch { sheetState.expand() } } - true - } - } else if (sheetState.hasHalfExpandedState) { - collapse { - if (sheetState.anchoredDraggableState.confirmValueChange( - HalfExpanded - ) - ) { - scope.launch { sheetState.halfExpand() } + ) + } else Modifier + ) + .modalBottomSheetAnchors(sheetState) + .anchoredDraggable( + state = sheetState.anchoredDraggableState, + orientation = orientation, + enabled = sheetGesturesEnabled && + sheetState.anchoredDraggableState.currentValue != ModalBottomSheetValue.Hidden, + ) + .then( + if (sheetGesturesEnabled) { + Modifier.semantics { + if (sheetState.isVisible) { + dismiss { + if ( + sheetState.anchoredDraggableState.confirmValueChange( + ModalBottomSheetValue.Hidden + ) + ) { + scope.launch { sheetState.hide() } + } + true + } + if (sheetState.anchoredDraggableState.currentValue + == ModalBottomSheetValue.HalfExpanded + ) { + expand { + if (sheetState.anchoredDraggableState.confirmValueChange( + ModalBottomSheetValue.Expanded + ) + ) { + scope.launch { sheetState.expand() } + } + true + } + } else if (sheetState.hasHalfExpandedState) { + collapse { + if (sheetState.anchoredDraggableState.confirmValueChange( + ModalBottomSheetValue.HalfExpanded + ) + ) { + scope.launch { sheetState.halfExpand() } + } + true + } + } } - true } - } - } - } - } else Modifier + } else Modifier + ) + } else { + Modifier + } ), shape = sheetShape, elevation = sheetElevation, @@ -457,13 +472,13 @@ private fun Modifier.modalBottomSheetAnchors(sheetState: ModalBottomSheetState) ) { sheetSize, constraints -> val fullHeight = constraints.maxHeight.toFloat() val newAnchors = DraggableAnchors { - Hidden at fullHeight + ModalBottomSheetValue.Hidden at fullHeight val halfHeight = fullHeight / 2f if (!sheetState.isSkipHalfExpanded && sheetSize.height > halfHeight) { - HalfExpanded at halfHeight + ModalBottomSheetValue.HalfExpanded at halfHeight } if (sheetSize.height != 0) { - Expanded at max(0f, fullHeight - sheetSize.height) + ModalBottomSheetValue.Expanded at max(0f, fullHeight - sheetSize.height) } } // If we are setting the anchors for the first time and have an anchor for @@ -474,15 +489,16 @@ private fun Modifier.modalBottomSheetAnchors(sheetState: ModalBottomSheetState) previousValue } else { when (sheetState.targetValue) { - Hidden -> Hidden - HalfExpanded, Expanded -> { - val hasHalfExpandedState = newAnchors.hasAnchorFor(HalfExpanded) + ModalBottomSheetValue.Hidden -> ModalBottomSheetValue.Hidden + ModalBottomSheetValue.HalfExpanded, ModalBottomSheetValue.Expanded -> { + val hasHalfExpandedState = + newAnchors.hasAnchorFor(ModalBottomSheetValue.HalfExpanded) val newTarget = if (hasHalfExpandedState) { - HalfExpanded - } else if (newAnchors.hasAnchorFor(Expanded)) { - Expanded + ModalBottomSheetValue.HalfExpanded + } else if (newAnchors.hasAnchorFor(ModalBottomSheetValue.Expanded)) { + ModalBottomSheetValue.Expanded } else { - Hidden + ModalBottomSheetValue.Hidden } newTarget } From e42389db03325973c9396b08e4a585a548d26602 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 May 2024 11:01:38 +0800 Subject: [PATCH 028/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4flyjingfish-a?= =?UTF-8?q?op=E5=BC=95=E5=85=A5=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 +- build.gradle.kts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67deeac79..f6aaa650f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { id("com.android.application") kotlin("android") id("com.google.devtools.ksp") - alias(libs.plugins.flyjingfish.aop) + id("android.aop") } val keystoreProps = rootProject.file("keystore.properties") diff --git a/build.gradle.kts b/build.gradle.kts index 7d807818c..d2df5d13f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.library) apply false alias(libs.plugins.kotlin) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.flyjingfish.aop) apply false } gradle.taskGraph.whenReady { From b138268219ac6975b3497c898638f99653731d14 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 May 2024 11:28:44 +0800 Subject: [PATCH 029/213] =?UTF-8?q?[modify]=E5=85=A8=E5=B1=80=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E6=89=80=E6=9C=89=E7=9A=84BackHandler=EF=BC=8C?= =?UTF-8?q?=E6=8F=92=E5=85=A5isVisible=E6=8E=A7=E5=88=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/aop/BackHandlerOverride.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 app/src/main/java/com/lalilu/lmusic/aop/BackHandlerOverride.kt diff --git a/app/src/main/java/com/lalilu/lmusic/aop/BackHandlerOverride.kt b/app/src/main/java/com/lalilu/lmusic/aop/BackHandlerOverride.kt new file mode 100644 index 000000000..90bb048c7 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/aop/BackHandlerOverride.kt @@ -0,0 +1,37 @@ +package com.lalilu.lmusic.aop + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import com.flyjingfish.android_aop_annotation.anno.AndroidAopReplaceClass +import com.flyjingfish.android_aop_annotation.anno.AndroidAopReplaceMethod +import com.flyjingfish.android_aop_annotation.enums.MatchType +import com.lalilu.component.base.LocalBottomSheetNavigator + +/** + * 全局替换所有的BackHandler + */ +@AndroidAopReplaceClass( + value = "androidx.activity.compose.BackHandlerKt", + type = MatchType.SELF +) +object BackHandlerOverride { + + @JvmStatic + @Composable + @AndroidAopReplaceMethod( + value = "void BackHandler(boolean, kotlin.jvm.functions.Function0, androidx.compose.runtime.Composer, int, int)" + ) + fun BackHandlerOverride(enabled: Boolean = true, onBack: () -> Unit) { + val sheetNavigator = LocalBottomSheetNavigator.current + // 若可获取到SheetNavigator,则说明其处于BottomSheet内,则为其关联isVisible控制 + if (sheetNavigator == null) { + BackHandler(enabled, onBack) + return + } + + BackHandler( + enabled = sheetNavigator.isVisible && enabled, + onBack = onBack + ) + } +} \ No newline at end of file From 32c09a8d743bc58dc0ce8c575db7e5e0262c12ce Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 May 2024 11:53:31 +0800 Subject: [PATCH 030/213] =?UTF-8?q?[modify]=E6=81=A2=E5=A4=8DHomeScreen?= =?UTF-8?q?=E5=B8=83=E5=B1=80=EF=BC=8C=E5=B0=86=E5=B9=B3=E6=9D=BF=E9=80=82?= =?UTF-8?q?=E9=85=8D=E9=80=BB=E8=BE=91=E8=BD=AC=E7=A7=BB=E8=87=B3=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E5=AD=90=E5=85=83=E7=B4=A0=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/new_screen/HomeScreen.kt | 43 +++++++------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt index e6ef3193d..cd70feda9 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt @@ -1,18 +1,17 @@ package com.lalilu.lmusic.compose.new_screen -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.unit.dp +import androidx.compose.ui.Modifier import com.lalilu.R -import com.lalilu.component.TwoColumnWithPad +import com.lalilu.component.LLazyColumn import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.TabScreen import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.extension.EntryPanel import com.lalilu.lmusic.extension.dailyRecommend -import com.lalilu.lmusic.extension.dailyRecommendVertical import com.lalilu.lmusic.extension.historyPanel import com.lalilu.lmusic.extension.latestPanel import com.lalilu.lmusic.viewmodel.HistoryViewModel @@ -35,32 +34,22 @@ object HomeScreen : DynamicScreen(), TabScreen { libraryVM.checkOrUpdateToday() } - TwoColumnWithPad( - arrangementForPad = Arrangement.spacedBy(10.dp), - columnForPad = { - dailyRecommendVertical(libraryVM = libraryVM) - }, - columnForNormal = { isPad -> - if (!isPad) { - dailyRecommend( - libraryVM = libraryVM, - ) - } + LLazyColumn(modifier = Modifier.fillMaxSize()) { + dailyRecommend(libraryVM = libraryVM) - latestPanel( - libraryVM = libraryVM, - playingVM = playingVM - ) + latestPanel( + libraryVM = libraryVM, + playingVM = playingVM + ) - historyPanel( - historyVM = historyVM, - playingVM = playingVM - ) + historyPanel( + historyVM = historyVM, + playingVM = playingVM + ) - item { - EntryPanel() - } + item { + EntryPanel() } - ) + } } } From 76d0a339e98ec610e18fd6e6d87793f3e2a51718 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 May 2024 11:54:25 +0800 Subject: [PATCH 031/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt | 1 - .../com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt | 4 +++- gradle/libs.versions.toml | 4 ++-- settings.gradle.kts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt index 865f0c903..1a3efa914 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt @@ -30,7 +30,6 @@ object NavigationWrapper { modifier: Modifier = Modifier, ) { val windowSizeClass = LocalWindowSize.current - // 共用Navigator避免切换时导致导航栈丢失 val animateSpec = remember { tween( durationMillis = 150, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt index c364f8bb7..6173fc764 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil.compose.AsyncImage +import coil.drawable.CrossfadeDrawable import coil.request.ImageRequest @@ -19,6 +20,7 @@ fun ImageBgBox( contentAlignment: Alignment = Alignment.TopCenter, imageData: Any? = null, imageModifier: Modifier = Modifier, + imageCrossFadeDuration: Int = CrossfadeDrawable.DEFAULT_DURATION, content: @Composable BoxScope.() -> Unit = {}, ) { val context = LocalContext.current @@ -26,7 +28,7 @@ fun ImageBgBox( imageData?.let { ImageRequest.Builder(context) .data(it) - .crossfade(true) + .crossfade(imageCrossFadeDuration) .build() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7d60f6b1..063ceb566 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ lifecycle_version = "2.6.2" navigation_version = "2.7.4" room_version = "2.5.2" media = "1.7.0" -flyjingfish-aop = "1.3.6" +flyjingfish-aop = "1.6.3" [libraries] # kotlin @@ -53,7 +53,7 @@ compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout-android" } compose-material = { module = "androidx.compose.material:material" } -#compose-material3 = { module = "androidx.compose.material3:material3" } +compose-material3 = { module = "androidx.compose.material3:material3" } compose-material3-window-size = { module = "androidx.compose.material3:material3-window-size-class" } compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } compose-tooling = { module = "androidx.compose.ui:ui-tooling" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 60eb2369b..abeeed8b4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,7 +20,7 @@ dependencyResolutionManagement { } -rootProject.name = "lmusic" +rootProject.name = "LMusic" include(":app") include(":ui") include(":common") From 753d1cd81b48ebab36d1f7cdf81cb6de768d4968 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 May 2024 18:00:52 +0800 Subject: [PATCH 032/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E7=AB=AF=E5=92=8C=E5=B9=B3=E6=9D=BF=E7=AB=AF=E7=9A=84?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=B8=83=E5=B1=80=EF=BC=8C=E5=8E=BB=E9=99=A4?= =?UTF-8?q?=E5=A4=9A=E4=BD=99padding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lmusic/compose/DrawerWrapper.kt | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt index 288a3add7..7d4f2547f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt @@ -1,5 +1,6 @@ package com.lalilu.lmusic.compose +import androidx.compose.material.Surface import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable @@ -20,12 +21,10 @@ object DrawerWrapper { ) { val density = LocalDensity.current val mainContentWidthPx = remember { density.run { 360.dp.toPx() }.toInt() } - val horizontalPaddingPx = remember { density.run { 16.dp.toPx() }.toInt() } val policy = remember(windowClass.widthSizeClass) { when (windowClass.widthSizeClass) { - WindowWidthSizeClass.Expanded -> expendedPolicy( - horizontalPaddingPx = horizontalPaddingPx, + WindowWidthSizeClass.Expanded -> rowPolicy( mainContentWidthPx = mainContentWidthPx ) @@ -35,29 +34,27 @@ object DrawerWrapper { Layout( content = { - mainContent() + Surface { mainContent() } secondContent() }, measurePolicy = policy ) } - private fun expendedPolicy( - horizontalPaddingPx: Int, + private fun rowPolicy( mainContentWidthPx: Int, ): MeasurePolicy = MeasurePolicy { measurables, constraints -> val mainPlaceable = measurables[0] .measure(constraints.copy(maxWidth = mainContentWidthPx)) - val secondWidth = - constraints.maxWidth - mainContentWidthPx - horizontalPaddingPx + val secondWidth = constraints.maxWidth - mainContentWidthPx val secondPlaceable = measurables[1] .measure(constraints.copy(maxWidth = secondWidth)) layout(constraints.maxWidth, constraints.maxHeight) { - mainPlaceable.place(horizontalPaddingPx, 0) + mainPlaceable.place(0, 0) - secondPlaceable.place(horizontalPaddingPx + mainContentWidthPx, 0) + secondPlaceable.place(mainContentWidthPx, 0) } } From 599432584ffd9d132f7d3839953254c9716209ea Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 May 2024 18:01:40 +0800 Subject: [PATCH 033/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96dailyRecommen?= =?UTF-8?q?d=E7=BB=84=E4=BB=B6=E7=9A=84=E6=98=BE=E7=A4=BA=E6=95=88?= =?UTF-8?q?=E6=9E=9C=EF=BC=8C=E9=80=82=E9=85=8D=E5=B9=B3=E6=9D=BF=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/extension/DailyRecommend.kt | 159 ++++++++++++++---- 1 file changed, 123 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index 69daca74a..ebdf12131 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -1,16 +1,29 @@ package com.lalilu.lmusic.extension -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items import androidx.compose.material.Chip import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.lalilu.component.base.LocalWindowSize import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lmusic.compose.component.card.RecommendCard2 import com.lalilu.lmusic.compose.component.card.RecommendRow @@ -23,6 +36,7 @@ fun LazyListScope.dailyRecommend( ) { item { RecommendTitle( + modifier = Modifier.padding(vertical = 8.dp), title = "每日推荐", onClick = { val ids = libraryVM.dailyRecommends.value.map { it.mediaId } @@ -38,51 +52,124 @@ fun LazyListScope.dailyRecommend( } } item { - RecommendRow( - items = { libraryVM.dailyRecommends.value }, - getId = { it.id } - ) { + AnimatedContent( + targetState = LocalWindowSize.current.widthSizeClass, + label = "" + ) { windowWidthSizeClass -> + when (windowWidthSizeClass) { + WindowWidthSizeClass.Medium -> RecommendRowForSizeMedium(libraryVM) + WindowWidthSizeClass.Expanded -> RecommendRowForSizeExpanded(libraryVM) + else -> RecommendRow( + items = { libraryVM.dailyRecommends.value }, + getId = { it.id } + ) { + RecommendCard2( + item = { it }, + modifier = Modifier.size(width = 250.dp, height = 250.dp), + onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + ) + } + } + } + } +} + + +@Composable +fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { + val items by remember { derivedStateOf { libraryVM.dailyRecommends.value.take(3) } } + + Row( + modifier = Modifier + .height(250.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items[0].let { + RecommendCard2( + item = { it }, + modifier = Modifier + .fillMaxHeight() + .weight(1f), + onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + ) + } + + items[1].let { + RecommendCard2( + item = { it }, + modifier = Modifier + .width(150.dp) + .fillMaxHeight(), + onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + ) + } + + items[2].let { RecommendCard2( item = { it }, - contentModifier = Modifier.size(width = 250.dp, height = 250.dp), + modifier = Modifier + .width(150.dp) + .fillMaxHeight(), onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } ) } } } +@Composable +fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { + val items by remember { derivedStateOf { libraryVM.dailyRecommends.value.take(3) } } -@OptIn(ExperimentalMaterialApi::class) -fun LazyListScope.dailyRecommendVertical( - libraryVM: LibraryViewModel, -) { - item { - RecommendTitle( - modifier = Modifier.width(250.dp), - paddingValues = PaddingValues(), - title = "每日推荐", - onClick = { - val ids = libraryVM.dailyRecommends.value.map { it.mediaId } - GlobalNavigatorImpl.showSongs(ids) - } + Row( + modifier = Modifier + .height(250.dp) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items[0].let { + RecommendCard2( + item = { it }, + modifier = Modifier + .fillMaxHeight() + .weight(1f), + onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + ) + } + + Column( + modifier = Modifier + .width(275.dp) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Chip(onClick = { libraryVM.forceUpdate() }) { - Text( - style = MaterialTheme.typography.caption, - text = "换一换" + items[1].let { + RecommendCard2( + item = { it }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + onClick = { + GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) + } + ) + } + + items[2].let { + RecommendCard2( + item = { it }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + onClick = { + GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) + } ) } } } - items( - items = libraryVM.dailyRecommends.value, - key = { it.id }, - contentType = { "dailyRecommendsCard" } - ) { - RecommendCard2( - item = { it }, - contentModifier = Modifier.size(width = 250.dp, height = 250.dp), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } - ) - } -} \ No newline at end of file +} From 1ec6d97f1ef52c277b341ee498a63fd0a7fc2770 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 May 2024 18:01:58 +0800 Subject: [PATCH 034/213] =?UTF-8?q?[modify]=E4=B8=BAHomeScreen=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0StatusBar=E7=9A=84padding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/new_screen/HomeScreen.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt index cd70feda9..1e440a2b2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt @@ -1,6 +1,10 @@ package com.lalilu.lmusic.compose.new_screen +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -35,6 +39,13 @@ object HomeScreen : DynamicScreen(), TabScreen { } LLazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Spacer( + modifier = Modifier + .windowInsetsTopHeight(WindowInsets.statusBars) + ) + } + dailyRecommend(libraryVM = libraryVM) latestPanel( From 1c052ada0651f4f80e2e7b18e119f4101e23c044 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 27 Jun 2024 02:00:15 +0800 Subject: [PATCH 035/213] =?UTF-8?q?[modify]=E5=88=A0=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension-core/.gitignore | 1 - extension-core/build.gradle.kts | 48 ---- extension-core/proguard-rules.pro | 21 -- extension-core/src/main/AndroidManifest.xml | 4 - .../com/lalilu/extension_core/Constants.kt | 28 -- .../java/com/lalilu/extension_core/Ext.kt | 5 - .../com/lalilu/extension_core/Extension.kt | 32 --- .../extension_core/ExtensionClassLoader.kt | 14 - .../extension_core/ExtensionLoadResult.kt | 86 ------ .../lalilu/extension_core/ExtensionManager.kt | 129 --------- .../com/lalilu/extension_core/Provider.kt | 42 --- .../loader/CacheApkExtensionLoader.kt | 61 ----- .../extension_core/loader/ExtensionLoader.kt | 85 ------ .../loader/HostExtensionLoader.kt | 33 --- .../loader/SharedExtensionLoader.kt | 68 ----- extension-ksp/.gitignore | 1 - extension-ksp/build.gradle.kts | 15 -- .../main/java/com/lalilu/extension_ksp/Ext.kt | 5 - .../com/lalilu/extension_ksp/ExtProcessor.kt | 66 ----- .../extension_ksp/ExtProcessorProvider.kt | 14 - ...ols.ksp.processing.SymbolProcessorProvider | 1 - extension/.gitignore | 1 - extension/build.gradle.kts | 74 ------ extension/proguard-rules.pro | 44 ---- extension/src/main/AndroidManifest.xml | 15 -- .../java/com/lalilu/extension/Constants.kt | 81 ------ .../main/java/com/lalilu/extension/Main.kt | 154 ----------- .../java/com/lalilu/extension/MainScreen.kt | 66 ----- .../java/com/lalilu/extension/VitsSentence.kt | 26 -- .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1404 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 982 -> 0 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 1900 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 2884 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 3844 -> 0 bytes extension/src/main/res/values/strings.xml | 4 - lextension/.gitignore | 1 - lextension/build.gradle.kts | 36 --- lextension/consumer-rules.pro | 0 lextension/proguard-rules.pro | 21 -- lextension/src/main/AndroidManifest.xml | 4 - .../com/lalilu/lextension/ExtensionModule.kt | 13 - .../lextension/component/ExtensionCard.kt | 140 ---------- .../lextension/component/ExtensionList.kt | 180 ------------- .../lextension/repository/ExtensionSp.kt | 14 - .../lextension/screen/ExtensionsScreen.kt | 74 ------ .../src/main/res/values-zh-rCN/strings.xml | 5 - lextension/src/main/res/values/strings.xml | 5 - register/.gitignore | 1 - register/build.gradle.kts | 23 -- register/settings.gradle.kts | 0 .../java/com/lalilu/register/ClassInfo.kt | 9 - .../com/lalilu/register/InjectClassVisitor.kt | 50 ---- .../lalilu/register/InjectMethodVisitor.kt | 72 ----- .../lalilu/register/InjectTransformTask.kt | 247 ------------------ .../com/lalilu/register/RegisterConfig.kt | 33 --- .../java/com/lalilu/register/RegisterInfo.kt | 19 -- .../com/lalilu/register/RegisterPlugin.kt | 69 ----- .../com/lalilu/register/ScanClassVisitor.kt | 79 ------ .../register/ScanClassVisitorFactory.kt | 43 --- .../java/com/lalilu/register/TempParameter.kt | 10 - value-cat/.gitignore | 1 - value-cat/build.gradle.kts | 37 --- value-cat/consumer-rules.pro | 0 value-cat/proguard-rules.pro | 21 -- value-cat/src/main/AndroidManifest.xml | 19 -- .../main/java/com/lalilu/value_cat/StartUp.kt | 66 ----- .../java/com/lalilu/value_cat/ValueCat.kt | 127 --------- 67 files changed, 2643 deletions(-) delete mode 100644 extension-core/.gitignore delete mode 100644 extension-core/build.gradle.kts delete mode 100644 extension-core/proguard-rules.pro delete mode 100644 extension-core/src/main/AndroidManifest.xml delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/Constants.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/Ext.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/Extension.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/ExtensionClassLoader.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/ExtensionLoadResult.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/ExtensionManager.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/Provider.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/loader/CacheApkExtensionLoader.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/loader/ExtensionLoader.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/loader/HostExtensionLoader.kt delete mode 100644 extension-core/src/main/java/com/lalilu/extension_core/loader/SharedExtensionLoader.kt delete mode 100644 extension-ksp/.gitignore delete mode 100644 extension-ksp/build.gradle.kts delete mode 100644 extension-ksp/src/main/java/com/lalilu/extension_ksp/Ext.kt delete mode 100644 extension-ksp/src/main/java/com/lalilu/extension_ksp/ExtProcessor.kt delete mode 100644 extension-ksp/src/main/java/com/lalilu/extension_ksp/ExtProcessorProvider.kt delete mode 100644 extension-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider delete mode 100644 extension/.gitignore delete mode 100644 extension/build.gradle.kts delete mode 100644 extension/proguard-rules.pro delete mode 100644 extension/src/main/AndroidManifest.xml delete mode 100644 extension/src/main/java/com/lalilu/extension/Constants.kt delete mode 100644 extension/src/main/java/com/lalilu/extension/Main.kt delete mode 100644 extension/src/main/java/com/lalilu/extension/MainScreen.kt delete mode 100644 extension/src/main/java/com/lalilu/extension/VitsSentence.kt delete mode 100644 extension/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 extension/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 extension/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 extension/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 extension/src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 extension/src/main/res/values/strings.xml delete mode 100644 lextension/.gitignore delete mode 100644 lextension/build.gradle.kts delete mode 100644 lextension/consumer-rules.pro delete mode 100644 lextension/proguard-rules.pro delete mode 100644 lextension/src/main/AndroidManifest.xml delete mode 100644 lextension/src/main/java/com/lalilu/lextension/ExtensionModule.kt delete mode 100644 lextension/src/main/java/com/lalilu/lextension/component/ExtensionCard.kt delete mode 100644 lextension/src/main/java/com/lalilu/lextension/component/ExtensionList.kt delete mode 100644 lextension/src/main/java/com/lalilu/lextension/repository/ExtensionSp.kt delete mode 100644 lextension/src/main/java/com/lalilu/lextension/screen/ExtensionsScreen.kt delete mode 100644 lextension/src/main/res/values-zh-rCN/strings.xml delete mode 100644 lextension/src/main/res/values/strings.xml delete mode 100644 register/.gitignore delete mode 100644 register/build.gradle.kts delete mode 100644 register/settings.gradle.kts delete mode 100644 register/src/main/java/com/lalilu/register/ClassInfo.kt delete mode 100644 register/src/main/java/com/lalilu/register/InjectClassVisitor.kt delete mode 100644 register/src/main/java/com/lalilu/register/InjectMethodVisitor.kt delete mode 100644 register/src/main/java/com/lalilu/register/InjectTransformTask.kt delete mode 100644 register/src/main/java/com/lalilu/register/RegisterConfig.kt delete mode 100644 register/src/main/java/com/lalilu/register/RegisterInfo.kt delete mode 100644 register/src/main/java/com/lalilu/register/RegisterPlugin.kt delete mode 100644 register/src/main/java/com/lalilu/register/ScanClassVisitor.kt delete mode 100644 register/src/main/java/com/lalilu/register/ScanClassVisitorFactory.kt delete mode 100644 register/src/main/java/com/lalilu/register/TempParameter.kt delete mode 100644 value-cat/.gitignore delete mode 100644 value-cat/build.gradle.kts delete mode 100644 value-cat/consumer-rules.pro delete mode 100644 value-cat/proguard-rules.pro delete mode 100644 value-cat/src/main/AndroidManifest.xml delete mode 100644 value-cat/src/main/java/com/lalilu/value_cat/StartUp.kt delete mode 100644 value-cat/src/main/java/com/lalilu/value_cat/ValueCat.kt diff --git a/extension-core/.gitignore b/extension-core/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/extension-core/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/extension-core/build.gradle.kts b/extension-core/build.gradle.kts deleted file mode 100644 index 2bba8f470..000000000 --- a/extension-core/build.gradle.kts +++ /dev/null @@ -1,48 +0,0 @@ -plugins { - id("com.android.library") - kotlin("android") -} - -android { - namespace = "com.lalilu.extension_core" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION - - buildFeatures { - compose = true - } - - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - } - - buildTypes { - release { - consumerProguardFiles("proguard-rules.pro") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } -} - -dependencies { - api(project(":common")) - api(project(":lmedia")) - api(project(":lplayer")) - - api(libs.coil) - api(libs.coil.compose) - - // compose - api(libs.compose.compiler) - api(platform(libs.compose.bom)) - api(libs.bundles.compose) - debugApi(libs.bundles.compose.debug) -} \ No newline at end of file diff --git a/extension-core/proguard-rules.pro b/extension-core/proguard-rules.pro deleted file mode 100644 index f1b424510..000000000 --- a/extension-core/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile diff --git a/extension-core/src/main/AndroidManifest.xml b/extension-core/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68a..000000000 --- a/extension-core/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/Constants.kt b/extension-core/src/main/java/com/lalilu/extension_core/Constants.kt deleted file mode 100644 index d14ab0c66..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/Constants.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.lalilu.extension_core - -import android.content.pm.PackageManager -import android.os.Build -import androidx.compose.runtime.Composable - - -val EMPTY_CONTENT = @Composable {} - -object Content { - const val COMPONENT_HOME = "component_home" - const val COMPONENT_CATEGORY = "component_category" - const val COMPONENT_SETTINGS = "component_settings" - const val COMPONENT_MAIN = "component_main" - const val COMPONENT_DETAIL = "component_detail" - - const val PARAMS_MEDIA_ID = "mediaId" -} - -internal object Constants { - const val EXTENSION_FEATURE_NAME = "lmusic.extension" - const val EXTENSION_META_DATA_CLASS = "lmusic.extension.class" - const val EXTENSION_SOURCES_CLASS = "lalilu.extension_ksp.ExtensionsConstants" - val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or - PackageManager.GET_META_DATA or - PackageManager.GET_SIGNATURES or - (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0) -} \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/Ext.kt b/extension-core/src/main/java/com/lalilu/extension_core/Ext.kt deleted file mode 100644 index b9a734b49..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/Ext.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.lalilu.extension_core - -@Target(AnnotationTarget.CLASS) -@Retention(AnnotationRetention.SOURCE) -annotation class Ext \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/Extension.kt b/extension-core/src/main/java/com/lalilu/extension_core/Extension.kt deleted file mode 100644 index 8120299be..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/Extension.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.lalilu.extension_core - -import androidx.annotation.Keep -import androidx.compose.runtime.Composable -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner - -@Keep -interface Extension : LifecycleEventObserver { - - /** - * 注册返回内容提供器 - * - * @return 返回空则意味无内容提供能力 - */ - @Keep - fun getProvider(): Provider? = null - - /** - * 注册自定义的界面供宿主访问调用 - */ - @Keep - fun getContentMap(): Map) -> Unit> - - /** - * 监听宿主Activity的状态变化 - */ - @Keep - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - } -} \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/ExtensionClassLoader.kt b/extension-core/src/main/java/com/lalilu/extension_core/ExtensionClassLoader.kt deleted file mode 100644 index 4fa73c8cd..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/ExtensionClassLoader.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.lalilu.extension_core - -import dalvik.system.PathClassLoader - -class ExtensionClassLoader( - dexPath: String, - parent: ClassLoader, -) : PathClassLoader(dexPath, null, parent) { - override fun loadClass(name: String, resolve: Boolean): Class<*> = - runCatching { findClass(name) }.getOrElse { - if (name == Constants.EXTENSION_SOURCES_CLASS) throw ClassNotFoundException("${Constants.EXTENSION_SOURCES_CLASS} not exist in the Extension, try load classList by getExtensionListFromMeta()") - else super.loadClass(name, resolve) - } -} \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/ExtensionLoadResult.kt b/extension-core/src/main/java/com/lalilu/extension_core/ExtensionLoadResult.kt deleted file mode 100644 index 7a05823dc..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/ExtensionLoadResult.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.lalilu.extension_core - -import android.content.Context -import android.content.ContextWrapper -import android.content.pm.PackageInfo -import android.content.res.Resources -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext - -data class ExtensionMetadata( - val extId: String, - val name: String, - val intro: String, - val versionName: String, - val versionNumber: Int, -) - -sealed interface ExtensionEnvironment { - data class Package(val packageInfo: PackageInfo) : ExtensionEnvironment - data class Apk(val resources: Resources) : ExtensionEnvironment -} - -sealed class ExtensionLoadResult( - open val extId: String, - open val metadata: ExtensionMetadata -) { - data class Error( - override val extId: String, - override val metadata: ExtensionMetadata, - val message: String - ) : ExtensionLoadResult(extId, metadata) - - data class Ready( - override val extId: String, - override val metadata: ExtensionMetadata, - val extension: Extension, - val classLoader: ClassLoader, - val environment: ExtensionEnvironment, - val isOutOfDated: Boolean = false - ) : ExtensionLoadResult(extId, metadata) -} - - -@Composable -fun ExtensionLoadResult.Place( - context: Context = LocalContext.current, - contentKey: String, - params: Map = emptyMap(), - errorPlaceHolder: @Composable () -> Unit = {}, -) { - if (this !is ExtensionLoadResult.Ready) { - errorPlaceHolder() - return - } - - val configuration = LocalConfiguration.current - val tempContext = remember(context) { - runCatching { - this.environment.let { environment -> - when (environment) { - is ExtensionEnvironment.Apk -> { - object : ContextWrapper(context.createConfigurationContext(configuration)) { - override fun getResources(): Resources = environment.resources - } - } - - is ExtensionEnvironment.Package -> { - context.createPackageContext(environment.packageInfo.packageName, 0) - } - } - } - }.getOrNull() - } - val content = remember(contentKey) { - extension.getContentMap()[contentKey]?.takeIf { it !== EMPTY_CONTENT } - } - - if (tempContext != null && content != null) { - CompositionLocalProvider(LocalContext provides tempContext) { content(params) } - } else { - errorPlaceHolder() - } -} \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/ExtensionManager.kt b/extension-core/src/main/java/com/lalilu/extension_core/ExtensionManager.kt deleted file mode 100644 index 227a659c3..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/ExtensionManager.kt +++ /dev/null @@ -1,129 +0,0 @@ -package com.lalilu.extension_core - -import android.app.Activity -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import com.lalilu.extension_core.loader.CacheApkExtensionLoader -import com.lalilu.extension_core.loader.HostExtensionLoader -import com.lalilu.extension_core.loader.SharedExtensionLoader -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext - -@OptIn(ExperimentalCoroutinesApi::class) -object ExtensionManager : CoroutineScope, LifecycleEventObserver { - override val coroutineContext: CoroutineContext = Dispatchers.Default - - private var debounceJob: Job? = null - private var loadingJob: Job? = null - private val isLoadingFlow = MutableStateFlow(false) - val extensionsFlow = MutableStateFlow>(emptyList()) - private val loaders = listOf( - CacheApkExtensionLoader(), - HostExtensionLoader(), - SharedExtensionLoader() - ) - - private val broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(p0: Context, p1: Intent) { - debounceJob?.cancel() - debounceJob = launch { - delay(500) - if (!isActive) return@launch - loadExtensions(p0) - } - } - } - - fun loadExtensions(context: Context) { - loadingJob?.cancel() - loadingJob = launch { - isLoadingFlow.emit(true) - - val result = loaders - .map { it.loadExtension(context, this) } - .flatten() - .awaitAll() - - if (!isActive) return@launch - extensionsFlow.emit(result) - isLoadingFlow.emit(false) - } - } - - fun requireExtensionByPackageName(packageName: String): Flow { - return extensionsFlow.mapLatest { list -> - list.firstOrNull { - it is ExtensionLoadResult.Ready && - it.environment is ExtensionEnvironment.Package && - it.environment.packageInfo.packageName == packageName - } - } - } - - fun requireExtensionByClassName(className: String): Flow { - return extensionsFlow.mapLatest { list -> list.firstOrNull { it.extId == className } } - } - - fun requireExtensionByContentKey(contentKey: String): Flow> { - return extensionsFlow.mapLatest { list -> - list.mapNotNull { result -> - (result as? ExtensionLoadResult.Ready) - ?.takeIf { - val content = it.extension.getContentMap()[contentKey] - content != null && content !== EMPTY_CONTENT - } - } - } - } - - fun requireProviderFromExtensions(): List { - return extensionsFlow.value - .filterIsInstance() - .mapNotNull { runCatching { it.extension.getProvider() }.getOrNull() } - } - - fun requireProviderFlowFromExtensions(): Flow> { - return extensionsFlow.mapLatest { list -> - list.filterIsInstance() - .mapNotNull { runCatching { it.extension.getProvider() }.getOrNull() } - } - } - - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - val activity = source as? Activity ?: return - when (event) { - Lifecycle.Event.ON_START -> { - val intentFilter = IntentFilter().apply { - addAction(Intent.ACTION_PACKAGE_ADDED) - addAction(Intent.ACTION_PACKAGE_REMOVED) - addAction(Intent.ACTION_PACKAGE_REPLACED) - addAction(Intent.ACTION_PACKAGE_CHANGED) - addAction(Intent.ACTION_PACKAGE_DATA_CLEARED) - addDataScheme("package") - } - activity.registerReceiver(broadcastReceiver, intentFilter) - } - - Lifecycle.Event.ON_DESTROY -> { - activity.unregisterReceiver(broadcastReceiver) - } - - else -> Unit - } - } -} \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/Provider.kt b/extension-core/src/main/java/com/lalilu/extension_core/Provider.kt deleted file mode 100644 index 6ab87dcdc..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/Provider.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lalilu.extension_core - -import com.lalilu.common.base.Playable -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf - -/** - * 宿主端需要随时能获取到插件端的内容, - * 并且插件端需要能主动更新自己提供的内容,所以使用Flow进行串联 - */ -@OptIn(ExperimentalCoroutinesApi::class) -interface Provider { - - /** - * 用于外部判断是否此Provider是否适用于该传入的ID - */ - fun isSupported(mediaId: String): Boolean - - /** - * 传入Id,获取指定的Playable - */ - fun getById(mediaId: String): Playable? - - /** - * 传入Id,获取指定的Playable - */ - fun getFlowById(mediaId: String): Flow - - /** - * 传入一系列Id,获取List - * - * NOTE: 可重写以简化获取List的逻辑 - */ - fun getFlowByIds(mediaIds: List): Flow> { - val flowList = mediaIds.map { getFlowById(it) } - return flowOf(flowList) - .flatMapLatest { list -> combine(list) { songs -> songs.mapNotNull { it } } } - } -} \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/loader/CacheApkExtensionLoader.kt b/extension-core/src/main/java/com/lalilu/extension_core/loader/CacheApkExtensionLoader.kt deleted file mode 100644 index bfc2cbc0f..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/loader/CacheApkExtensionLoader.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.lalilu.extension_core.loader - -import android.content.Context -import android.content.res.AssetManager -import android.content.res.Resources -import com.lalilu.extension_core.ExtensionClassLoader -import com.lalilu.extension_core.ExtensionEnvironment -import com.lalilu.extension_core.ExtensionLoadResult -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import java.io.File - -/** - * 加载在宿主应用的cache目录下的apk插件 - */ -class CacheApkExtensionLoader : ExtensionLoader { - override suspend fun loadExtension( - context: Context, - scope: CoroutineScope - ): List> { - val cacheDictionary = File(context.cacheDir, "ext_apk") - if (!cacheDictionary.exists()) cacheDictionary.mkdir() - if (!cacheDictionary.isDirectory) return emptyList() - - val childList = cacheDictionary.listFiles() ?: return emptyList() - - return childList.filter { it.extension.uppercase() == "APK" } - .map { file -> - // 创建该Extension专用的ClassLoader - val classLoader = ExtensionClassLoader(file.absolutePath, context.classLoader) - val classes = getExtensionListByReflection(classLoader) - - // 读取该Apk内的resources - val resources = createResource(context, file.absolutePath) - val environment = ExtensionEnvironment.Apk(resources) - - loadExtensionWithClassLoader( - scope, - classes, - classLoader, - environment - ) - }.flatten() - } - - @Suppress("DEPRECATION") - private fun createResource(context: Context, path: String): Resources { - val assetManagerClass = AssetManager::class.java - val assetManager = assetManagerClass.getDeclaredConstructor().newInstance() - val method = assetManagerClass.getMethod("addAssetPath", String::class.java) - - method.isAccessible = true - method.invoke(assetManager, path) - - return Resources( - assetManager, - context.resources.displayMetrics, - context.resources.configuration - ) - } -} \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/loader/ExtensionLoader.kt b/extension-core/src/main/java/com/lalilu/extension_core/loader/ExtensionLoader.kt deleted file mode 100644 index 421d4d7fe..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/loader/ExtensionLoader.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.lalilu.extension_core.loader - -import android.content.Context -import com.lalilu.extension_core.Constants -import com.lalilu.extension_core.Extension -import com.lalilu.extension_core.ExtensionEnvironment -import com.lalilu.extension_core.ExtensionLoadResult -import com.lalilu.extension_core.ExtensionMetadata -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async - -interface ExtensionLoader { - - suspend fun loadExtension( - context: Context, - scope: CoroutineScope - ): List> - - fun getExtensionListByReflection( - classLoader: ClassLoader, - ): List { - return runCatching { - val targetClass = Constants.EXTENSION_SOURCES_CLASS - val clazz = Class.forName(targetClass, false, classLoader) - val method = clazz.getDeclaredMethod("getClasses").apply { isAccessible = true } - val obj = clazz.getDeclaredConstructor().newInstance() - - (method.invoke(obj) as List<*>).mapNotNull { it as? String } - }.getOrElse { - it.printStackTrace() - emptyList() - } - } - - fun loadExtensionWithClassLoader( - scope: CoroutineScope, - classes: List, - classLoader: ClassLoader, - environment: ExtensionEnvironment, - ): List> { - return classes.map { className -> - scope.async { - var errorMessage = "Unknown error" - - // 加载Extension对象 - val extension = runCatching { - val clazz = Class.forName(className, false, classLoader) - - clazz.getDeclaredConstructor().newInstance() as? Extension - }.getOrElse { - println("""[loadExtensionWithClassLoader] Error: ${it.message}""") - it.printStackTrace() - errorMessage = it.message ?: it.localizedMessage ?: "Unknown error" - null - } - // TODO 待完善metadata的获取逻辑 - val extMetadata = ExtensionMetadata( - extId = className, - name = "", - intro = "", - versionName = "", - versionNumber = 0, - ) - - if (extension != null) { - return@async ExtensionLoadResult.Ready( - extId = className, - metadata = extMetadata, - classLoader = classLoader, - extension = extension, - environment = environment - ) - } - - ExtensionLoadResult.Error( - extId = className, - metadata = extMetadata, - message = errorMessage - ) - } - } - } -} - diff --git a/extension-core/src/main/java/com/lalilu/extension_core/loader/HostExtensionLoader.kt b/extension-core/src/main/java/com/lalilu/extension_core/loader/HostExtensionLoader.kt deleted file mode 100644 index 33ea19d55..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/loader/HostExtensionLoader.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.lalilu.extension_core.loader - -import android.content.Context -import com.lalilu.extension_core.Constants -import com.lalilu.extension_core.ExtensionEnvironment -import com.lalilu.extension_core.ExtensionLoadResult -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred - -/** - * 获取宿主App中定义的插件 - */ -class HostExtensionLoader : ExtensionLoader { - override suspend fun loadExtension( - context: Context, - scope: CoroutineScope - ): List> { - val packageManager = context.packageManager - val packageInfo = - packageManager.getPackageInfo(context.packageName, Constants.PACKAGE_FLAGS) - - return runCatching { - val classes = getExtensionListByReflection(context.classLoader) - val environment = ExtensionEnvironment.Package(packageInfo) - - loadExtensionWithClassLoader(scope, classes, context.classLoader, environment) - }.getOrElse { - println("""[loadHostExtensions] Error: ${it.message}""") - it.printStackTrace() - emptyList() - } - } -} \ No newline at end of file diff --git a/extension-core/src/main/java/com/lalilu/extension_core/loader/SharedExtensionLoader.kt b/extension-core/src/main/java/com/lalilu/extension_core/loader/SharedExtensionLoader.kt deleted file mode 100644 index c2f3f4429..000000000 --- a/extension-core/src/main/java/com/lalilu/extension_core/loader/SharedExtensionLoader.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.lalilu.extension_core.loader - -import android.content.Context -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import com.lalilu.extension_core.Constants -import com.lalilu.extension_core.ExtensionClassLoader -import com.lalilu.extension_core.ExtensionEnvironment -import com.lalilu.extension_core.ExtensionLoadResult -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred - -/** - * 加载本机中已安装的其他插件,实际通过包管理器获取 - */ -class SharedExtensionLoader : ExtensionLoader { - override suspend fun loadExtension( - context: Context, - scope: CoroutineScope - ): List> { - val packageManager = context.packageManager - val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(Constants.PACKAGE_FLAGS.toLong())) - } else { - packageManager.getInstalledPackages(Constants.PACKAGE_FLAGS) - } - - val sharedPackageInfo = installedPackages - .asSequence() - .filter { - it.reqFeatures.orEmpty().any { it.name == Constants.EXTENSION_FEATURE_NAME } - }.toList() - - return sharedPackageInfo.map { packageInfo -> - runCatching { - val classLoader = ExtensionClassLoader( - dexPath = packageInfo.applicationInfo.sourceDir, - parent = context.classLoader - ) - val classes = getExtensionListFromMeta(packageInfo).toMutableSet() - classes += getExtensionListByReflection(classLoader) - val environment = ExtensionEnvironment.Package(packageInfo) - - loadExtensionWithClassLoader(scope, classes.toList(), classLoader, environment) - }.getOrElse { - println("""[loadSharedExtensions] Error: ${it.message}""") - it.printStackTrace() - emptyList() - } - }.flatten() - } - - private fun getExtensionListFromMeta( - packageInfo: PackageInfo, - ): List { - val packageName = packageInfo.packageName - val appInfo = packageInfo.applicationInfo - - return appInfo.metaData - .getString(Constants.EXTENSION_META_DATA_CLASS) - ?.trim() - ?.takeIf(String::isNotBlank) - ?.split(";") - ?.map { if (it.startsWith(".")) packageName + it else it } - ?: emptyList() - } -} \ No newline at end of file diff --git a/extension-ksp/.gitignore b/extension-ksp/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/extension-ksp/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/extension-ksp/build.gradle.kts b/extension-ksp/build.gradle.kts deleted file mode 100644 index fef66787d..000000000 --- a/extension-ksp/build.gradle.kts +++ /dev/null @@ -1,15 +0,0 @@ -plugins { - id("java-library") - kotlin("jvm") -} - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -dependencies { - implementation(libs.kotlinpoet) - implementation(libs.kotlinpoet.ksp) - implementation(libs.ksp.symbol.api) -} \ No newline at end of file diff --git a/extension-ksp/src/main/java/com/lalilu/extension_ksp/Ext.kt b/extension-ksp/src/main/java/com/lalilu/extension_ksp/Ext.kt deleted file mode 100644 index 4d7ce8e59..000000000 --- a/extension-ksp/src/main/java/com/lalilu/extension_ksp/Ext.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.lalilu.extension_ksp - -@Target(AnnotationTarget.CLASS) -@Retention(AnnotationRetention.SOURCE) -annotation class Ext \ No newline at end of file diff --git a/extension-ksp/src/main/java/com/lalilu/extension_ksp/ExtProcessor.kt b/extension-ksp/src/main/java/com/lalilu/extension_ksp/ExtProcessor.kt deleted file mode 100644 index 2127a0472..000000000 --- a/extension-ksp/src/main/java/com/lalilu/extension_ksp/ExtProcessor.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.lalilu.extension_ksp - -import com.google.devtools.ksp.processing.CodeGenerator -import com.google.devtools.ksp.processing.KSPLogger -import com.google.devtools.ksp.processing.Resolver -import com.google.devtools.ksp.processing.SymbolProcessor -import com.google.devtools.ksp.symbol.KSAnnotated -import com.google.devtools.ksp.symbol.KSClassDeclaration -import com.google.devtools.ksp.validate -import com.squareup.kotlinpoet.FileSpec -import com.squareup.kotlinpoet.FunSpec -import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy -import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.ksp.toClassName -import com.squareup.kotlinpoet.ksp.writeTo - -class ExtProcessor( - private val codeGenerator: CodeGenerator, - private val logger: KSPLogger -) : SymbolProcessor { - companion object { - private val listType = List::class.parameterizedBy(String::class) - const val GENERATE_PACKAGE_NAME = "lalilu.extension_ksp" - const val GENERATE_FILE_NAME = "ExtensionsConstants" - - // 需手动修改与extension_core的保持一致 - private const val TARGET_ANNOTATION = "com.lalilu.extension_core.Ext" - } - - private val classNames: HashSet = LinkedHashSet() - - override fun process(resolver: Resolver): List { - val symbols = resolver.getSymbolsWithAnnotation(TARGET_ANNOTATION) - .filterIsInstance() - .toList() - - if (symbols.isEmpty()) return emptyList() - - classNames.addAll(symbols.map { it.toClassName().toString() }) - - // 筛选返回不可解析的symbols - return symbols.filter { !it.validate() }.toList() - } - - override fun finish() { - super.finish() - val packageName = GENERATE_PACKAGE_NAME - val fileName = GENERATE_FILE_NAME - val listValue = "listOf(${classNames.joinToString(",") { "\"$it\"" }})" - - val function = FunSpec.builder("getClasses") - .addKdoc("Get all extensions' className from this library") - .addCode("return $listValue") - .returns(listType) - .build() - - val classType = TypeSpec.classBuilder(fileName) - .addFunction(function) - .build() - - FileSpec.builder(packageName, fileName) - .addType(classType) - .build() - .writeTo(codeGenerator, true) - } -} \ No newline at end of file diff --git a/extension-ksp/src/main/java/com/lalilu/extension_ksp/ExtProcessorProvider.kt b/extension-ksp/src/main/java/com/lalilu/extension_ksp/ExtProcessorProvider.kt deleted file mode 100644 index 68d442f3b..000000000 --- a/extension-ksp/src/main/java/com/lalilu/extension_ksp/ExtProcessorProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.lalilu.extension_ksp - -import com.google.devtools.ksp.processing.SymbolProcessor -import com.google.devtools.ksp.processing.SymbolProcessorEnvironment -import com.google.devtools.ksp.processing.SymbolProcessorProvider - -class ExtProcessorProvider : SymbolProcessorProvider { - override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { - return ExtProcessor( - codeGenerator = environment.codeGenerator, - logger = environment.logger - ) - } -} \ No newline at end of file diff --git a/extension-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/extension-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider deleted file mode 100644 index afd548547..000000000 --- a/extension-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider +++ /dev/null @@ -1 +0,0 @@ -com.lalilu.extension_ksp.ExtProcessorProvider \ No newline at end of file diff --git a/extension/.gitignore b/extension/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/extension/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/extension/build.gradle.kts b/extension/build.gradle.kts deleted file mode 100644 index 4f49204ce..000000000 --- a/extension/build.gradle.kts +++ /dev/null @@ -1,74 +0,0 @@ -import java.io.FileInputStream -import java.util.Properties - -plugins { - id("com.android.application") - kotlin("android") - id("com.google.devtools.ksp") -} - -val keystoreProps = rootProject.file("keystore.properties") - .takeIf { it.exists() } - ?.let { Properties().apply { load(FileInputStream(it)) } } - -android { - namespace = "com.lalilu.extension" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION - - buildFeatures { - compose = true - buildConfig = true - } - - defaultConfig { - applicationId = "com.lalilu.extension" - minSdk = AndroidConfig.MIN_SDK_VERSION - targetSdk = AndroidConfig.TARGET_SDK_VERSION - versionCode = 1 - versionName = "1.0" - } - - if (keystoreProps != null) { - val storeFileValue = keystoreProps["storeFile"]?.toString() ?: "" - val storePasswordValue = keystoreProps["storePassword"]?.toString() ?: "" - val keyAliasValue = keystoreProps["keyAlias"]?.toString() ?: "" - val keyPasswordValue = keystoreProps["keyPassword"]?.toString() ?: "" - - if (storeFileValue.isNotBlank() && file(storeFileValue).exists()) { - signingConfigs.create("release") { - storeFile(file(storeFileValue)) - storePassword(storePasswordValue) - keyAlias(keyAliasValue) - keyPassword(keyPasswordValue) - } - } - } - - buildTypes { - release { - isMinifyEnabled = true - signingConfig = kotlin.runCatching { signingConfigs["release"] }.getOrNull() - - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } -} - -dependencies { - compileOnly(libs.kotlin.stdlib) - compileOnly(project(":extension-core")) - ksp(project(":extension-ksp")) -} \ No newline at end of file diff --git a/extension/proguard-rules.pro b/extension/proguard-rules.pro deleted file mode 100644 index 826db87e9..000000000 --- a/extension/proguard-rules.pro +++ /dev/null @@ -1,44 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - --dontwarn org.bouncycastle.** --dontwarn org.conscrypt.** --dontwarn org.openjsse.** - -# 基础依赖遵循尽可能减少、并且不进行混淆的原则 --keep,allowoptimization class kotlin.** { public protected *; } --keep,allowoptimization class kotlinx.coroutines.** { public protected *; } --keep,allowoptimization class androidx.lifecycle.** { public protected *; } --keep,allowoptimization class androidx.compose.** { public protected *; } --keep,allowoptimization class coil.compose.** { public protected *; } --keep,allowoptimization class com.lalilu.extension_core.** { public protected *; } --keep,allowoptimization class com.lalilu.common.** { public protected *; } --keep,allowoptimization class com.lalilu.lplayer.** { public protected *; } --keep,allowoptimization class android.support.v4.media.** { public protected *; } - --keepclassmembers class * implements com.lalilu.extension_core.Extension { - (...); - com.lalilu.extension_core.Extension *; -} --keep class lalilu.extension_ksp.ExtensionsConstants { *;} - --printmapping mapping.txt \ No newline at end of file diff --git a/extension/src/main/AndroidManifest.xml b/extension/src/main/AndroidManifest.xml deleted file mode 100644 index 751ce0dde..000000000 --- a/extension/src/main/AndroidManifest.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/extension/src/main/java/com/lalilu/extension/Constants.kt b/extension/src/main/java/com/lalilu/extension/Constants.kt deleted file mode 100644 index 0d32d43ad..000000000 --- a/extension/src/main/java/com/lalilu/extension/Constants.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.lalilu.extension - -object Constants { - val lyric1 = """ - 稻香 (Demo) - 周杰伦 - 作词 : 周杰伦 - 作曲:周杰伦 - 对这个世界如果你有太多的抱怨 - 跌倒了就不敢继续往前走 - 为什么人要这么的脆弱 堕落 - 请你打开电视看看 - 多少人为生命在努力勇敢的走下去 - 我们是不是该知足 - 珍惜一切 就算没有拥有 - 还记得你说家是唯一的城堡 - 随着稻香河流继续奔跑 - 微微笑 小时候的梦我知道 - 不要哭让萤火虫带着你逃跑 - 乡间的歌谣永远的依靠 - 回家吧 回到最初的美好 - """.trimIndent() - - val lyric2 = """ - 乘着风 游荡在蓝天边 - 一片云掉落在我面前 - 捏成你的形状 随风跟着我 - 一口一口 吃掉忧愁 - 载着你 仿佛载着阳光 - 不管到哪里 都是晴天 - 蝴蝶自在飞 花也布满天 - 一朵一朵 因你而香 - 试图让夕阳飞翔 带领你我环绕大自然 - 迎着风 开始共渡每一天 - 手牵手 一步两步三步四步 望着天 - 看星星 一颗两颗三颗四颗 连成线 - 背对背默默许下心愿 看远方的星 是否听得见 - 手牵手 一步两步三步四步 望着天 - 看星星 一颗两颗三颗四颗 连成线 - 背对背默默许下心愿 看远方的星 如果听得见 - 它一定实现 - """.trimIndent() - - val lyric3 = """ - 轻轻的我走了, - 正如我轻轻的来; - 我轻轻的招手, - 作别西天的云彩。 - - 那河畔的金柳, - 是夕阳中的新娘; - 波光里的艳影, - 在我的心头荡漾。 - - 软泥上的青荇, - 油油的在水底招摇; - 在康河的柔波里, - 我甘心做一条水草! - - 那榆荫下的一潭, - 不是清泉, - 是天上虹; - 揉碎在浮藻间, - 沉淀着彩虹似的梦。 - - 寻梦? - 撑一支长篙, - 向青草更青处漫溯; - 满载一船星辉, - 在星辉斑斓里放歌。 - - 但我不能放歌, - 悄悄是别离的笙箫; - 夏虫也为我沉默, - 沉默是今晚的康桥! - - 悄悄的我走了, - 正如我悄悄的来; - 我挥一挥衣袖, - 不带走一片云彩。 - """.trimIndent() -} \ No newline at end of file diff --git a/extension/src/main/java/com/lalilu/extension/Main.kt b/extension/src/main/java/com/lalilu/extension/Main.kt deleted file mode 100644 index f67785937..000000000 --- a/extension/src/main/java/com/lalilu/extension/Main.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.lalilu.extension - -import android.net.Uri -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.lalilu.common.base.Playable -import com.lalilu.extension_core.Content -import com.lalilu.extension_core.Ext -import com.lalilu.extension_core.Extension -import com.lalilu.extension_core.Provider -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.mapLatest - -@OptIn(ExperimentalCoroutinesApi::class) -@Ext -class Main : Extension, Provider { - private val baseUrl = "https://frp-gas.top:55244/voice/bert-vits2" - private val baseParams = mapOf( - "id" to "0", - "format" to "wav", - "length" to "1.2", - "noisew" to "0.9" - ) - - private fun getUrlWithText(text: String): String { - val list = baseParams.toList() + ("text" to text) - return "$baseUrl?${list.joinToString(separator = "&") { "${it.first}=${it.second}" }}" - } - - private val sentences = MutableStateFlow( - listOf( - VitsSentence( - mediaId = "vits_1", - title = "稻香", - subTitle = "周杰伦", - imageSource = "https://api.sretna.cn/layout/pc.php", - targetUri = Uri.parse(getUrlWithText(Constants.lyric1)) - ), - VitsSentence( - mediaId = "vits_2", - title = "星晴", - subTitle = "周杰伦", - targetUri = Uri.parse(getUrlWithText(Constants.lyric2)) - ), - VitsSentence( - mediaId = "vits_3", - title = "再别康桥", - subTitle = "徐志摩", - targetUri = Uri.parse(getUrlWithText(Constants.lyric3)) - ) - ) - ) - - override fun getContentMap(): Map) -> Unit> = - mapOf( - Content.COMPONENT_HOME to { bannerContent() }, - Content.COMPONENT_CATEGORY to { bannerContent() }, - Content.COMPONENT_MAIN to { MainScreen(sentences) }, - ) - - override fun getProvider(): Provider = this - - override fun isSupported(mediaId: String): Boolean { - return mediaId.startsWith("vits_") - } - - override fun getById(mediaId: String): Playable? { - return sentences.value.firstOrNull { it.mediaId == mediaId } - } - - override fun getFlowById(mediaId: String): Flow { - return sentences.mapLatest { list -> list.firstOrNull { it.mediaId == mediaId } } - } - - private val bannerContent: @Composable () -> Unit = { - val imageApi = - remember { mutableStateOf("https://api.sretna.cn/layout/pc.php?seed=${System.currentTimeMillis() / 30000}") } - val showBar = remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .animateContentSize() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - ) { - AsyncImage( - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - model = imageApi.value, - contentDescription = "" - ) - IconButton( - modifier = Modifier.align(Alignment.BottomEnd), - onClick = { showBar.value = !showBar.value } - ) { - Icon(imageVector = Icons.Default.ArrowDropDown, "") - } - } - - AnimatedVisibility(visible = showBar.value) { - Row( - modifier = Modifier - .background(MaterialTheme.colors.surface) - .fillMaxWidth() - .padding(15.dp), - horizontalArrangement = Arrangement.spacedBy(15.dp) - ) { - IconButton(onClick = { }) { - Text(text = "#${BuildConfig.VERSION_NAME}") - } - IconButton( - onClick = { - imageApi.value = - "https://api.sretna.cn/layout/pc.php?seed=${System.currentTimeMillis()}" - } - ) { - Text(text = "CHANGE") - } - } - } - } - } -} \ No newline at end of file diff --git a/extension/src/main/java/com/lalilu/extension/MainScreen.kt b/extension/src/main/java/com/lalilu/extension/MainScreen.kt deleted file mode 100644 index c3f10da1f..000000000 --- a/extension/src/main/java/com/lalilu/extension/MainScreen.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.lalilu.extension - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.lalilu.lplayer.LPlayer -import com.lalilu.lplayer.extensions.PlayerAction -import kotlinx.coroutines.flow.Flow - -@Composable -fun MainScreen( - sentences: Flow>, -) { - val imageApi = - remember { "https://api.sretna.cn/layout/pc.php?seed=${System.currentTimeMillis() / 30000}" } - val sentence by sentences.collectAsState(emptyList()) - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 100.dp, horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - item { - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f), - contentScale = ContentScale.Crop, - model = imageApi, - contentDescription = "" - ) - } - items(items = sentence) { - Surface { - Column { - Text(text = it.title, style = MaterialTheme.typography.subtitle1) - Text(text = it.subTitle, style = MaterialTheme.typography.subtitle2) - TextButton(onClick = { - LPlayer.runtime.queue.setCurrentId(it.mediaId) - LPlayer.runtime.queue.setIds(sentence.map { it.mediaId }) - PlayerAction.PlayById(it.mediaId).action() - }) { - Text(text = "播放") - } - } - } - } - } -} \ No newline at end of file diff --git a/extension/src/main/java/com/lalilu/extension/VitsSentence.kt b/extension/src/main/java/com/lalilu/extension/VitsSentence.kt deleted file mode 100644 index 32ef8a715..000000000 --- a/extension/src/main/java/com/lalilu/extension/VitsSentence.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.lalilu.extension - -import android.net.Uri -import android.support.v4.media.MediaMetadataCompat -import com.lalilu.common.base.Playable -import com.lalilu.common.base.Sticker - -data class VitsSentence( - override val mediaId: String, - override val title: String, - override val subTitle: String, - override val durationMs: Long = -1L, - override val targetUri: Uri, - override val imageSource: Any? = null, -) : Playable { - override val sticker: List = emptyList() - - override val metaDataCompat: MediaMetadataCompat = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subTitle) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "unknown") - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId) - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, targetUri.toString()) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs) - .build() -} \ No newline at end of file diff --git a/extension/src/main/res/mipmap-hdpi/ic_launcher.webp b/extension/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78ecd372343283f4157dcfd918ec5165bb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG diff --git a/extension/src/main/res/mipmap-mdpi/ic_launcher.webp b/extension/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64e58ba64d180ce43ee13bf9a17835fbca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!To6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? diff --git a/extension/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/extension/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f9f036a47549d47db79c16788749dca10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2884 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 - - Test - \ No newline at end of file diff --git a/lextension/.gitignore b/lextension/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/lextension/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/lextension/build.gradle.kts b/lextension/build.gradle.kts deleted file mode 100644 index 0ff4139da..000000000 --- a/lextension/build.gradle.kts +++ /dev/null @@ -1,36 +0,0 @@ -plugins { - id("com.android.library") - kotlin("android") -} - -android { - namespace = "com.lalilu.lextension" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION - - buildFeatures { - compose = true - } - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - } - buildTypes { - release { - consumerProguardFiles("proguard-rules.pro") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } -} - -dependencies { - implementation(project(":component")) - implementation(project(":extension-core")) -} \ No newline at end of file diff --git a/lextension/consumer-rules.pro b/lextension/consumer-rules.pro deleted file mode 100644 index e69de29bb..000000000 diff --git a/lextension/proguard-rules.pro b/lextension/proguard-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/lextension/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/lextension/src/main/AndroidManifest.xml b/lextension/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68a..000000000 --- a/lextension/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/lextension/src/main/java/com/lalilu/lextension/ExtensionModule.kt b/lextension/src/main/java/com/lalilu/lextension/ExtensionModule.kt deleted file mode 100644 index 37641ddbd..000000000 --- a/lextension/src/main/java/com/lalilu/lextension/ExtensionModule.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.lalilu.lextension - -import com.lalilu.lextension.repository.ExtensionSp -import com.lalilu.lextension.component.ExtensionsScreenModel -import org.koin.android.ext.koin.androidApplication -import org.koin.core.module.dsl.factoryOf -import org.koin.dsl.module - -val ExtensionModule = module { - single { ExtensionSp(androidApplication()) } - - factoryOf(::ExtensionsScreenModel) -} \ No newline at end of file diff --git a/lextension/src/main/java/com/lalilu/lextension/component/ExtensionCard.kt b/lextension/src/main/java/com/lalilu/lextension/component/ExtensionCard.kt deleted file mode 100644 index a2c631045..000000000 --- a/lextension/src/main/java/com/lalilu/lextension/component/ExtensionCard.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.lalilu.lextension.component - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import com.lalilu.component.R as componentR - - -@Composable -fun ExtensionCard( - modifier: Modifier = Modifier, - draggableModifier: Modifier = Modifier, - maskAlpha: Float = 0.5f, - onUpClick: () -> Unit = {}, - onDownClick: () -> Unit = {}, - onVisibleChange: (Boolean) -> Unit = {}, - isVisible: () -> Boolean = { true }, - isEditing: () -> Boolean = { false }, - isDragging: () -> Boolean = { false }, - content: @Composable BoxScope.() -> Unit = {}, -) { - val visible = isVisible() - val density = LocalDensity.current - val heightDp = remember { mutableStateOf(0.dp) } - val elevation = animateDpAsState( - targetValue = if (isDragging()) 4.dp else 0.dp, - label = "" - ) - - AnimatedVisibility( - visible = isEditing() || visible, - enter = fadeIn(), - exit = fadeOut() - ) { - Surface( - elevation = elevation.value, - color = MaterialTheme.colors.background - ) { - Box( - modifier = modifier - .fillMaxWidth() - .onSizeChanged { heightDp.value = density.run { it.height.toDp() } } - .run { if (isEditing()) this.heightIn(100.dp) else this } - .wrapContentHeight(), - contentAlignment = Alignment.Center - ) { - content() - - AnimatedVisibility( - visible = isEditing(), - enter = fadeIn(), - exit = fadeOut() - ) { - Box( - modifier = Modifier - .background(color = MaterialTheme.colors.surface.copy(alpha = maskAlpha)) - .clickable(MutableInteractionSource(), indication = null) { } - .fillMaxWidth() - .height(heightDp.value), - contentAlignment = Alignment.Center - ) { - Row( - modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End) - ) { - IconButton(onClick = { onVisibleChange(!visible) }) { - AnimatedContent( - targetState = visible, - label = "" - ) { - val icon = if (it) { - painterResource(id = componentR.drawable.ic_eye_off_fill) - } else { - painterResource(id = componentR.drawable.ic_edit_line) - } - - Icon( - modifier = Modifier.size(36.dp), - painter = icon, - contentDescription = "" - ) - } - } - IconButton(onClick = onUpClick) { - Icon( - modifier = Modifier.size(36.dp), - painter = painterResource(id = componentR.drawable.ic_arrow_up_s_line), - contentDescription = "" - ) - } - IconButton(onClick = onDownClick) { - Icon( - modifier = Modifier.size(36.dp), - painter = painterResource(id = componentR.drawable.ic_arrow_down_s_line), - contentDescription = "" - ) - } - Icon( - modifier = draggableModifier.size(36.dp), - painter = painterResource(id = componentR.drawable.ic_draggable), - contentDescription = "" - ) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/lextension/src/main/java/com/lalilu/lextension/component/ExtensionList.kt b/lextension/src/main/java/com/lalilu/lextension/component/ExtensionList.kt deleted file mode 100644 index 4c1e7b6e8..000000000 --- a/lextension/src/main/java/com/lalilu/lextension/component/ExtensionList.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.lalilu.lextension.component - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.LoadingScaffold -import com.lalilu.component.base.collectAsLoadingState -import com.lalilu.component.navigation.BackHandler -import com.lalilu.extension_core.Content -import com.lalilu.extension_core.ExtensionLoadResult -import com.lalilu.extension_core.ExtensionManager -import com.lalilu.extension_core.Place -import com.lalilu.lextension.repository.ExtensionSp -import kotlinx.coroutines.flow.combine -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyColumnState - - -class ExtensionsScreenModel(val extensionSp: ExtensionSp) : ScreenModel { - val isEditing = mutableStateOf(false) - - val extensions = extensionSp.orderList.flow(true) - .combine(ExtensionManager.extensionsFlow) { orderList, extensions -> - orderList?.mapNotNull { order -> extensions.firstOrNull { it.extId == order } } - ?: emptyList() - } - - fun requireExtensions() { - extensionSp.orderList.value = ExtensionManager.extensionsFlow.value.map { it.extId } - extensionSp.orderList.save() - } - - fun onMove(from: LazyListItemInfo, to: LazyListItemInfo) { - extensionSp.orderList.value = extensionSp.orderList.value.toMutableList().apply { - val toIndex = indexOfFirst { it == to.key } - val fromIndex = indexOfFirst { it == from.key } - if (toIndex < 0 || fromIndex < 0) return - - add(toIndex, removeAt(fromIndex)) - } - extensionSp.orderList.save() - } - - /** - * 将某插件的顺序前移 - * - * [extId] 插件ID - */ - fun onOrderUp(extId: String) { - extensionSp.orderList.value = extensionSp.orderList.value.toMutableList().apply { - val itemIndex = indexOfFirst { it == extId } - if (itemIndex < 0) return - - val targetIndex = itemIndex - 1 - if (targetIndex < 0) return - - add(targetIndex, removeAt(itemIndex)) - } - extensionSp.orderList.save() - } - - /** - * 将某插件的顺序后移 - * - * [extId] 插件ID - */ - fun onOrderDown(extId: String) { - extensionSp.orderList.value = extensionSp.orderList.value.toMutableList().apply { - val itemIndex = indexOfFirst { it == extId } - if (itemIndex < 0) return - - val targetIndex = itemIndex + 1 - if (targetIndex < 0 || targetIndex >= this.size) return - - add(targetIndex, removeAt(itemIndex)) - } - extensionSp.orderList.save() - } - - fun isVisible(extId: String): Boolean { - return !extensionSp.hidingList.value.contains(extId) - } - - fun onVisibleChange(extId: String, visible: Boolean) { - extensionSp.hidingList.value = extensionSp.hidingList.value.toMutableList().apply { - if (visible) { - removeAll { it == extId } - return@apply - } - - if (!contains(extId)) { - add(extId) - } - } - extensionSp.hidingList.save() - } -} - - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun Screen.ExtensionList( - extensionsSM: ExtensionsScreenModel = getScreenModel(), - headerContent: LazyListScope.(List) -> Unit = {}, - footerContent: LazyListScope.(List) -> Unit = {}, -) { - val listState = rememberLazyListState() - val orderList = extensionsSM.extensionSp.orderList - - val extensionsState = extensionsSM.extensions.collectAsLoadingState() - val reorderableState = rememberReorderableLazyColumnState( - lazyListState = listState, - onMove = extensionsSM::onMove - ) - - LoadingScaffold( - modifier = Modifier.fillMaxSize(), - targetState = extensionsState - ) { extensions -> - LaunchedEffect(Unit) { - if (orderList.value.isEmpty()) { - extensionsSM.requireExtensions() - } - } - - LLazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - headerContent(extensions) - - items( - items = extensions, - key = { it.extId }, - contentType = { ExtensionLoadResult::class.java } - ) { extension -> - ReorderableItem( - reorderableLazyListState = reorderableState, - key = extension.extId - ) { isDragging -> - ExtensionCard( - onVisibleChange = { extensionsSM.onVisibleChange(extension.extId, it) }, - onUpClick = { extensionsSM.onOrderUp(extension.extId) }, - onDownClick = { extensionsSM.onOrderDown(extension.extId) }, - isVisible = { extensionsSM.isVisible(extension.extId) }, - isEditing = { extensionsSM.isEditing.value }, - isDragging = { isDragging }, - draggableModifier = Modifier.draggableHandle() - ) { - extension.Place(contentKey = Content.COMPONENT_HOME) - } - } - } - - footerContent(extensions) - } - - if (extensionsSM.isEditing.value) { - BackHandler { - extensionsSM.isEditing.value = false - } - } - } -} \ No newline at end of file diff --git a/lextension/src/main/java/com/lalilu/lextension/repository/ExtensionSp.kt b/lextension/src/main/java/com/lalilu/lextension/repository/ExtensionSp.kt deleted file mode 100644 index ef364d6b8..000000000 --- a/lextension/src/main/java/com/lalilu/lextension/repository/ExtensionSp.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.lalilu.lextension.repository - -import android.app.Application -import android.content.SharedPreferences -import com.lalilu.common.base.BaseSp - -class ExtensionSp(private val context: Application) : BaseSp() { - override fun obtainSourceSp(): SharedPreferences { - return context.getSharedPreferences("EXTENSIONS", Application.MODE_PRIVATE) - } - - val orderList = obtainList("ORDER_LIST") - val hidingList = obtainList("HIDING_LIST") -} \ No newline at end of file diff --git a/lextension/src/main/java/com/lalilu/lextension/screen/ExtensionsScreen.kt b/lextension/src/main/java/com/lalilu/lextension/screen/ExtensionsScreen.kt deleted file mode 100644 index 4f3575c74..000000000 --- a/lextension/src/main/java/com/lalilu/lextension/screen/ExtensionsScreen.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.lalilu.lextension.screen - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenInfo -import com.lalilu.lextension.R -import com.lalilu.lextension.component.ExtensionList -import com.lalilu.lextension.component.ExtensionsScreenModel -import com.lalilu.component.R as ComponentR - -object ExtensionsScreen : DynamicScreen() { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.extension_screen_title, - icon = ComponentR.drawable.ic_shapes_line - ) - - @Composable - override fun Content() { - val extensionsSM = getScreenModel() - - ExtensionsScreen(extensionsSM = extensionsSM) - } -} - -@Composable -private fun DynamicScreen.ExtensionsScreen( - extensionsSM: ExtensionsScreenModel -) { - ExtensionList( - extensionsSM = extensionsSM, - headerContent = { extension -> - item { - NavigatorHeader( - modifier = Modifier.statusBarsPadding(), - title = stringResource(id = R.string.extension_screen_title), - subTitle = "共 ${extension.size} 个扩展" - ) - } - }, - footerContent = { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .height(36.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text( - modifier = Modifier.clickable { - extensionsSM.isEditing.value = !extensionsSM.isEditing.value - }, - text = if (extensionsSM.isEditing.value) "Save" else "Edit", - color = MaterialTheme.colors.primary - ) - } - } - } - ) -} \ No newline at end of file diff --git a/lextension/src/main/res/values-zh-rCN/strings.xml b/lextension/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 0fb02a358..000000000 --- a/lextension/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - 插件 - 插件详情 - \ No newline at end of file diff --git a/lextension/src/main/res/values/strings.xml b/lextension/src/main/res/values/strings.xml deleted file mode 100644 index ce6e1bf96..000000000 --- a/lextension/src/main/res/values/strings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - Extensions - Extension Detail - \ No newline at end of file diff --git a/register/.gitignore b/register/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/register/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/register/build.gradle.kts b/register/build.gradle.kts deleted file mode 100644 index 58f1d06b9..000000000 --- a/register/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -plugins { - `kotlin-dsl` - `java-gradle-plugin` -} - -repositories { - google() - gradlePluginPortal() - mavenCentral() -} - -dependencies { - implementation("org.ow2.asm:asm-util:9.2") - implementation("org.ow2.asm:asm-commons:9.2") - implementation("com.android.tools.build:gradle-api:8.2.0-rc02") -} - -gradlePlugin { - plugins.register("RegisterPlugin") { - id = "com.lalilu.register" - implementationClass = "com.lalilu.register.RegisterPlugin" - } -} \ No newline at end of file diff --git a/register/settings.gradle.kts b/register/settings.gradle.kts deleted file mode 100644 index e69de29bb..000000000 diff --git a/register/src/main/java/com/lalilu/register/ClassInfo.kt b/register/src/main/java/com/lalilu/register/ClassInfo.kt deleted file mode 100644 index 2e23b245f..000000000 --- a/register/src/main/java/com/lalilu/register/ClassInfo.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.lalilu.register - -class ClassInfo( - val className: String, - var isObject: Boolean = false, - var isAbleToCreate: Boolean = false, - var isInterface: Boolean = false, - var isAbstract: Boolean = false -) \ No newline at end of file diff --git a/register/src/main/java/com/lalilu/register/InjectClassVisitor.kt b/register/src/main/java/com/lalilu/register/InjectClassVisitor.kt deleted file mode 100644 index 7bd5b1d97..000000000 --- a/register/src/main/java/com/lalilu/register/InjectClassVisitor.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.lalilu.register - -import org.objectweb.asm.ClassVisitor -import org.objectweb.asm.MethodVisitor -import org.objectweb.asm.Opcodes - - -class InjectClassVisitor(nextVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM9, nextVisitor) { - private lateinit var info: RegisterInfo - - override fun visit( - version: Int, - access: Int, - name: String?, - signature: String?, - superName: String?, - interfaces: Array? - ) { - val className = name?.replace('/', '.') - RegisterConfig.registerInfo[className]?.let { - info = it - println( - """ - ------ $name ------- - [targetManagerClass: ${info.targetManagerClass}] - [baseInterface: ${info.baseInterface}] - [registerMethod: ${info.registerMethod}] - [registerMethodClass: ${info.registerMethodClass}] - [classSetSize: ${info.classSet.size}] - """.trimIndent() - ) - } - super.visit(version, access, name, signature, superName, interfaces) - } - - override fun visitMethod( - access: Int, - name: String?, - descriptor: String?, - signature: String?, - exceptions: Array? - ): MethodVisitor { - var mv = super.visitMethod(access, name, descriptor, signature, exceptions) - if (name == "" && ::info.isInitialized) { - println("visitMethod: $access $name $descriptor $signature") - mv = InjectMethodVisitor(access, name, descriptor, mv, info) - } - return mv - } -} diff --git a/register/src/main/java/com/lalilu/register/InjectMethodVisitor.kt b/register/src/main/java/com/lalilu/register/InjectMethodVisitor.kt deleted file mode 100644 index c4cca0656..000000000 --- a/register/src/main/java/com/lalilu/register/InjectMethodVisitor.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.lalilu.register - -import org.objectweb.asm.MethodVisitor -import org.objectweb.asm.Opcodes -import org.objectweb.asm.commons.AdviceAdapter - -class InjectMethodVisitor( - access: Int, - name: String?, - descriptor: String?, - methodVisitor: MethodVisitor, - private val info: RegisterInfo -) : AdviceAdapter(Opcodes.ASM9, methodVisitor, access, name, descriptor) { - - override fun onMethodExit(opcode: Int) { - val managerAsmClassName = info.targetManagerClass.replace('.', '/') - - for (classInfo in info.classSet) { - // 跳过无法注入的类 - if (!classInfo.isObject && !classInfo.isAbleToCreate) continue - if (classInfo.isInterface || classInfo.isAbstract) continue - - val itemAsmClassName = classInfo.className.replace('.', '/') - - // TODO 需要适配非object的Manager对象 - // 首先需要获取到对象自身 - mv.visitFieldInsn( - Opcodes.GETSTATIC, - managerAsmClassName, - "INSTANCE", - "L$managerAsmClassName;" - ) - - mv.visitLdcInsn(classInfo.className) //类名 - - when { - // 若为单例对象,则直接获取单例 - classInfo.isObject -> { - mv.visitFieldInsn( - Opcodes.GETSTATIC, - itemAsmClassName, - "INSTANCE", - "L${itemAsmClassName};" - ) - } - - // 若拥有无参构造函数,则实例化以后调用其构造函数 - classInfo.isAbleToCreate -> { - mv.visitTypeInsn(Opcodes.NEW, itemAsmClassName) - mv.visitInsn(Opcodes.DUP) - mv.visitMethodInsn( - Opcodes.INVOKESPECIAL, - itemAsmClassName, - "", - "()V", - false - ) - } - } - - // 进行组件的注入,实际所需的操作栈参数为3个,第一个为调用的对象,其他为函数声明的参数 - // .(String,) - mv.visitMethodInsn( - Opcodes.INVOKEVIRTUAL, - info.registerMethodClass.replace('.', '/'), - info.registerMethod, - "(Ljava/lang/String;Ljava/lang/Object;)V", - false - ) - } - } -} \ No newline at end of file diff --git a/register/src/main/java/com/lalilu/register/InjectTransformTask.kt b/register/src/main/java/com/lalilu/register/InjectTransformTask.kt deleted file mode 100644 index c63ef4910..000000000 --- a/register/src/main/java/com/lalilu/register/InjectTransformTask.kt +++ /dev/null @@ -1,247 +0,0 @@ -package com.lalilu.register - -import org.gradle.api.DefaultTask -import org.gradle.api.file.Directory -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFile -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.TaskAction -import org.gradle.workers.WorkAction -import org.gradle.workers.WorkParameters -import org.gradle.workers.WorkerExecutor -import org.objectweb.asm.ClassReader -import org.objectweb.asm.ClassWriter -import java.io.File -import java.io.InputStream -import java.io.OutputStream -import java.security.MessageDigest -import java.util.Locale -import java.util.jar.JarEntry -import java.util.jar.JarInputStream -import java.util.jar.JarOutputStream -import java.util.zip.Deflater -import javax.inject.Inject - -/** - * 复制修改自 b7woreo/TraceX 的仓库 - * https://github.com/b7woreo/TraceX/blob/main/gradle-plugin/src/main/java/tracex/TraceClassVisitor.kt - */ -abstract class InjectTransformTask : DefaultTask() { - - @get:InputFiles - abstract val allJars: ListProperty - - @get:InputFiles - abstract val allDirectories: ListProperty - - @get:OutputDirectory - abstract val intermediate: DirectoryProperty - - @get:OutputFile - abstract val outputJar: RegularFileProperty - - @get:Inject - abstract val workerExecutor: WorkerExecutor - - @TaskAction - fun transform() { - val workQueue = workerExecutor.noIsolation() - - val intermediateFile = intermediate.get().asFile - // 删除所有中间产物 - intermediateFile.deleteRecursively() - - allJars.get().forEach { jar -> - workQueue.submit(TransformJar::class.java) { - rootDir.set(project.rootDir) - source.set(jar.asFile) - normalizedPath.set(jar.asFile.normalize().path) - intermediate.set(intermediateFile) - } - } - - allDirectories.get().forEach { directory -> - directory.asFile.allFiles { classFile -> - workQueue.submit(TransformClass::class.java) { - rootDir.set(project.rootDir) - source.set(classFile) - normalizedPath.set(classFile.toRelativeString(directory.asFile)) - intermediate.set(intermediateFile) - } - } - } - - workQueue.await() - - mergeClasses( - intermediateFile, - outputJar.get().asFile, - ) - } - - private fun mergeClasses( - intermediate: File, - outputJar: File, - ) { - JarOutputStream( - outputJar.outputStream() - .buffered() - ).use { jar -> - jar.setLevel(Deflater.NO_COMPRESSION) - - intermediate.listFiles()?.forEach { rootDir -> - rootDir.allFiles { child -> - val name = child.toRelativeString(rootDir) - val entry = JarEntry(name) - jar.putNextEntry(entry) - child.inputStream().use { input -> input.transferTo(jar) } - jar.closeEntry() - } - } - } - } - - abstract class Transform : WorkAction { - - protected val rootDir: File - get() = parameters.rootDir.get().asFile - - protected val source: File - get() = parameters.source.get().asFile - - protected val normalizedPath: String - get() = parameters.normalizedPath.get() - - protected val intermediate: File - get() = parameters.intermediate.get().asFile - - protected abstract val destination: File - - protected abstract fun transform() - - final override fun execute() { - destination.deleteRecursively() - transform() - } - - protected fun includeFileInTransform(relativePath: String): Boolean { - val lowerCase = relativePath.lowercase(Locale.ROOT) - if (!lowerCase.endsWith(".class")) { - return false - } - - if (lowerCase == "module-info.class" || - lowerCase.endsWith("/module-info.class") - ) { - return false - } - - if (lowerCase.startsWith("/meta-info/") || - lowerCase.startsWith("meta-info/") - ) { - return false - } - return true - } - - protected fun transform( - input: InputStream, - output: OutputStream, - ) { - val cr = ClassReader(input) - val cw = ClassWriter(ClassWriter.COMPUTE_MAXS) - cr.accept(InjectClassVisitor(cw), ClassReader.EXPAND_FRAMES) - output.write(cw.toByteArray()) - } - - interface Parameters : WorkParameters { - val rootDir: DirectoryProperty - val source: RegularFileProperty - val normalizedPath: Property - val intermediate: DirectoryProperty - } - } - - abstract class TransformJar : Transform() { - - override val destination: File - get() = File(intermediate, source.identify()) - - override fun transform() { - JarInputStream( - source.inputStream().buffered() - ).use { input -> - while (true) { - val entry = input.nextEntry ?: break - if (!includeFileInTransform(entry.name)) continue - val outputFile = File(destination, entry.name) - .also { - it.parentFile.mkdirs() - it.createNewFile() - } - - outputFile.outputStream() - .buffered() - .use { output -> - transform(input, output) - } - } - } - } - - private fun File.identify(): String { - var current: File? = this - while (current != null) { - if (rootDir == current) { - return toRelativeString(rootDir).toSha256() - } - current = current.parentFile - } - return name.toSha256() - } - - private fun String.toSha256(): String { - val md = MessageDigest.getInstance("SHA-256") - val bytes = md.digest(this.toByteArray()) - return bytes.joinToString("") { "%02x".format(it) } - } - } - - abstract class TransformClass : Transform() { - - override val destination: File - get() = File(intermediate.resolve("classes"), normalizedPath) - - override fun transform() { - if (!includeFileInTransform(normalizedPath)) return - destination.parentFile.mkdirs() - destination.createNewFile() - - source.inputStream() - .buffered() - .use { input -> - destination.outputStream() - .buffered() - .use { output -> - transform(input, output) - } - } - } - } - - private fun File.allFiles(block: (File) -> Unit) { - if (this.isFile) { - block(this) - return - } - - val children = listFiles() ?: return - children.forEach { it.allFiles(block) } - } - -} \ No newline at end of file diff --git a/register/src/main/java/com/lalilu/register/RegisterConfig.kt b/register/src/main/java/com/lalilu/register/RegisterConfig.kt deleted file mode 100644 index f16cc4d5e..000000000 --- a/register/src/main/java/com/lalilu/register/RegisterConfig.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.lalilu.register - -import java.io.Serializable - -open class RegisterConfig : Serializable { - var enable: Boolean = false - var registerInfoList: List> = arrayListOf() - - fun convertRegisterInfo(): List { - return registerInfoList.mapNotNull { - val targetManagerClass = it[TARGET_MANAGER_CLASS] ?: return@mapNotNull null - val baseInterface = it[BASE_INTERFACE] ?: return@mapNotNull null - val registerMethod = it[REGISTER_METHOD] ?: return@mapNotNull null - val registerMethodClass = it[REGISTER_METHOD_CLASS] ?: targetManagerClass - - RegisterInfo( - targetManagerClass = targetManagerClass, - baseInterface = baseInterface, - registerMethod = registerMethod, - registerMethodClass = registerMethodClass - ) - } - } - - companion object { - const val TARGET_MANAGER_CLASS = "TARGET_MANAGER" - const val BASE_INTERFACE = "BASE_INTERFACE" - const val REGISTER_METHOD = "REGISTER_METHOD" - const val REGISTER_METHOD_CLASS = "REGISTER_METHOD_CLASS" - - val registerInfo = HashMap() - } -} \ No newline at end of file diff --git a/register/src/main/java/com/lalilu/register/RegisterInfo.kt b/register/src/main/java/com/lalilu/register/RegisterInfo.kt deleted file mode 100644 index 3b8b2618e..000000000 --- a/register/src/main/java/com/lalilu/register/RegisterInfo.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.lalilu.register - -import java.io.Serializable - -/** - * [targetManagerClass] 目标注册的类 - * [baseInterface] 需要进行扫描后注册的接口 - * [registerMethod] 实际调用来自动注册方法名 - * [registerMethodClass] - */ -class RegisterInfo( - val targetManagerClass: String, - val baseInterface: String, - val registerMethodClass: String, - val registerMethod: String -) : Serializable { - val classSet = HashSet() -} - diff --git a/register/src/main/java/com/lalilu/register/RegisterPlugin.kt b/register/src/main/java/com/lalilu/register/RegisterPlugin.kt deleted file mode 100644 index d7d388ac3..000000000 --- a/register/src/main/java/com/lalilu/register/RegisterPlugin.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.lalilu.register - -import com.android.build.api.artifact.ScopedArtifact -import com.android.build.api.instrumentation.InstrumentationScope -import com.android.build.api.variant.AndroidComponentsExtension -import com.android.build.api.variant.ScopedArtifacts -import com.android.build.gradle.AppPlugin -import org.gradle.api.Plugin -import org.gradle.api.Project - -class RegisterPlugin : Plugin { - companion object { - const val extensionName: String = "registerPlugin" - } - - override fun apply(project: Project) { - project.extensions.create(extensionName, RegisterConfig::class.java) - - val isApp = project.plugins.hasPlugin(AppPlugin::class.java) - require(isApp) { "RegisterPlugin should be apply to App project." } - - val config = project.extensions.findByName(extensionName) as? RegisterConfig - requireNotNull(config) { "RegisterConfig hasn't been initialized." } - - if (!config.enable) { - println("RegisterConfig is not enable.") - return - } - - val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) - println("RegisterPlugin: ${androidComponents.pluginVersion}") - - var registerInfo: List? = null - androidComponents.onVariants { variant -> - if (registerInfo == null) { - registerInfo = config.convertRegisterInfo() - RegisterConfig.registerInfo.putAll(registerInfo!!.associateBy { it.targetManagerClass }) - } - - if (registerInfo.isNullOrEmpty()) { - println("RegisterPlugin: No register info found.") - return@onVariants - } - - // 扫描所有需要注册的Item - variant.instrumentation.transformClassesWith( - ScanClassVisitorFactory::class.java, - InstrumentationScope.ALL - ) { - it.temp.set(System.currentTimeMillis()) - } - - val injectTransformTask = project.tasks - .register("InjectTransformTask_${variant.name}", InjectTransformTask::class.java) { - intermediate.set(project.layout.buildDirectory.dir("intermediates/inject_result/${variant.name}")) - } - - variant.artifacts - .forScope(ScopedArtifacts.Scope.ALL) - .use(injectTransformTask) - .toTransform( - ScopedArtifact.CLASSES, - InjectTransformTask::allJars, - InjectTransformTask::allDirectories, - InjectTransformTask::outputJar - ) - } - } -} \ No newline at end of file diff --git a/register/src/main/java/com/lalilu/register/ScanClassVisitor.kt b/register/src/main/java/com/lalilu/register/ScanClassVisitor.kt deleted file mode 100644 index 1f5c84c2b..000000000 --- a/register/src/main/java/com/lalilu/register/ScanClassVisitor.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.lalilu.register - -import org.objectweb.asm.ClassVisitor -import org.objectweb.asm.FieldVisitor -import org.objectweb.asm.MethodVisitor -import org.objectweb.asm.Opcodes - - -class ScanClassVisitor( - nextClassVisitor: ClassVisitor, - private val registerInfo: RegisterInfo, - private val classInfo: ClassInfo -) : ClassVisitor(Opcodes.ASM9, nextClassVisitor) { - - override fun visit( - version: Int, - access: Int, - name: String?, - signature: String?, - superName: String?, - interfaces: Array? - ) { - val isInterface = (access and Opcodes.ACC_INTERFACE) != 0 - val isAbstract = (access and Opcodes.ACC_ABSTRACT) != 0 - - classInfo.isAbstract = isAbstract - classInfo.isInterface = isInterface - - println( - """ - ============================== - [info]: ${registerInfo.hashCode()} $registerInfo - [targetManagerClass: ${registerInfo.targetManagerClass}] - [baseInterface: ${registerInfo.baseInterface}] - version: $version - access: $access - name: $name - signature: $signature - superName: $superName - interfaces: ${interfaces?.joinToString(", ")} - isInterface: $isInterface - isAbstract: $isAbstract - """.trimIndent() - ) - - super.visit(version, access, name, signature, superName, interfaces) - } - - override fun visitField( - access: Int, - name: String?, - descriptor: String?, - signature: String?, - value: Any? - ): FieldVisitor { - // 存在INSTANCE变量且该变量的类型为该类自身,则说明可直接获取单例对象 - if (name == "INSTANCE" && - (access and Opcodes.ACC_PUBLIC) != 0 && - descriptor == "L${classInfo.className.replace('.', '/')};" - ) { - classInfo.isObject = true - } - return super.visitField(access, name, descriptor, signature, value) - } - - override fun visitMethod( - access: Int, - name: String?, - descriptor: String?, - signature: String?, - exceptions: Array? - ): MethodVisitor { - // 存在公开的无参构造函数则说明可以被直接实例化 - if (name == "" && descriptor == "()V" && (access and Opcodes.ACC_PUBLIC) != 0) { - classInfo.isAbleToCreate = true - } - return super.visitMethod(access, name, descriptor, signature, exceptions) - } -} \ No newline at end of file diff --git a/register/src/main/java/com/lalilu/register/ScanClassVisitorFactory.kt b/register/src/main/java/com/lalilu/register/ScanClassVisitorFactory.kt deleted file mode 100644 index ce8260d6e..000000000 --- a/register/src/main/java/com/lalilu/register/ScanClassVisitorFactory.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.lalilu.register - -import com.android.build.api.instrumentation.AsmClassVisitorFactory -import com.android.build.api.instrumentation.ClassContext -import com.android.build.api.instrumentation.ClassData -import com.android.build.api.instrumentation.InstrumentationParameters -import org.objectweb.asm.ClassVisitor - -private val registerInfoMap: LinkedHashMap = linkedMapOf() -private val classesMap: LinkedHashMap = linkedMapOf() - -/** - * 扫描需要进行注册的各个子类 - */ -abstract class ScanClassVisitorFactory : AsmClassVisitorFactory { - - override fun createClassVisitor( - classContext: ClassContext, - nextClassVisitor: ClassVisitor - ): ClassVisitor { - val info = registerInfoMap[classContext.currentClassData.className] - requireNotNull(info) { "registerConfig is null, please check your registerConfig" } - - val classInfo = classesMap[classContext.currentClassData.className] - requireNotNull(classInfo) { "classInfo is null, please check your classInfo" } - - return ScanClassVisitor(nextClassVisitor, info, classInfo) - } - - override fun isInstrumentable(classData: ClassData): Boolean { - val registerInfo = RegisterConfig.registerInfo.values - - val info = registerInfo.firstOrNull { classData.interfaces.contains(it.baseInterface) } - registerInfoMap[classData.className] = info - - if (info != null) { - val classInfo = ClassInfo(classData.className) - classesMap[classData.className] = classInfo - info.classSet.add(classInfo) - } - return info != null - } -} \ No newline at end of file diff --git a/register/src/main/java/com/lalilu/register/TempParameter.kt b/register/src/main/java/com/lalilu/register/TempParameter.kt deleted file mode 100644 index 85e3bae15..000000000 --- a/register/src/main/java/com/lalilu/register/TempParameter.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.lalilu.register - -import com.android.build.api.instrumentation.InstrumentationParameters -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input - -interface TempParameter : InstrumentationParameters { - @get:Input - val temp: Property -} \ No newline at end of file diff --git a/value-cat/.gitignore b/value-cat/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/value-cat/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/value-cat/build.gradle.kts b/value-cat/build.gradle.kts deleted file mode 100644 index 1b6ceffd0..000000000 --- a/value-cat/build.gradle.kts +++ /dev/null @@ -1,37 +0,0 @@ -plugins { - alias(libs.plugins.library) - alias(libs.plugins.kotlin) -} - -android { - namespace = "com.lalilu.value_cat" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION - - buildFeatures { - compose = true - } - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - } - buildTypes { - release { - consumerProguardFiles("proguard-rules.pro") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } -} - -dependencies { - implementation("com.github.getActivity:EasyWindow:10.6") - implementation(libs.startup.runtime) - implementation(project(":component")) -} \ No newline at end of file diff --git a/value-cat/consumer-rules.pro b/value-cat/consumer-rules.pro deleted file mode 100644 index e69de29bb..000000000 diff --git a/value-cat/proguard-rules.pro b/value-cat/proguard-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/value-cat/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/value-cat/src/main/AndroidManifest.xml b/value-cat/src/main/AndroidManifest.xml deleted file mode 100644 index 68cbb8ad3..000000000 --- a/value-cat/src/main/AndroidManifest.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/value-cat/src/main/java/com/lalilu/value_cat/StartUp.kt b/value-cat/src/main/java/com/lalilu/value_cat/StartUp.kt deleted file mode 100644 index 680e62739..000000000 --- a/value-cat/src/main/java/com/lalilu/value_cat/StartUp.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.lalilu.value_cat - -import android.app.Activity -import android.app.Application -import android.content.Context -import android.os.Bundle -import androidx.compose.ui.platform.ComposeView -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.SavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import androidx.startup.Initializer -import com.hjq.window.EasyWindow -import com.hjq.window.WindowLayout - - -class StartUp : Initializer, Application.ActivityLifecycleCallbacks { - - override fun create(context: Context) { - val application = context as Application - - application.registerActivityLifecycleCallbacks(this) - } - - override fun dependencies(): List>> { - return emptyList() - } - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - val viewTreeLifecycleOwner = activity as? LifecycleOwner ?: return - val savedStateRegistryOwner = activity as? SavedStateRegistryOwner ?: return - - val mDecorView = WindowLayout(activity) - val composeView = ComposeView(activity) - - composeView.setContent(ValueCat.content) - - mDecorView.setViewTreeLifecycleOwner(viewTreeLifecycleOwner) - mDecorView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - - EasyWindow.with(activity) - .setDecorView(mDecorView) - .setContentView(composeView) - .setDraggable() - .show() - } - - override fun onActivityStarted(activity: Activity) { - } - - override fun onActivityResumed(activity: Activity) { - } - - override fun onActivityPaused(activity: Activity) { - } - - override fun onActivityStopped(activity: Activity) { - } - - override fun onActivityDestroyed(activity: Activity) { - } - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { - } -} - diff --git a/value-cat/src/main/java/com/lalilu/value_cat/ValueCat.kt b/value-cat/src/main/java/com/lalilu/value_cat/ValueCat.kt deleted file mode 100644 index f2715e7ca..000000000 --- a/value-cat/src/main/java/com/lalilu/value_cat/ValueCat.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.lalilu.value_cat - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -object ValueCat { - private val valueMap = mutableStateMapOf>() - private val enable = mutableStateOf(false) - - fun catFor(key: String, value: Float) { - val queue = valueMap.getOrPut(key) { emptyList() }.toMutableList() - - if (queue.size >= 19) queue.removeFirst() - queue.add(value) - - valueMap[key] = queue - } - - internal val content = @Composable { - AnimatedVisibility(visible = enable.value) { - Box( - modifier = Modifier.padding(5.dp) - ) { - Surface( - elevation = 10.dp, - shape = RoundedCornerShape(10.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentPadding = PaddingValues(20.dp) - ) { - items(items = valueMap.entries.toList(), key = { it.key }) { - ValueRow( - key = it.key, - list = it.value - ) - } - } - } - } - } - } -} - -@Composable -fun ValueRow(key: String, list: List) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - val textMeasurer = rememberTextMeasurer() - val textStyle = remember { TextStyle(fontSize = 12.sp) } - - Text(text = "$key: %2f".format(list.lastOrNull())) - Canvas( - modifier = Modifier - .border(color = Color.Blue, width = 2.dp) - .height(56.dp) - .width(100.dp) - ) { - val average = list.average() - - val gap = size.width / list.size - val middle = size.height / 2f - - val offsets = list.mapIndexed { index, fl -> - Offset(x = index * gap, y = (middle + (fl - average)).toFloat()) - } - val averageText = textMeasurer.measure( - text = "%.1f".format(average), - style = textStyle - ) - - drawLine( - strokeWidth = 1f, - color = Color.Blue, - start = Offset(0f, middle), - end = Offset(size.width, middle) - ) - drawText( - textLayoutResult = averageText, - topLeft = Offset(0f, middle - averageText.size.height / 2f) - ) - - for (index in offsets.indices) { - if (index == 0) continue - - drawLine( - color = Color.Red, - start = offsets[index - 1], - end = offsets[index], - strokeWidth = 2f - ) - } - } - } -} \ No newline at end of file From d413bca1f3e147af6375c9882a04d44909309eb0 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 27 Jun 2024 02:59:54 +0800 Subject: [PATCH 036/213] =?UTF-8?q?[modify]=E6=9B=B4=E6=96=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=EF=BC=8C=E5=8E=BB=E9=99=A4=E6=97=A0=E7=94=A8=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 14 +++++++----- build.gradle.kts | 1 + buildSrc/build.gradle.kts | 7 ------ buildSrc/src/main/kotlin/AndroidConfig.kt | 5 ---- common/build.gradle.kts | 4 ++-- component/build.gradle.kts | 18 ++++++++------- crash/build.gradle.kts | 9 ++++---- gradle.properties | 5 +--- gradle/libs.versions.toml | 28 +++++++++++------------ gradle/wrapper/gradle-wrapper.properties | 2 +- lalbum/build.gradle.kts | 18 +++++++++------ lartist/build.gradle.kts | 18 +++++++++------ ldictionary/build.gradle.kts | 18 +++++++++------ lhistory/build.gradle.kts | 20 +++++++++------- lmedia | 2 +- lplayer/build.gradle.kts | 6 ++--- lplaylist/build.gradle.kts | 18 +++++++++------ settings.gradle.kts | 7 +----- ui/build.gradle.kts | 5 ++-- 19 files changed, 105 insertions(+), 100 deletions(-) delete mode 100644 buildSrc/build.gradle.kts delete mode 100644 buildSrc/src/main/kotlin/AndroidConfig.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f6aaa650f..342448242 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,7 @@ import java.util.TimeZone plugins { id("com.android.application") kotlin("android") + alias(libs.plugins.compose.compiler) id("com.google.devtools.ksp") id("android.aop") } @@ -39,12 +40,12 @@ androidAopConfig { android { namespace = "com.lalilu" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() defaultConfig { applicationId = "com.lalilu.lmusic" - minSdk = AndroidConfig.MIN_SDK_VERSION - targetSdk = AndroidConfig.TARGET_SDK_VERSION + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() + targetSdk = libs.versions.compile.version.get().toIntOrNull() versionCode = 42 versionName = "1.5.4" @@ -154,15 +155,16 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.compose.compiler.get().version.toString() - } lint { disable += "Instantiatable" abortOnError = false } } +composeCompiler { + enableStrongSkippingMode = true +} + dependencies { implementation(project(":ui")) implementation(project(":crash")) diff --git a/build.gradle.kts b/build.gradle.kts index d2df5d13f..92379033f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.flyjingfish.aop) apply false + alias(libs.plugins.compose.compiler) apply false } gradle.taskGraph.whenReady { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts deleted file mode 100644 index b22ed732f..000000000 --- a/buildSrc/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - `kotlin-dsl` -} - -repositories { - mavenCentral() -} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/AndroidConfig.kt b/buildSrc/src/main/kotlin/AndroidConfig.kt deleted file mode 100644 index 82056a71c..000000000 --- a/buildSrc/src/main/kotlin/AndroidConfig.kt +++ /dev/null @@ -1,5 +0,0 @@ -object AndroidConfig { - const val COMPILE_SDK_VERSION = 34 - const val TARGET_SDK_VERSION = 34 - const val MIN_SDK_VERSION = 21 -} \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 2b4951d78..feefc1c91 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -5,10 +5,10 @@ plugins { android { namespace = "com.lalilu.common" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() } buildTypes { release { diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 8a3d03534..fa65c911e 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -1,18 +1,19 @@ plugins { id("com.android.library") kotlin("android") + alias(libs.plugins.compose.compiler) } android { namespace = "com.lalilu.component" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() - buildFeatures { - compose = true + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() } - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION + buildFeatures { + compose = true } buildTypes { @@ -27,9 +28,10 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } dependencies { diff --git a/crash/build.gradle.kts b/crash/build.gradle.kts index d82abeb5e..f5d6be023 100644 --- a/crash/build.gradle.kts +++ b/crash/build.gradle.kts @@ -5,15 +5,16 @@ plugins { android { namespace = "com.lalilu.crash" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() + } buildFeatures { viewBinding = true } - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - } buildTypes { release { consumerProguardFiles("proguard-rules.pro") diff --git a/gradle.properties b/gradle.properties index 7e90671af..98bed167d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,7 +18,4 @@ android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official -android.suppressUnsupportedCompileSdk=32 -#android.nonTransitiveRClass=false -kotlin.experimental.tryK2=false \ No newline at end of file +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 063ceb566..248bfec1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,18 @@ [versions] -agp_version = "8.2.0" -kotlin_version = "1.9.20" -coroutines_version = "1.7.3" -ksp_version = "1.9.20-1.0.14" +compile_version = "34" +min_sdk_version = "21" + +agp_version = "8.5.0" +kotlin_version = "2.0.0" +coroutines_version = "1.8.1" +ksp_version = "2.0.0-1.0.22" #serialization_json_version = "1.6.0" -koin_version = "3.5.0" -compose_bom_alpha_version = "2024.02.00-alpha02" -compose_bom_version = "2024.01.00" -compose_compiler_version = "1.5.5" +koin_version = "3.5.6" +compose_bom_alpha_version = "2024.05.00-alpha03" +compose_bom_version = "2024.06.00" accompanist_version = "0.32.0" -voyager = "1.0.0-rc10" +voyager = "1.1.0-beta02" lottie-compose = "5.2.0" kotlinpoet = "1.14.2" @@ -18,8 +20,8 @@ coil_version = "2.4.0" utilcodex_version = "1.31.1" # androidx -appcompat = "1.6.1" -core-ktx = "1.12.0" +appcompat = "1.7.0" +core-ktx = "1.13.1" palette-ktx = "1.0.0" dynamicanimation-ktx = "1.0.0-alpha03" startup-runtime = "1.2.0-alpha02" @@ -43,7 +45,6 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine #kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json_version" } # compose -compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose_compiler_version" } compose-bom-alpha = { module = "dev.chrisbanes.compose:compose-bom", version.ref = "compose_bom_alpha_version" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom_version" } compose-ui = { module = "androidx.compose.ui:ui" } @@ -64,7 +65,6 @@ voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version. voyager-bottomSheetNavigator = { module = "cafe.adriel.voyager:voyager-bottom-sheet-navigator", version.ref = "voyager" } voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } -voyager-androidx = { module = "cafe.adriel.voyager:voyager-androidx", version.ref = "voyager" } voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist_version" } @@ -122,6 +122,7 @@ library = { id = "com.android.library", version.ref = "agp_version" } kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin_version" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp_version" } flyjingfish-aop = { id = "io.github.FlyJingFish.AndroidAop.android-aop", version.ref = "flyjingfish-aop" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin_version" } [bundles] @@ -145,7 +146,6 @@ voyager = [ "voyager-navigator", "voyager-tabNavigator", "voyager-transitions", - "voyager-androidx", "voyager-koin" ] flyjingfish-aop = [ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e38081492..7e2884564 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jul 20 14:42:18 CST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/lalbum/build.gradle.kts b/lalbum/build.gradle.kts index e1bac1f42..abd2a3566 100644 --- a/lalbum/build.gradle.kts +++ b/lalbum/build.gradle.kts @@ -1,18 +1,21 @@ plugins { id("com.android.library") kotlin("android") + alias(libs.plugins.compose.compiler) } android { namespace = "com.lalilu.lalbum" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() + } buildFeatures { compose = true } - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - } + buildTypes { release { consumerProguardFiles("proguard-rules.pro") @@ -25,9 +28,10 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } dependencies { diff --git a/lartist/build.gradle.kts b/lartist/build.gradle.kts index 5c7cde26a..3420ab214 100644 --- a/lartist/build.gradle.kts +++ b/lartist/build.gradle.kts @@ -1,18 +1,21 @@ plugins { id("com.android.library") kotlin("android") + alias(libs.plugins.compose.compiler) } android { namespace = "com.lalilu.lartist" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() + } buildFeatures { compose = true } - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - } + buildTypes { release { consumerProguardFiles("proguard-rules.pro") @@ -25,9 +28,10 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } dependencies { diff --git a/ldictionary/build.gradle.kts b/ldictionary/build.gradle.kts index 3fc26b174..cd8f3283f 100644 --- a/ldictionary/build.gradle.kts +++ b/ldictionary/build.gradle.kts @@ -1,18 +1,21 @@ plugins { id("com.android.library") kotlin("android") + alias(libs.plugins.compose.compiler) } android { namespace = "com.lalilu.ldictionary" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() + } buildFeatures { compose = true } - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - } + buildTypes { release { consumerProguardFiles("proguard-rules.pro") @@ -25,9 +28,10 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } dependencies { diff --git a/lhistory/build.gradle.kts b/lhistory/build.gradle.kts index dcd183d72..04f03d267 100644 --- a/lhistory/build.gradle.kts +++ b/lhistory/build.gradle.kts @@ -1,23 +1,26 @@ plugins { id("com.android.library") kotlin("android") + alias(libs.plugins.compose.compiler) id("com.google.devtools.ksp") } android { namespace = "com.lalilu.lhistory" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() - buildFeatures { - compose = true - } defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() ksp { arg("room.schemaLocation", "$projectDir/schemas") } } + + buildFeatures { + compose = true + } + buildTypes { release { consumerProguardFiles("proguard-rules.pro") @@ -30,9 +33,10 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } dependencies { diff --git a/lmedia b/lmedia index a339cef69..d4f9219bd 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit a339cef695f8d583de31871f1330ac9faf42ca60 +Subproject commit d4f9219bd68e1f6ade25325d6457567857f1fc05 diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index b652007d6..dd5347f69 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -5,12 +5,12 @@ plugins { android { namespace = "com.lalilu.lplayer" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() } + buildTypes { release { consumerProguardFiles("proguard-rules.pro") diff --git a/lplaylist/build.gradle.kts b/lplaylist/build.gradle.kts index 64458b45a..75e19351f 100644 --- a/lplaylist/build.gradle.kts +++ b/lplaylist/build.gradle.kts @@ -1,18 +1,21 @@ plugins { id("com.android.library") kotlin("android") + alias(libs.plugins.compose.compiler) } android { namespace = "com.lalilu.lplaylist" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() + } buildFeatures { compose = true } - defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - } + buildTypes { release { consumerProguardFiles("proguard-rules.pro") @@ -25,9 +28,10 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } dependencies { diff --git a/settings.gradle.kts b/settings.gradle.kts index abeeed8b4..3f2f3c286 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,8 +3,6 @@ pluginManagement { google() mavenCentral() gradlePluginPortal() -// maven("https://maven.aliyun.com/repository/central") -// maven("https://maven.aliyun.com/repository/google") } } @@ -13,8 +11,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() -// maven("https://maven.aliyun.com/repository/google") -// maven("https://maven.aliyun.com/repository/central") maven("https://jitpack.io") } } @@ -32,5 +28,4 @@ include(":lartist") include(":lalbum") include(":ldictionary") include(":crash") -include(":component") -include(":value-cat") \ No newline at end of file +include(":component") \ No newline at end of file diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 9842f0683..c032dfd7b 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -5,11 +5,10 @@ plugins { android { namespace = "com.lalilu.ui" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION + compileSdk = libs.versions.compile.version.get().toIntOrNull() defaultConfig { - minSdk = AndroidConfig.MIN_SDK_VERSION - + minSdk = libs.versions.min.sdk.version.get().toIntOrNull() } buildTypes { release { From 5318194a8dc8d94a5e8c74de05d34fe72825176e Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 27 Jun 2024 03:24:10 +0800 Subject: [PATCH 037/213] =?UTF-8?q?[modify]=E5=AE=9E=E7=8E=B0=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E7=8A=B6=E6=80=81=E6=A0=8F=E5=8C=BA=E5=9F=9F=E5=8F=96?= =?UTF-8?q?=E8=89=B2=E6=8E=A7=E5=88=B6=E7=8A=B6=E6=80=81=E6=A0=8F=E6=96=87?= =?UTF-8?q?=E5=AD=97=E9=A2=9C=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/lalilu/lmusic/MainActivity.kt | 8 +- .../navigate/NavigationSheetContent.kt | 30 ------ .../lmusic/utils/DynamicStatusBarUtils.kt | 94 +++++++++++++++++++ 3 files changed, 99 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt diff --git a/app/src/main/java/com/lalilu/lmusic/MainActivity.kt b/app/src/main/java/com/lalilu/lmusic/MainActivity.kt index 0866c8fef..5f49b7de0 100644 --- a/app/src/main/java/com/lalilu/lmusic/MainActivity.kt +++ b/app/src/main/java/com/lalilu/lmusic/MainActivity.kt @@ -24,6 +24,7 @@ import com.lalilu.lmusic.compose.App import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.helper.LastTouchTimeHelper import com.lalilu.lmusic.service.LMusicServiceConnector +import com.lalilu.lmusic.utils.dynamicUpdateStatusBarColor import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { @@ -72,13 +73,14 @@ class MainActivity : ComponentActivity() { LMedia.initialize(this) lifecycle.addObserver(connector) - SystemUiUtil.immerseNavigationBar(this) - SystemUiUtil.immersiveCutout(window) - // 注册返回键事件回调 onBackPressedDispatcher.addCallback { this@MainActivity.moveTaskToBack(false) } + SystemUiUtil.immerseNavigationBar(this) + SystemUiUtil.immersiveCutout(window) + setContent { App.Content(activity = this) } + dynamicUpdateStatusBarColor() volumeControlStream = AudioManager.STREAM_MUSIC } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt index d4864a13a..f51a1dc06 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt @@ -1,52 +1,26 @@ package com.lalilu.lmusic.compose.component.navigate -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.currentComposer -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator -import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.lalilu.component.base.BottomSheetNavigator import com.lalilu.component.base.LocalPaddingValue -import com.lalilu.component.base.LocalWindowSize -import com.lalilu.component.extension.rememberIsPad import com.lalilu.lmusic.compose.component.CustomTransition import com.lalilu.lmusic.compose.new_screen.HomeScreen import com.lalilu.lmusic.compose.new_screen.SearchScreen import com.lalilu.lplaylist.screen.PlaylistScreen -@Composable -fun ImmerseStatusBar( - enable: () -> Boolean = { true }, - isExpended: () -> Boolean = { false }, -) { - val windowSize = LocalWindowSize.current - val isPad by windowSize.rememberIsPad() - val result by remember { derivedStateOf { (isExpended() && enable()) || isPad } } - val systemUiController = rememberSystemUiController() - val isDarkModeNow = isSystemInDarkTheme() - - LaunchedEffect(result, isDarkModeNow) { - systemUiController.setStatusBarColor( - color = Color.Transparent, - darkIcons = result && !isDarkModeNow - ) - } -} @Composable fun NavigationSheetContent( @@ -55,10 +29,6 @@ fun NavigationSheetContent( navigator: BottomSheetNavigator, getScreenFrom: (Navigator) -> Screen = { it.lastItem }, ) { - ImmerseStatusBar( - isExpended = { navigator.isVisible } - ) - Box(modifier = Modifier.fillMaxSize()) { val currentScreen = remember { mutableStateOf(null) } val currentPaddingValue = remember { mutableStateOf(PaddingValues(0.dp)) } diff --git a/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt b/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt new file mode 100644 index 000000000..654bfccc4 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt @@ -0,0 +1,94 @@ +package com.lalilu.lmusic.utils + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.Rect +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +fun ComponentActivity.dynamicUpdateStatusBarColor( + showLog: Boolean = true, + delay: Long = 100, +) = lifecycleScope.launch(Dispatchers.Default) { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + val statusBarHeight = getStatusBarHeight().takeIf { it > 0 } ?: 100 + val width = window.decorView.width.takeIf { it > 0 } ?: 100 + val bitmap = Bitmap.createBitmap(width, statusBarHeight, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bitmap) + val handler = Handler(Looper.getMainLooper()) + val targetRect = Rect(0, 0, bitmap.width, bitmap.height) + + while (isActive) { + delay(delay) + + if (!isActive) break + // 截取Bitmap + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val result = suspendCancellableCoroutine { continuation -> + runCatching { + PixelCopy.request( + window, + targetRect, + bitmap, + { continuation.resume(it) }, + handler + ) + }.getOrElse { + if (showLog) println("PixelCopy: ${it.message}") + continuation.resume(PixelCopy.ERROR_UNKNOWN) + } + } + if (result != PixelCopy.SUCCESS) continue + } else { + window.decorView.draw(canvas) + } + + if (!isActive) break + // 计算Bitmap内的平均亮度 + val averageLuminance = (0.. + (0.. + Color(bitmap.getPixel(x, y)).luminance() + } + } + .flatten() + .average() + + if (!isActive) break + // 更新状态栏上的内容颜色 + if (showLog) println("averageLuminance: $averageLuminance") + val target = averageLuminance > 0.5f + if (controller.isAppearanceLightStatusBars != target) { + withContext(Dispatchers.Main) { + controller.isAppearanceLightStatusBars = target + } + } + } + + canvas.setBitmap(null) + bitmap.recycle() + } +} + +@SuppressLint("InternalInsetResource") +private fun ComponentActivity.getStatusBarHeight(): Int { + val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) +} \ No newline at end of file From ef2dd8eacd8c2fb8721fe3cfe88efc64300790ed Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 28 Jun 2024 03:28:34 +0800 Subject: [PATCH 038/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E9=A6=96=E9=A1=B5=E5=B5=8C=E5=A5=97=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/playing/CustomRecyclerView.kt | 124 -------------- .../screen/playing/NestedScrollBaseLayout.kt | 155 +++++++++--------- .../compose/screen/playing/PlayingLayout.kt | 29 +--- .../compose/screen/playing/PlaylistLayout.kt | 138 ++++++++++++++++ .../lmusic/utils/DynamicStatusBarUtils.kt | 2 +- 5 files changed, 226 insertions(+), 222 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt deleted file mode 100644 index e9b0940a0..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.lalilu.lmusic.compose.screen.playing - -import android.view.View -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.lalilu.common.base.Playable -import com.lalilu.component.extension.DynamicTipsItem -import com.lalilu.component.extension.collectWithLifeCycleOwner -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lmusic.GlobalNavigatorImpl -import com.lalilu.lmusic.adapter.NewPlayingAdapter -import com.lalilu.lmusic.adapter.ViewEvent -import com.lalilu.lmusic.ui.ComposeNestedScrollRecyclerView -import com.lalilu.lmusic.utils.extension.calculateExtraLayoutSpace -import com.lalilu.lmusic.utils.extension.getActivity -import com.lalilu.lplayer.LPlayer -import com.lalilu.lplayer.extensions.QueueAction -import org.koin.compose.koinInject - - -@Composable -fun CustomRecyclerView( - modifier: Modifier = Modifier, - playingVM: IPlayingViewModel = koinInject(), - scrollToTopEvent: () -> Long = { 0L }, - onScrollStart: () -> Unit = {}, - onScrollTouchUp: () -> Unit = {}, - onScrollIdle: () -> Unit = {} -) { - val density = LocalDensity.current - - AndroidView( - modifier = modifier.fillMaxSize(), - factory = { context -> - val activity = context.getActivity()!! - - ComposeNestedScrollRecyclerView(context = context).apply { - val mAdapter = createAdapter(playingVM) { scrollToPosition(0) } - mAdapter.stateRestorationPolicy = - RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY - - val paddingBottom = density.run { 128.dp.roundToPx() } - setPadding(0, 0, 0, paddingBottom) - clipToPadding = false - - id = Int.MAX_VALUE - overScrollMode = View.OVER_SCROLL_NEVER - layoutManager = calculateExtraLayoutSpace(context, 500) - adapter = mAdapter - setItemViewCacheSize(5) - - LPlayer.runtime.info.listFlow - .collectWithLifeCycleOwner(activity) { mAdapter.setDiffData(it) } - - addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged( - recyclerView: RecyclerView, - newState: Int - ) { - when (newState) { - 1 -> onScrollStart() - 2 -> onScrollTouchUp() - 0 -> onScrollIdle() - } - } - }) - } - } - ) { - val event = scrollToTopEvent() - if (event > 0) { - it.smoothScrollToPosition(0) - } - } -} - -private fun createAdapter( - playingVM: IPlayingViewModel, - onScrollToTop: () -> Unit = {}, -): NewPlayingAdapter { - return NewPlayingAdapter.Builder() - .setViewEvent { event, item -> - when (event) { - ViewEvent.OnClick -> playingVM.play(mediaId = item.mediaId, playOrPause = true) - ViewEvent.OnLongClick -> GlobalNavigatorImpl.goToDetailOf(mediaId = item.mediaId) - - ViewEvent.OnSwipeLeft -> { - DynamicTipsItem.Static( - title = item.title, - subTitle = "下一首播放", - imageData = item.imageSource - ).show() - QueueAction.AddToNext(item.mediaId).action() - } - - ViewEvent.OnSwipeRight -> QueueAction.Remove(item.mediaId).action() - ViewEvent.OnBind -> { - - } - } - } - .setOnDataUpdatedCB { needScrollToTop -> if (needScrollToTop) onScrollToTop() } - .setOnItemBoundCB { binding, item -> - playingVM.requireLyric(item) { - binding.songLrc.visibility = if (it) View.VISIBLE else View.INVISIBLE - } - } - .setItemCallback(object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Playable, newItem: Playable): Boolean = - oldItem.mediaId == newItem.mediaId - - override fun areContentsTheSame(oldItem: Playable, newItem: Playable): Boolean = - oldItem.mediaId == newItem.mediaId && - oldItem.title == newItem.title && - oldItem.durationMs == newItem.durationMs - }) - .build() -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt index 8d6e584c4..06b6ef122 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt @@ -1,8 +1,8 @@ package com.lalilu.lmusic.compose.screen.playing import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember @@ -14,39 +14,70 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Velocity +import kotlinx.coroutines.CancellationException import kotlin.math.roundToInt -enum class ScrollingItemType { - LyricView, - RecyclerView -} @Composable fun NestedScrollBaseLayout( draggable: CustomAnchoredDraggableState, isLyricScrollEnable: MutableState, - scrollingItemType: () -> ScrollingItemType? = { null }, toolbarContent: @Composable () -> Unit = {}, - dynamicHeaderContent: @Composable (Constraints) -> Unit = { }, - recyclerViewContent: @Composable () -> Unit = {}, - overlayContent: @Composable BoxScope.() -> Unit = {}, + dynamicHeaderContent: @Composable (Modifier) -> Unit = { }, + playlistContent: @Composable (Modifier) -> Unit = {}, + overlayContent: @Composable (BoxScope.() -> Unit) = {}, ) { val haptic = LocalHapticFeedback.current - BackHandler(draggable.state.value == DragAnchor.Max) { if (draggable.state.value == DragAnchor.Max) { draggable.animateToState(DragAnchor.Middle) } } - val nestedScrollConnection = remember { + val lyricViewNestedScrollConnection = remember { object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // 取消正在进行的动画事件 + draggable.tryCancel() + + if ( + !isLyricScrollEnable.value + && available.y > 0 + && source == NestedScrollSource.Drag + && draggable.position.floatValue.toInt() + == draggable.getPositionByAnchor(DragAnchor.Max) + ) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + isLyricScrollEnable.value = true + } + + return if (isLyricScrollEnable.value) { + super.onPreScroll(available, source) + } else { + if (source == NestedScrollSource.Drag) { + draggable.scrollBy(available.y) + } + available + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (isLyricScrollEnable.value) { + super.onPreScroll(available, source) + } else { + draggable.scrollBy(available.y) + available + } + } + override suspend fun onPreFling(available: Velocity): Velocity { - // 若非RecyclerView的滚动,则消费y轴上的所有速度,避免嵌套滚动事件继续 - if (ScrollingItemType.RecyclerView != scrollingItemType() && !isLyricScrollEnable.value) { + if (!isLyricScrollEnable.value) { draggable.fling(available.y) return available } @@ -55,90 +86,66 @@ fun NestedScrollBaseLayout( } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - if (consumed.y != 0f && available.y == 0f) { - draggable.fling(0f) - } + draggable.fling(0f) + return super.onPostFling(consumed, available) } + } + } + val playlistNestedScrollConnection = remember { + object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - // 取消正在进行的动画事件 draggable.tryCancel() - return when (scrollingItemType()) { - /** - * 若是LyricView的滑动事件,则需判断当前LyricView是否处于可拖动歌词的状态 - * - */ - ScrollingItemType.LyricView -> { - if ( - !isLyricScrollEnable.value - && available.y > 0 - && source == NestedScrollSource.Drag - && draggable.position.floatValue.toInt() - == draggable.getPositionByAnchor(DragAnchor.Max) - ) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - isLyricScrollEnable.value = true - } - - if (isLyricScrollEnable.value) { - super.onPreScroll(available, source) - } else { - if (source == NestedScrollSource.Drag) { - draggable.scrollBy(available.y) - } - available - } - } - - /** - * 若是RecyclerView的滑动事件,则区分上滑和下滑的情况 - * 上滑:首先交由draggable消费,剩余的传递给后续的RecyclerView消费 - * 下滑:直接全部传递给后续的RecyclerView消费 - */ - ScrollingItemType.RecyclerView -> { - if (available.y < 0) available.copy(y = draggable.scrollBy(available.y)) - else super.onPreScroll(available, source) - } - - // 前面的条件都不满足,则将该事件全部消费,避免未知的子组件产生动作 - else -> available + if (available.y < 0f) { + return available.copy(y = draggable.scrollBy(available.y)) } + + return super.onPreScroll(available, source) } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource - ): Offset = when (scrollingItemType()) { - ScrollingItemType.LyricView -> { - if (isLyricScrollEnable.value) { - super.onPreScroll(available, source) - } else { - draggable.scrollBy(available.y) - available + ): Offset { + if (available.y > 0f) { + val consumedY = draggable.scrollBy(available.y) + + if (available.y - consumedY > 0.005f && source == NestedScrollSource.SideEffect) { + throw CancellationException() } + return available } - ScrollingItemType.RecyclerView -> { - if (available.y > 0) available.copy(y = draggable.scrollBy(available.y)) - else super.onPostScroll(consumed, available, source) - } + return super.onPostScroll(consumed, available, source) + } - else -> super.onPostScroll(consumed, available, source) + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + draggable.fling(0f) + + return if (available.y > 0) { + // 向下滑动的情况,消耗剩余的所有速度,避免剩余的速度传递给OverScroll + available + } else { + // 向上滑动的情况,将剩余速度继续传递给外部的OverScroll + super.onPostFling(consumed, available) + } } } } - BoxWithConstraints { + Box { Layout( - modifier = Modifier - .nestedScroll(nestedScrollConnection), content = { toolbarContent() - dynamicHeaderContent(constraints) - recyclerViewContent() + dynamicHeaderContent( + Modifier.nestedScroll(lyricViewNestedScrollConnection) + ) + playlistContent( + Modifier.nestedScroll(playlistNestedScrollConnection) + ) } ) { measurables, constraints -> val toolbar = measurables[0].measure(constraints) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index c9b04492c..43b8df1d2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -7,7 +7,7 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -57,7 +57,6 @@ fun PlayingLayout( val lyricLayoutLazyListState = rememberLazyListState() val isLyricScrollEnable = remember { mutableStateOf(false) } - val recyclerViewScrollState = remember { mutableStateOf(false) } val backgroundColor = remember { mutableStateOf(Color.DarkGray) } val animateColor = animateColorAsState(targetValue = backgroundColor.value, label = "") val scrollToTopEvent = remember { mutableStateOf(0L) } @@ -85,13 +84,6 @@ fun PlayingLayout( NestedScrollBaseLayout( draggable = draggable, isLyricScrollEnable = isLyricScrollEnable, - scrollingItemType = { - when { - lyricLayoutLazyListState.isScrollInProgress -> ScrollingItemType.LyricView - recyclerViewScrollState.value -> ScrollingItemType.RecyclerView - else -> null - } - }, toolbarContent = { Column( modifier = Modifier @@ -112,9 +104,9 @@ fun PlayingLayout( ) } }, - dynamicHeaderContent = { constraints -> - Box( - modifier = Modifier + dynamicHeaderContent = { modifier -> + BoxWithConstraints( + modifier = modifier .fillMaxSize() .clipToBounds() .background(color = animateColor.value) @@ -253,17 +245,8 @@ fun PlayingLayout( ) } }, - recyclerViewContent = { - CustomRecyclerView( - modifier = Modifier.clipToBounds(), - scrollToTopEvent = { scrollToTopEvent.value }, - onScrollStart = { recyclerViewScrollState.value = true }, - onScrollTouchUp = { }, - onScrollIdle = { - recyclerViewScrollState.value = false - draggable.fling(0f) - } - ) + playlistContent = { modifier -> + PlaylistLayout(modifier = modifier.clipToBounds()) }, overlayContent = { val animateProgress = animateFloatAsState( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt new file mode 100644 index 000000000..127d20d87 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -0,0 +1,138 @@ +package com.lalilu.lmusic.compose.screen.playing + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.lalilu.common.base.Playable +import com.lalilu.component.viewmodel.IPlayingViewModel +import com.lalilu.lmusic.GlobalNavigatorImpl +import com.lalilu.lplayer.LPlayer +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + + +@Composable +fun PlaylistLayout( + modifier: Modifier = Modifier, + playingVM: IPlayingViewModel = koinInject() +) { + val items by LPlayer.runtime.info.listFlow.collectAsState(initial = emptyList()) + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + + DisposableEffect(items) { + scope.launch { + println("items: ${items.firstOrNull()?.mediaId}") + listState.animateScrollToItem(0) + } + onDispose { } + } + + LazyColumn( + state = listState, + modifier = modifier.fillMaxWidth() + ) { + items( + items = items, + key = { it.mediaId }, + contentType = { Playable::class.java } + ) { item -> + MediaCard( + Modifier.animateItem( + fadeInSpec = spring(stiffness = Spring.StiffnessVeryLow), + ), + item = item, + onPlayItem = { + playingVM.play(mediaId = item.mediaId, playOrPause = true) + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + GlobalNavigatorImpl.goToDetailOf(mediaId = item.mediaId) + } + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MediaCard( + modifier: Modifier = Modifier, + item: Playable, + onPlayItem: () -> Unit = {}, + onLongClick: () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = { onPlayItem() }, + onLongClick = { onLongClick() } + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colors.background.copy(0.15f), + elevation = 2.dp, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colors.onBackground.copy(0.1f) + ) + ) { + AsyncImage( + modifier = Modifier.size(64.dp), + contentScale = ContentScale.Crop, + model = item, contentDescription = null + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = item.title, + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + Text( + text = item.subTitle, + fontSize = 10.sp, + lineHeight = 12.sp, + color = MaterialTheme.colors.onBackground.copy(0.7f) + ) + } + } +} diff --git a/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt b/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt index 654bfccc4..485fb3b9f 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.resume fun ComponentActivity.dynamicUpdateStatusBarColor( - showLog: Boolean = true, + showLog: Boolean = false, delay: Long = 100, ) = lifecycleScope.launch(Dispatchers.Default) { repeatOnLifecycle(Lifecycle.State.RESUMED) { From 898d2fb79ca86623f94ab7119c54342a39cb3932 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Jun 2024 18:44:10 +0800 Subject: [PATCH 039/213] =?UTF-8?q?[modify]=E5=8E=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/extension/LazyListAnimateScroller.kt | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt index 5df1856f9..ff1b6653f 100644 --- a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt +++ b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -import kotlin.random.Random class LazyListAnimateScroller internal constructor( private val keysKeeper: () -> Collection, @@ -60,10 +59,6 @@ class LazyListAnimateScroller internal constructor( @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) internal suspend fun startLoop(scope: CoroutineScope) = withContext(scope.coroutineContext) { - snapshotFlow { targetValue.floatValue } - .onEach { animator.animateToFinalPosition(it) } - .launchIn(this) - snapshotFlow { listState.layoutInfo.visibleItemsInfo } .distinctUntilChanged() .onEach { list -> list.forEach { sizeMap[it.index] = it.size } } @@ -107,10 +102,9 @@ class LazyListAnimateScroller internal constructor( animator.cancel() exactAnimation = isExactScroll currentValue.floatValue = 0f - targetValue.floatValue = targetOffset + - Random(System.currentTimeMillis()).nextFloat() * 0.1f - // 添加随机值,为了确保能触发LaunchedEffect重组 - // add random value to offset, to ensure that LaunchedEffect will be recomposed + targetValue.floatValue = targetOffset + + animator.animateToFinalPosition(targetOffset) } private suspend fun scrollTo(index: Int) = withContext(Dispatchers.Unconfined) { From d6281ceaa164ade7269ab4372855b44380bc1786 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Jun 2024 18:45:49 +0800 Subject: [PATCH 040/213] =?UTF-8?q?[modify]=E5=9C=A8LazyColumn=E4=B8=8A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=97=A7=E5=88=97=E8=A1=A8=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/PlaylistLayout.kt | 125 ++++++++++++++---- 1 file changed, 102 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 127d20d87..a92095b39 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -1,13 +1,12 @@ package com.lalilu.lmusic.compose.screen.playing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -19,65 +18,144 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback import coil.compose.AsyncImage import com.lalilu.common.base.Playable import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lplayer.LPlayer -import kotlinx.coroutines.launch import org.koin.compose.koinInject +data class Item( + val data: T, + val key: String +) + +fun List>.diff( + items: List, + getId: (T) -> String +): List> { + val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = this@diff.size + override fun getNewListSize(): Int = items.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return this@diff[oldItemPosition].data == items[newItemPosition] + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return this@diff[oldItemPosition].data == items[newItemPosition] + } + }, false) + + val tempList: MutableList?> = this.toMutableList() + result.dispatchUpdatesTo(object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + repeat(count) { tempList.add(position, null) } + } + + override fun onRemoved(position: Int, count: Int) { + repeat(count) { tempList.removeAt(position) } + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + } + }) + + val newGenerationId = System.currentTimeMillis().toString() + (0 until maxOf(items.size, tempList.size)).forEach { index -> + val oldItem = tempList.getOrNull(index) + val newItem = items.getOrNull(index) + if (oldItem == null && newItem != null) { + tempList[index] = Item( + key = "${newGenerationId}_${getId(newItem)}", + data = newItem + ) + } + } + + return tempList.filterNotNull() +} + @Composable fun PlaylistLayout( modifier: Modifier = Modifier, playingVM: IPlayingViewModel = koinInject() ) { - val items by LPlayer.runtime.info.listFlow.collectAsState(initial = emptyList()) - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current + val view = LocalView.current + val listState = rememberLazyListState() + + val items by LPlayer.runtime.info.listFlow.collectAsState(initial = emptyList()) + var actualItems by remember { mutableStateOf(emptyList>()) } + + LaunchedEffect(items) { + val newList = actualItems.diff(items) { it.mediaId } + val newListFirst = newList.firstOrNull() + val oldListFirst = actualItems.firstOrNull() + + // 若无法获取新列表的首元素,则说明新列表为空,及时返回 + if (newListFirst == null) { + actualItems = emptyList() + return@LaunchedEffect + } + + // 判断新列表的首元素是否处于可视范围内 + val isNewListTopVisible = listState.layoutInfo.visibleItemsInfo + .any { it.key == newListFirst.key } + + // 判断旧列表的首元素是否处于可视范围内 + val isOldListTopVisible = oldListFirst?.let { item -> + listState.layoutInfo.visibleItemsInfo + .any { it.key == item.key } + } ?: false - DisposableEffect(items) { - scope.launch { - println("items: ${items.firstOrNull()?.mediaId}") - listState.animateScrollToItem(0) + if (isNewListTopVisible || isOldListTopVisible) { + actualItems = emptyList() + view.post { actualItems = newList } + } else { + actualItems = newList } - onDispose { } } LazyColumn( state = listState, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxSize() ) { items( - items = items, - key = { it.mediaId }, + items = actualItems, + key = { it.key }, contentType = { Playable::class.java } ) { item -> MediaCard( - Modifier.animateItem( - fadeInSpec = spring(stiffness = Spring.StiffnessVeryLow), - ), - item = item, + modifier = Modifier.animateItem(), + item = item.data, onPlayItem = { - playingVM.play(mediaId = item.mediaId, playOrPause = true) + playingVM.play(mediaId = item.data.mediaId, playOrPause = true) }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - GlobalNavigatorImpl.goToDetailOf(mediaId = item.mediaId) + GlobalNavigatorImpl.goToDetailOf(mediaId = item.data.mediaId) } ) } @@ -115,7 +193,8 @@ fun MediaCard( AsyncImage( modifier = Modifier.size(64.dp), contentScale = ContentScale.Crop, - model = item, contentDescription = null + model = item, + contentDescription = null ) } From b468e1dc2b19f0a5c7ff9ee97d015220bdd808dd Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Jun 2024 18:45:59 +0800 Subject: [PATCH 041/213] =?UTF-8?q?[modify]=E6=9B=B4=E6=96=B0compose-bom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 248bfec1d..ebb72f64a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ ksp_version = "2.0.0-1.0.22" #serialization_json_version = "1.6.0" koin_version = "3.5.6" -compose_bom_alpha_version = "2024.05.00-alpha03" +compose_bom_alpha_version = "2024.06.00-alpha01" compose_bom_version = "2024.06.00" accompanist_version = "0.32.0" voyager = "1.1.0-beta02" From 548fb77e0e02dc3ff13264059549db95ad750734 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Jun 2024 23:15:50 +0800 Subject: [PATCH 042/213] =?UTF-8?q?[modify]=E4=BF=AE=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E7=BB=84=E6=97=B6=E6=AF=94=E8=BE=83=E5=AF=B9=E8=B1=A1=E6=97=B6?= =?UTF-8?q?=E6=9C=AA=E6=AF=94=E8=BE=83=E7=B1=BB=E5=9E=8B=E5=B0=B1=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E5=BC=BA=E8=BD=AC=E5=AF=BC=E8=87=B4=E9=97=AA=E9=80=80?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/component/override/AnchoredDraggable.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt b/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt index f43141df1..2db3bdf42 100644 --- a/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt +++ b/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt @@ -836,6 +836,7 @@ private class DraggableAnchorsElement( override fun equals(other: Any?): Boolean { if (this === other) return true + if (javaClass != other?.javaClass) return false other as DraggableAnchorsElement<*> From 038ee9006da3f489cfa87c16b299f26cc285c6ea Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Jun 2024 23:16:10 +0800 Subject: [PATCH 043/213] =?UTF-8?q?[modify]coil2=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E8=87=B3coil3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 21 +- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 12 +- .../com/lalilu/lmusic/adapter/BindAdapter.kt | 38 --- .../com/lalilu/lmusic/adapter/NewAdapter.kt | 251 ------------------ .../compose/component/card/RecommendCard.kt | 6 +- .../compose/new_screen/SearchLyricScreen.kt | 7 +- .../compose/new_screen/SongDetailScreen.kt | 5 +- .../lmusic/compose/screen/ShowScreen.kt | 14 +- .../compose/screen/detail/ImageBgBox.kt | 7 +- .../compose/screen/playing/BlurBackground.kt | 11 +- .../compose/screen/playing/PlaylistLayout.kt | 2 +- .../lalilu/lmusic/service/LMusicNotifier.kt | 10 +- .../lmusic/utils/coil/BlurTransformation.kt | 6 +- .../utils/coil/CrossfadeTransitionFactory.kt | 14 +- .../lmusic/utils/coil/fetcher/BaseFetcher.kt | 2 +- .../utils/coil/fetcher/LAlbumFetcher.kt | 27 +- .../lmusic/utils/coil/fetcher/LSongFetcher.kt | 25 +- .../lmusic/utils/coil/keyer/SongCoverKeyer.kt | 4 +- .../lmusic/utils/coil/mapper/LSongMapper.kt | 4 +- component/build.gradle.kts | 3 +- .../com/lalilu/component/card/SongCard.kt | 7 +- .../component/extension/DynamicTipsHost.kt | 6 +- .../component/extension/PaletteFetcher.kt | 15 +- gradle/libs.versions.toml | 12 +- .../com/lalilu/lalbum/component/AlbumCard.kt | 7 +- .../lalilu/lartist/component/ArtistCard.kt | 7 +- 26 files changed, 132 insertions(+), 391 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/adapter/BindAdapter.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/adapter/NewAdapter.kt diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index 6454458c3..dd83ab0f9 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -4,10 +4,10 @@ import StatusBarLyric.API.StatusBarLyric import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.ViewModelStoreOwner -import coil.EventListener -import coil.ImageLoader -import coil.request.ErrorResult -import coil.request.ImageRequest +import coil3.ImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.transitionFactory +import coil3.util.DebugLogger import com.lalilu.R import com.lalilu.common.base.SourceType import com.lalilu.component.navigation.GlobalNavigator @@ -84,8 +84,8 @@ val AppModule = module { } single { ImageLoader.Builder(androidApplication()) - .callFactory(get()) .components { + add(OkHttpNetworkFetcherFactory(get())) add(SongCoverKeyer()) add(PlayableKeyer()) add(LSongMapper()) @@ -93,16 +93,7 @@ val AppModule = module { add(LAlbumFetcher.AlbumFactory()) } .transitionFactory(CrossfadeTransitionFactory()) - .error(R.drawable.ic_music_2_line_100dp) - .eventListener(object : EventListener { - override fun onError(request: ImageRequest, result: ErrorResult) { -// LogUtils.w("[ImageLoader]:onError", request.data, result.throwable) - } - - override fun onCancel(request: ImageRequest) { -// LogUtils.w("[ImageLoader]:onCancel", request.data) - } - }) + .logger(DebugLogger()) .build() } } diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 88df73f0c..0d67aad4b 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -3,8 +3,9 @@ package com.lalilu.lmusic import android.app.Application import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner -import coil.ImageLoader -import coil.ImageLoaderFactory +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader import com.blankj.utilcode.util.LogUtils import com.lalilu.component.ComponentModule import com.lalilu.lalbum.AlbumModule @@ -22,17 +23,20 @@ import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import java.io.File -class LMusicApp : Application(), ImageLoaderFactory, FilterProvider, ViewModelStoreOwner { +class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, ViewModelStoreOwner { override val viewModelStore: ViewModelStore = ViewModelStore() private val imageLoader: ImageLoader by inject() private val filterGroup: FilterGroup by inject() - override fun newImageLoader(): ImageLoader = imageLoader + override fun newImageLoader(context: PlatformContext): ImageLoader = imageLoader override fun newFilterGroup(): FilterGroup = filterGroup override fun onCreate() { super.onCreate() + SingletonImageLoader + .setSafe(this) + LogUtils.getConfig() .setLog2FileSwitch(true) .setFileExtension(".log") diff --git a/app/src/main/java/com/lalilu/lmusic/adapter/BindAdapter.kt b/app/src/main/java/com/lalilu/lmusic/adapter/BindAdapter.kt deleted file mode 100644 index 5ac91e1a1..000000000 --- a/app/src/main/java/com/lalilu/lmusic/adapter/BindAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.lalilu.lmusic.adapter - -import android.graphics.Outline -import android.view.View -import android.view.ViewOutlineProvider -import androidx.appcompat.widget.AppCompatImageView -import coil.load -import coil.util.CoilUtils.dispose -import com.blankj.utilcode.util.SizeUtils -import com.lalilu.R -import com.lalilu.common.base.Playable - -fun AppCompatImageView.loadCoverForPlaying(item: Playable?) { - item ?: run { - setImageDrawable(null) - return - } - val samplingTo = width - - dispose(this) - load(item.imageSource) { - if (samplingTo > 0) size(samplingTo) - placeholder(R.drawable.ic_music_line_bg_64dp) - error(R.drawable.ic_music_line_bg_64dp) - } -} - -fun AppCompatImageView.setRoundOutline(radius: Number) { - outlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - outline.setRoundRect( - 0, 0, view.width, view.height, - SizeUtils.dp2px(radius.toFloat()).toFloat() - ) - } - } - clipToOutline = true -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/adapter/NewAdapter.kt b/app/src/main/java/com/lalilu/lmusic/adapter/NewAdapter.kt deleted file mode 100644 index 8e7bee067..000000000 --- a/app/src/main/java/com/lalilu/lmusic/adapter/NewAdapter.kt +++ /dev/null @@ -1,251 +0,0 @@ -package com.lalilu.lmusic.adapter - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.DiffUtil.ItemCallback -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding -import com.lalilu.R -import com.lalilu.common.base.Playable -import com.lalilu.databinding.ItemPlayingBinding -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.utils.extension.durationToTime -import com.lalilu.lmusic.utils.extension.getMimeTypeIconRes -import com.lalilu.lmusic.utils.extension.moveHeadToTail -import com.lalilu.lmusic.utils.extension.removeAt - -abstract class NewAdapter constructor( - private val layoutId: Int, -) : RecyclerView.Adapter.NewViewHolder>(), View.OnClickListener, - View.OnLongClickListener { - protected var data: List = emptyList() - - abstract fun onBind(binding: B, item: I, position: Int) - abstract fun getIdFromItem(item: I): String - - fun updateByItem(item: I) { - updateByItemId(getIdFromItem(item)) - } - - fun updateByItemId(id: String) { - val position = data.indexOfFirst { id == getIdFromItem(it) } - if (position in data.indices) { - notifyItemChanged(position) - } - } - - inner class NewViewHolder constructor(internal val binding: B) : - RecyclerView.ViewHolder(binding.root) - - override fun onBindViewHolder(holder: NewAdapter.NewViewHolder, position: Int) { - val binding = holder.binding - val item = data[position] - binding.root.tag = item - binding.root.setOnClickListener(this) - binding.root.setOnLongClickListener(this) - onBind(binding, item, position) - } -} - -enum class ViewEvent { - OnClick, OnLongClick, OnSwipeLeft, OnSwipeRight, OnBind -} - -fun interface OnViewEvent { - fun onViewEvent(event: ViewEvent, item: T) -} - -fun interface OnItemBoundCallback { - fun onItemBound(binding: ItemPlayingBinding, item: T) -} - -fun interface OnDataUpdatedCallback { - fun onDataUpdated(needScrollToTop: Boolean) -} - -class NewPlayingAdapter private constructor( - private val onViewEvent: OnViewEvent?, - private val onItemBoundCallback: OnItemBoundCallback?, - private val onDataUpdatedCallback: OnDataUpdatedCallback?, - private val itemCallback: ItemCallback?, -) : NewAdapter(R.layout.item_playing) { - - private val diffUtilCallbackHelper = - itemCallback?.let { DiffUtilCallbackHelper(itemCallback = it) } - private val touchHelper = TouchHelper { position, direction -> - val item = data.getOrNull(position) ?: return@TouchHelper - - val remove = when (direction) { - ItemTouchHelper.LEFT -> position !in 0..1 - else -> true - } - - if (remove) { - notifyItemRemoved(position) - data = data.removeAt(position) - } else { - notifyItemChanged(position) - } - - when (direction) { - ItemTouchHelper.LEFT -> onViewEvent?.onViewEvent(ViewEvent.OnSwipeLeft, item) - ItemTouchHelper.RIGHT -> onViewEvent?.onViewEvent(ViewEvent.OnSwipeRight, item) - } - } - - override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { - ItemTouchHelper(touchHelper).attachToRecyclerView(recyclerView) - } - - - override fun onBind(binding: ItemPlayingBinding, item: Playable, position: Int) { - binding.songTitle.text = item.title - binding.songSinger.text = item.subTitle - binding.songDuration.text = item.durationMs.durationToTime() - binding.songPic.setRoundOutline(2) - binding.songPic.loadCoverForPlaying(item) - - if (item is LSong) { - binding.songType.setImageResource(getMimeTypeIconRes(item.fileInfo.mimeType)) - } - - onItemBoundCallback?.onItemBound(binding, item) - } - - override fun getIdFromItem(item: Playable): String { - return item.mediaId - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewViewHolder { - return NewViewHolder( - ItemPlayingBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun getItemCount(): Int { - return data.size - } - - override fun onClick(v: View?) { - (v?.tag as? Playable)?.let { onViewEvent?.onViewEvent(ViewEvent.OnClick, it) } - } - - override fun onLongClick(v: View?): Boolean { - v?.parent?.requestDisallowInterceptTouchEvent(true) - (v?.tag as? Playable)?.let { onViewEvent?.onViewEvent(ViewEvent.OnLongClick, it) } - return true - } - - fun setDiffData(list: List) { - var needScrollToTop = false - if (itemCallback == null || diffUtilCallbackHelper == null) { - data = list - notifyDataSetChanged() - onDataUpdatedCallback?.onDataUpdated(false) - } - - var oldList = data - if (list.isNotEmpty() && oldList.isNotEmpty()) { - // 排除播放上一首的情况 - if (oldList.lastOrNull()?.mediaId == list[0].mediaId) { - needScrollToTop = true - } else { - // 预先将头部部分差异进行转移 - // 通过比对第一个元素的id来判断是否需要转移 - val size = oldList.indexOfFirst { it.mediaId == list[0].mediaId } - if (size > 0) { - oldList = oldList.moveHeadToTail(size) - - notifyItemRangeRemoved(0, size) - notifyItemRangeInserted(oldList.size, size) - needScrollToTop = true - } - } - } - - data = list - diffUtilCallbackHelper!!.update(oldList, list) - DiffUtil.calculateDiff(diffUtilCallbackHelper, false) - .dispatchUpdatesTo(this) - onDataUpdatedCallback?.onDataUpdated(needScrollToTop) - } - - class DiffUtilCallbackHelper( - private var oldList: List = emptyList(), - private var newList: List = emptyList(), - private var itemCallback: ItemCallback, - ) : DiffUtil.Callback() { - fun update(oldList: List, newList: List) { - this.oldList = oldList - this.newList = newList - } - - override fun getOldListSize(): Int = oldList.size - override fun getNewListSize(): Int = newList.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - itemCallback.areItemsTheSame(oldList[oldItemPosition], newList[newItemPosition]) - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - itemCallback.areContentsTheSame(oldList[oldItemPosition], newList[newItemPosition]) - } - - class Builder( - private var onViewEvent: OnViewEvent? = null, - private var onItemBoundCallback: OnItemBoundCallback? = null, - private var onDataUpdatedCallback: OnDataUpdatedCallback? = null, - private var itemCallback: ItemCallback? = null, - ) { - fun setViewEvent(onViewEvent: OnViewEvent) = apply { - this.onViewEvent = onViewEvent - } - - fun setOnItemBoundCB(onItemBoundCallback: OnItemBoundCallback) = apply { - this.onItemBoundCallback = onItemBoundCallback - } - - fun setOnDataUpdatedCB(onDataUpdatedCallback: OnDataUpdatedCallback) = apply { - this.onDataUpdatedCallback = onDataUpdatedCallback - } - - fun setItemCallback(itemCallback: ItemCallback) = apply { - this.itemCallback = itemCallback - } - - fun build() = NewPlayingAdapter( - onViewEvent, - onItemBoundCallback, - onDataUpdatedCallback, - itemCallback - ) - } -} - -class TouchHelper( - private val onSwipedCB: OnSwipedCB, -) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) { - fun interface OnSwipedCB { - fun onSwiped(position: Int, direction: Int) - } - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ): Boolean { - return false - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val position = viewHolder.absoluteAdapterPosition - onSwipedCB.onSwiped(position, direction) - } -} - diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/card/RecommendCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/card/RecommendCard.kt index 92132e78d..25affce7b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/card/RecommendCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/card/RecommendCard.kt @@ -26,8 +26,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.palette.graphics.Palette -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.error +import coil3.request.placeholder import com.airbnb.lottie.LottieProperty import com.airbnb.lottie.compose.* import com.lalilu.R diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt index 56f7ed5c5..33d7aba52 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt @@ -30,8 +30,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.request.error +import coil3.request.placeholder import com.lalilu.R import com.lalilu.lmusic.api.lrcshare.SongResult import com.lalilu.component.base.DynamicScreen diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt index 829c7ddd1..6a94c455f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt @@ -47,8 +47,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.ScreenKey -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade import com.lalilu.R import com.lalilu.component.IconButton import com.lalilu.component.IconTextButton diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt index 711ece2fe..b2e5b696a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt @@ -36,11 +36,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import coil.compose.AsyncImagePainter -import coil.compose.SubcomposeAsyncImage -import coil.compose.SubcomposeAsyncImageContent -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.request.transformations import com.blankj.utilcode.util.SizeUtils import com.lalilu.R import com.lalilu.common.base.Playable @@ -120,7 +122,7 @@ fun RowScope.ImageCover(playable: Playable?) { .build(), contentDescription = "" ) { - val state = painter.state + val state by painter.state.collectAsState() if (state is AsyncImagePainter.State.Loading || state is AsyncImagePainter.State.Error) { Image( painter = painterResource(id = R.drawable.ic_music_line), diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt index 6173fc764..fc64d733d 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt @@ -9,9 +9,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import coil.compose.AsyncImage -import coil.drawable.CrossfadeDrawable -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.transition.CrossfadeDrawable @Composable diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt index 090e845b7..cb853322a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt @@ -20,12 +20,15 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.palette.graphics.Palette -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.toBitmap import com.lalilu.common.getAutomaticColor import com.lalilu.lmusic.utils.StackBlurUtils -import com.lalilu.lmusic.utils.extension.toBitmap +@OptIn(ExperimentalCoilApi::class) @Composable fun BlurBackground( modifier: Modifier = Modifier, @@ -85,7 +88,7 @@ fun BlurBackground( contentScale = ContentScale.Crop, contentDescription = "", onSuccess = { state -> - val temp = state.result.drawable.toBitmap() + val temp = state.result.image.toBitmap() samplingBitmap.value = createSamplingBitmap(temp, 400).also { // 提前预加载BlurredBitmap StackBlurUtils.preload(it, extraKey) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index a92095b39..425acbf8b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback -import coil.compose.AsyncImage +import coil3.compose.AsyncImage import com.lalilu.common.base.Playable import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lmusic.GlobalNavigatorImpl diff --git a/app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt b/app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt index ef71026e0..f2251e4a2 100644 --- a/app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt +++ b/app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt @@ -9,8 +9,11 @@ import android.os.Build import android.support.v4.media.session.MediaSessionCompat import androidx.core.app.NotificationCompat import androidx.palette.graphics.Palette -import coil.imageLoader -import coil.request.ImageRequest +import coil3.annotation.ExperimentalCoilApi +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.toBitmap import com.lalilu.R import com.lalilu.common.getAutomaticColor import com.lalilu.lmusic.datastore.SettingsSp @@ -51,6 +54,7 @@ class LMusicNotifier constructor( private val notificationBuilderFlow = MutableStateFlow(null) private var notificationLoopJob: Job? = null + @OptIn(ExperimentalCoilApi::class) override suspend fun getBitmapFromData(data: Any?): Bitmap? { return mContext.imageLoader.execute( ImageRequest.Builder(mContext) @@ -58,7 +62,7 @@ class LMusicNotifier constructor( .data(data) .size(400) .build() - ).drawable?.toBitmap() + ).image?.toBitmap() } override fun getColorFromBitmap(bitmap: Bitmap): Int { diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/BlurTransformation.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/BlurTransformation.kt index 4104b7a5d..c57f45127 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/BlurTransformation.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/BlurTransformation.kt @@ -8,8 +8,8 @@ import android.renderscript.Element import android.renderscript.RenderScript import android.renderscript.ScriptIntrinsicBlur import androidx.core.graphics.applyCanvas -import coil.size.Size -import coil.transform.Transformation +import coil3.size.Size +import coil3.transform.Transformation /** * A [Transformation] that applies a Gaussian blur to an image. @@ -23,7 +23,7 @@ class BlurTransformation @JvmOverloads constructor( private val context: Context, private val radius: Float = DEFAULT_RADIUS, private val sampling: Float = DEFAULT_SAMPLING, -) : Transformation { +) : Transformation() { init { require(radius in 0.0..25.0) { "radius must be in [0, 25]." } diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/CrossfadeTransitionFactory.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/CrossfadeTransitionFactory.kt index d83a91061..60bd680a0 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/CrossfadeTransitionFactory.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/CrossfadeTransitionFactory.kt @@ -1,12 +1,12 @@ package com.lalilu.lmusic.utils.coil -import coil.decode.DataSource -import coil.drawable.CrossfadeDrawable -import coil.request.ImageResult -import coil.request.SuccessResult -import coil.transition.CrossfadeTransition -import coil.transition.Transition -import coil.transition.TransitionTarget +import coil3.decode.DataSource +import coil3.transition.CrossfadeDrawable +import coil3.request.ImageResult +import coil3.request.SuccessResult +import coil3.transition.CrossfadeTransition +import coil3.transition.Transition +import coil3.transition.TransitionTarget /** * A copy of [CrossfadeTransition.Factory] that applies a transition to error results. You know. diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/BaseFetcher.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/BaseFetcher.kt index fa6c2dfa4..9a64e5ba0 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/BaseFetcher.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/BaseFetcher.kt @@ -3,7 +3,7 @@ package com.lalilu.lmusic.utils.coil.fetcher import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri -import coil.fetch.Fetcher +import coil3.fetch.Fetcher import com.blankj.utilcode.util.LogUtils import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.wrapper.Taglib diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LAlbumFetcher.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LAlbumFetcher.kt index 08229fcba..54948523d 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LAlbumFetcher.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LAlbumFetcher.kt @@ -1,29 +1,28 @@ package com.lalilu.lmusic.utils.coil.fetcher -import android.content.Context -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.request.Options +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.request.Options +import coil3.fetch.SourceFetchResult import com.lalilu.lmedia.entity.LAlbum import okio.buffer import okio.source class LAlbumFetcher private constructor( - private val context: Context, + private val options: Options, private val album: LAlbum ) : BaseFetcher() { override suspend fun fetch(): FetchResult? { // 首先尝试从媒体库获取封面,若无则通过其内部的歌曲来获取 - val result = fetchMediaStoreCovers(context, album.coverUri) - ?: album.songs.firstNotNullOfOrNull { fetchForSong(context, it) } + val result = fetchMediaStoreCovers(options.context, album.coverUri) + ?: album.songs.firstNotNullOfOrNull { fetchForSong(options.context, it) } return result?.let { stream -> - SourceResult( - source = ImageSource(stream.source().buffer(), context), + SourceFetchResult( + source = ImageSource(stream.source().buffer(), options.fileSystem), mimeType = null, dataSource = DataSource.DISK ) @@ -32,6 +31,6 @@ class LAlbumFetcher private constructor( class AlbumFactory : Fetcher.Factory { override fun create(data: LAlbum, options: Options, imageLoader: ImageLoader) = - LAlbumFetcher(options.context, data) + LAlbumFetcher(options, data) } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LSongFetcher.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LSongFetcher.kt index cbd6515fe..473ea103d 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LSongFetcher.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LSongFetcher.kt @@ -1,26 +1,25 @@ package com.lalilu.lmusic.utils.coil.fetcher -import android.content.Context -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.request.Options +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.request.Options import com.lalilu.lmedia.entity.LSong import okio.buffer import okio.source class LSongFetcher private constructor( - private val context: Context, + private val options: Options, private val song: LSong ) : BaseFetcher() { - override suspend fun fetch(): FetchResult? = fetchForSong(context, song) + override suspend fun fetch(): FetchResult? = fetchForSong(options.context, song) ?.let { stream -> - SourceResult( - source = ImageSource(stream.source().buffer(), context), + SourceFetchResult( + source = ImageSource(stream.source().buffer(), options.fileSystem), mimeType = null, dataSource = DataSource.DISK ) @@ -28,6 +27,6 @@ class LSongFetcher private constructor( class SongFactory : Fetcher.Factory { override fun create(data: LSong, options: Options, imageLoader: ImageLoader): Fetcher? = - LSongFetcher(options.context, data) + LSongFetcher(options, data) } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt index b88498c20..f5a480de9 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt @@ -1,7 +1,7 @@ package com.lalilu.lmusic.utils.coil.keyer -import coil.key.Keyer -import coil.request.Options +import coil3.key.Keyer +import coil3.request.Options import com.lalilu.common.base.Playable import com.lalilu.lmedia.entity.Item diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/mapper/LSongMapper.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/mapper/LSongMapper.kt index 450159c2f..a3ead02d9 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/mapper/LSongMapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/mapper/LSongMapper.kt @@ -1,7 +1,7 @@ package com.lalilu.lmusic.utils.coil.mapper -import coil.map.Mapper -import coil.request.Options +import coil3.map.Mapper +import coil3.request.Options import com.lalilu.lmedia.entity.LSong class LSongMapper : Mapper { diff --git a/component/build.gradle.kts b/component/build.gradle.kts index fa65c911e..6d8e3e9e0 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -47,8 +47,7 @@ dependencies { api(project(":common")) api(project(":lplayer")) - api(libs.coil) - api(libs.coil.compose) + api(libs.bundles.coil3) // https://github.com/Calvin-LL/Reorderable // Apache-2.0 license diff --git a/component/src/main/java/com/lalilu/component/card/SongCard.kt b/component/src/main/java/com/lalilu/component/card/SongCard.kt index ace941f7b..6035a71a7 100644 --- a/component/src/main/java/com/lalilu/component/card/SongCard.kt +++ b/component/src/main/java/com/lalilu/component/card/SongCard.kt @@ -45,8 +45,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.request.error +import coil3.request.placeholder import com.lalilu.common.base.Playable import com.lalilu.common.base.Sticker import com.lalilu.component.R diff --git a/component/src/main/java/com/lalilu/component/extension/DynamicTipsHost.kt b/component/src/main/java/com/lalilu/component/extension/DynamicTipsHost.kt index e3985291b..c28535488 100644 --- a/component/src/main/java/com/lalilu/component/extension/DynamicTipsHost.kt +++ b/component/src/main/java/com/lalilu/component/extension/DynamicTipsHost.kt @@ -52,8 +52,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.error +import coil3.request.placeholder import com.lalilu.component.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/component/src/main/java/com/lalilu/component/extension/PaletteFetcher.kt b/component/src/main/java/com/lalilu/component/extension/PaletteFetcher.kt index 4680b8151..109346ad8 100644 --- a/component/src/main/java/com/lalilu/component/extension/PaletteFetcher.kt +++ b/component/src/main/java/com/lalilu/component/extension/PaletteFetcher.kt @@ -2,9 +2,12 @@ package com.lalilu.component.extension import android.util.LruCache import androidx.palette.graphics.Palette -import coil.imageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult +import coil3.annotation.ExperimentalCoilApi +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.toBitmap /** * 利用Coil的Listener,结合Bitmap的generationId为Palette做缓存 @@ -12,15 +15,17 @@ import coil.request.SuccessResult object PaletteFetcher { private val paletteCache = LruCache(100) + @OptIn(ExperimentalCoilApi::class) fun onSuccess( request: ImageRequest, result: SuccessResult, callback: (Palette) -> Unit ) { val cacheKey = result.memoryCacheKey ?: return val cacheBitmap = request.context.imageLoader.memoryCache?.get(cacheKey) ?: return - val key = cacheBitmap.bitmap.generationId.toString() + val bitmap = cacheBitmap.image.toBitmap() + val key = bitmap.generationId.toString() paletteCache.get(key).let { - it ?: Palette.Builder(cacheBitmap.bitmap).generate() + it ?: Palette.Builder(bitmap).generate() .apply { paletteCache.put(key, this) } }.let(callback) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ebb72f64a..26a92b55b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ voyager = "1.1.0-beta02" lottie-compose = "5.2.0" kotlinpoet = "1.14.2" -coil_version = "2.4.0" +coil3_version = "3.0.0-alpha07" utilcodex_version = "1.31.1" # androidx @@ -79,8 +79,9 @@ koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = # https://github.com/coil-kt/coil # Apache-2.0 License # 图片加载库 -coil = { module = "io.coil-kt:coil", version.ref = "coil_version" } -coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil_version" } +coil3-android = { module = "io.coil-kt.coil3:coil-android", version.ref = "coil3_version" } +coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3_version" } +coil3-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3_version" } # androidx appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -151,4 +152,9 @@ voyager = [ flyjingfish-aop = [ "flyjingfish-aop-core", "flyjingfish-aop-annotation" +] +coil3 = [ + "coil3-android", + "coil3-compose", + "coil3-okhttp" ] \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/component/AlbumCard.kt b/lalbum/src/main/java/com/lalilu/lalbum/component/AlbumCard.kt index c7e79eb9d..be0b2b63f 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/component/AlbumCard.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/component/AlbumCard.kt @@ -34,8 +34,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.palette.graphics.Palette -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.request.error +import coil3.request.placeholder import com.lalilu.component.R as ComponentR import com.lalilu.component.card.PlayingTipIcon import com.lalilu.lmedia.entity.LAlbum diff --git a/lartist/src/main/java/com/lalilu/lartist/component/ArtistCard.kt b/lartist/src/main/java/com/lalilu/lartist/component/ArtistCard.kt index 1240711f0..3e7dfa067 100644 --- a/lartist/src/main/java/com/lalilu/lartist/component/ArtistCard.kt +++ b/lartist/src/main/java/com/lalilu/lartist/component/ArtistCard.kt @@ -30,8 +30,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import coil.request.ImageRequest +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.request.error +import coil3.request.placeholder import com.lalilu.component.R import com.lalilu.component.card.PlayingTipIcon import com.lalilu.component.extension.dayNightTextColor From 6ae8a8a4a96cc172f7be7ad99abb06ef4a9cda61 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 10 Jul 2024 23:27:55 +0800 Subject: [PATCH 044/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4ModelBottomSh?= =?UTF-8?q?eetLayout=E7=BB=84=E4=BB=B6=E7=9A=84=E6=88=90=E5=91=98=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=8F=AF=E8=A7=81=E6=80=A7=EF=BC=8C=E4=BD=BF=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E8=83=BD=E6=9B=B4=E6=96=B9=E4=BE=BF=E7=81=B5=E6=B4=BB?= =?UTF-8?q?=E7=9A=84=E8=B0=83=E7=94=A8=E5=AF=B9=E5=BA=94=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/component/override/AnchoredDraggable.kt | 8 ++++---- .../component/override/ModalBottomSheetLayout.kt | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt b/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt index 2db3bdf42..af44f6191 100644 --- a/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt +++ b/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt @@ -63,7 +63,7 @@ import kotlinx.coroutines.launch * See the DraggableAnchors factory method to construct drag anchors using a default implementation. */ @ExperimentalMaterialApi -internal interface DraggableAnchors { +interface DraggableAnchors { /** * Get the anchor position for an associated [value] @@ -196,7 +196,7 @@ internal fun Modifier.anchoredDraggable( * access to this scope. */ @ExperimentalMaterialApi -internal interface AnchoredDragScope { +interface AnchoredDragScope { /** * Assign a new value for an offset value for [AnchoredDraggableState]. * @@ -230,7 +230,7 @@ internal interface AnchoredDragScope { */ @Stable @ExperimentalMaterialApi -internal class AnchoredDraggableState( +class AnchoredDraggableState( initialValue: T, internal val positionalThreshold: (totalDistance: Float) -> Float, internal val velocityThreshold: () -> Float, @@ -813,7 +813,7 @@ private class MapDraggableAnchors(private val anchors: Map) : Dragg * constraints. These can be useful to avoid subcomposition. */ @ExperimentalMaterialApi -internal fun Modifier.draggableAnchors( +internal fun Modifier.draggableAnchors( state: AnchoredDraggableState, orientation: Orientation, anchors: (size: IntSize, constraints: Constraints) -> Pair, T>, diff --git a/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt index 7be757c71..8e36278d4 100644 --- a/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt +++ b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt @@ -115,7 +115,7 @@ class ModalBottomSheetState( internal val isSkipHalfExpanded: Boolean = false, ) { - internal val anchoredDraggableState = AnchoredDraggableState( + val anchoredDraggableState = AnchoredDraggableState( initialValue = initialValue, animationSpec = animationSpec, confirmValueChange = confirmValueChange, @@ -404,12 +404,12 @@ fun ModalBottomSheetLayout( } else Modifier ) .modalBottomSheetAnchors(sheetState) - .anchoredDraggable( - state = sheetState.anchoredDraggableState, - orientation = orientation, - enabled = sheetGesturesEnabled && - sheetState.anchoredDraggableState.currentValue != ModalBottomSheetValue.Hidden, - ) +// .anchoredDraggable( +// state = sheetState.anchoredDraggableState, +// orientation = orientation, +// enabled = sheetGesturesEnabled && +// sheetState.anchoredDraggableState.currentValue != ModalBottomSheetValue.Hidden, +// ) .then( if (sheetGesturesEnabled) { Modifier.semantics { From 01f86fa7eb68e8c855701241ab50929cc26a9dce Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 10 Jul 2024 23:31:46 +0800 Subject: [PATCH 045/213] =?UTF-8?q?[refactor]=E9=87=8D=E6=9E=84BottomSheet?= =?UTF-8?q?Navigator=E4=BB=A5=E4=BC=98=E5=8C=96sheetContent=E5=92=8Cconten?= =?UTF-8?q?t=E5=8F=82=E6=95=B0=E5=A4=84=E7=90=86=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0ModalSheet=E8=81=94=E5=8A=A8=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E4=B8=BBcontent=E8=BF=9B=E8=A1=8C=E7=BC=A9=E6=94=BE=E7=9A=84?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/LayoutWrapper.kt | 5 +- .../lmusic/compose/NavigationWrapper.kt | 7 +- .../component/base/BottomSheetNavigator.kt | 67 ++++++++++++++++--- 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt index ad5e28295..f11d2a730 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt @@ -21,10 +21,7 @@ object LayoutWrapper { derivedStateOf { configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } } - DrawerWrapper.Content( - mainContent = { PlayingLayout() }, - secondContent = { NavigationWrapper.Content() } - ) + NavigationWrapper.Content { PlayingLayout() } if (isLandscape) { ShowScreen() diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt index 1a3efa914..9bdb82e44 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt @@ -28,6 +28,7 @@ object NavigationWrapper { @Composable fun Content( modifier: Modifier = Modifier, + content: @Composable () -> Unit = {} ) { val windowSizeClass = LocalWindowSize.current val animateSpec = remember { @@ -40,6 +41,7 @@ object NavigationWrapper { modifier = modifier.fillMaxSize(), defaultScreen = HomeScreen, scrimColor = Color.Black.copy(alpha = 0.5f), + skipHalfExpanded = false, sheetBackgroundColor = MaterialTheme.colors.background, enableBottomSheetMode = { windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded }, animationSpec = animateSpec, @@ -51,7 +53,8 @@ object NavigationWrapper { transitionKeyPrefix = "bottomSheet", navigator = sheetNavigator ) - } - ) { } + }, + content = { content() } + ) } } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt index 00ef5e05a..98c9b3567 100644 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt @@ -2,20 +2,27 @@ package com.lalilu.component.base import androidx.activity.compose.BackHandler import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen @@ -31,8 +38,6 @@ import com.lalilu.component.override.rememberModalBottomSheetState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -typealias BottomSheetNavigatorContent = @Composable (bottomSheetNavigator: BottomSheetNavigator) -> Unit - val LocalBottomSheetNavigator: ProvidableCompositionLocal = staticCompositionLocalOf { null } @@ -50,8 +55,8 @@ fun BottomSheetNavigatorLayout( skipHalfExpanded: Boolean = true, enableBottomSheetMode: () -> Boolean = { true }, animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, - sheetContent: BottomSheetNavigatorContent = { CurrentScreen() }, - content: BottomSheetNavigatorContent + sheetContent: @Composable (bottomSheetNavigator: BottomSheetNavigator) -> Unit = { CurrentScreen() }, + content: @Composable (sheetState: ModalBottomSheetState) -> Unit ) { val coroutineScope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState( @@ -70,6 +75,19 @@ fun BottomSheetNavigatorLayout( ) } + val scaleValue = remember(sheetState) { + derivedStateOf { + val state = sheetState.anchoredDraggableState + val min = state.anchors.minAnchor() + val max = state.anchors.maxAnchor() + val offset = state.offset + + val fraction = offset.normalize(min, max) + val scale = 0.8f + 0.2f * fraction + scale.takeIf { !it.isNaN() } ?: 1f + } + } + CompositionLocalProvider(LocalBottomSheetNavigator provides bottomSheetNavigator) { ModalBottomSheetLayout( modifier = modifier, @@ -84,9 +102,23 @@ fun BottomSheetNavigatorLayout( BackHandler(enabled = bottomSheetNavigator.isVisible) { bottomSheetNavigator.back() } - sheetContent(bottomSheetNavigator) + key("SheetContent") { + sheetContent(bottomSheetNavigator) + } }, - content = { content(bottomSheetNavigator) } + content = { + Surface(color = Color.Black) { + Box(modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scaleValue.value + scaleY = scaleX + } + .clip(RoundedCornerShape(32.dp)), + content = { content(sheetState) } + ) + } + } ) } } @@ -94,8 +126,8 @@ fun BottomSheetNavigatorLayout( class BottomSheetNavigator internal constructor( private val navigator: Navigator, - private val sheetState: ModalBottomSheetState, - private val coroutineScope: CoroutineScope + private val coroutineScope: CoroutineScope, + val sheetState: ModalBottomSheetState, ) : Stack by navigator, EnhanceNavigator { val isVisible: Boolean by derivedStateOf { @@ -103,6 +135,13 @@ class BottomSheetNavigator internal constructor( return@derivedStateOf items.size > 1 } + if (!sheetState.isSkipHalfExpanded) { + return@derivedStateOf sheetState.progress( + from = ModalBottomSheetValue.Hidden, + to = ModalBottomSheetValue.HalfExpanded + ) >= 0.95 + } + sheetState.progress( from = ModalBottomSheetValue.Hidden, to = ModalBottomSheetValue.Expanded @@ -140,4 +179,16 @@ class BottomSheetNavigator internal constructor( } override fun getNavigator(): Navigator = navigator +} + +private fun Float.normalize(minValue: Float, maxValue: Float): Float { + val min = minOf(minValue, maxValue) + val max = maxOf(minValue, maxValue) + + if (min == max) return 0f + if (this <= min) return 0f + if (this >= max) return 1f + + return ((this - min) / (max - min)) + .coerceIn(0f, 1f) } \ No newline at end of file From 7e565999fb6711d26466972d52476a82b284cbdd Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 17 Jul 2024 01:20:11 +0800 Subject: [PATCH 046/213] =?UTF-8?q?[refactor]=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=AD=8C=E6=9B=B2=E8=AF=A6=E6=83=85=E9=A1=B5=E7=9A=84?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E5=92=8C=E5=8A=A8=E6=80=81=E6=95=88=E6=9E=9C?= =?UTF-8?q?=EF=BC=8C=E6=8B=86=E5=88=86=E6=95=B4=E7=90=86=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lmusic/GlobalNavigatorImpl.kt | 2 +- .../compose/new_screen/SongDetailScreen.kt | 402 ------------------ .../new_screen/detail/SongActionsCard.kt | 98 +++++ .../new_screen/detail/SongAlbumInfoCard.kt | 66 +++ .../new_screen/detail/SongArtistsRow.kt | 52 +++ .../new_screen/detail/SongDetailContent.kt | 273 ++++++++++++ .../new_screen/detail/SongDetailScreen.kt | 141 ++++++ .../detail}/SongInformationCard.kt | 6 +- component/build.gradle.kts | 1 + 9 files changed, 634 insertions(+), 407 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt rename app/src/main/java/com/lalilu/lmusic/compose/{component/card => new_screen/detail}/SongInformationCard.kt (97%) diff --git a/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt b/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt index 4b81751e3..fbf02e7e4 100644 --- a/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt +++ b/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt @@ -4,7 +4,7 @@ import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.navigation.EnhanceNavigator import com.lalilu.component.navigation.GlobalNavigator import com.lalilu.lmusic.compose.NavigationWrapper -import com.lalilu.lmusic.compose.new_screen.SongDetailScreen +import com.lalilu.lmusic.compose.new_screen.detail.SongDetailScreen import com.lalilu.lmusic.compose.new_screen.SongsScreen import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt deleted file mode 100644 index 6a94c455f..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt +++ /dev/null @@ -1,402 +0,0 @@ -package com.lalilu.lmusic.compose.new_screen - -import android.content.ComponentName -import android.content.Intent -import android.widget.Toast -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -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.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Chip -import androidx.compose.material.ChipDefaults -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import cafe.adriel.voyager.core.screen.ScreenKey -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade -import com.lalilu.R -import com.lalilu.component.IconButton -import com.lalilu.component.IconTextButton -import com.lalilu.component.TwoColumnWithPad -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenAction -import com.lalilu.component.base.ScreenInfo -import com.lalilu.component.extension.DynamicTipsItem -import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.navigation.GlobalNavigator -import com.lalilu.lalbum.screen.AlbumDetailScreen -import com.lalilu.lartist.screen.ArtistDetailScreen -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.compose.component.base.IconCheckButton -import com.lalilu.lmusic.compose.component.card.RecommendCardCover -import com.lalilu.lmusic.compose.component.card.SongInformationCard -import com.lalilu.lmusic.compose.presenter.DetailScreenAction -import com.lalilu.lmusic.compose.presenter.DetailScreenIsPlayingPresenter -import com.lalilu.lmusic.compose.presenter.DetailScreenLikeBtnPresenter -import com.lalilu.lmusic.compose.screen.detail.ImageBgBox -import com.lalilu.lmusic.utils.extension.EDGE_BOTTOM -import com.lalilu.lmusic.utils.extension.checkActivityIsExist -import com.lalilu.lmusic.utils.extension.edgeTransparent -import com.lalilu.lplayer.extensions.QueueAction -import org.koin.compose.koinInject - -data class SongDetailScreen( - private val mediaId: String -) : DynamicScreen() { - override val key: ScreenKey = "${super.key}:$mediaId" - - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_song_detail - ) - - @Composable - override fun registerActions(): List { - return remember { - listOf( - ScreenAction.StaticAction( - title = R.string.button_set_song_to_next, - color = Color(0xFF00AC84), - onAction = { - val song = LMedia.get(id = mediaId) ?: return@StaticAction - QueueAction.AddToNext(song.mediaId).action() - DynamicTipsItem.Static( - title = song.title, - subTitle = "下一首播放", - imageData = song.imageSource - ).show() - } - ), - ScreenAction.ComposeAction { - val state = DetailScreenLikeBtnPresenter(mediaId) - - IconCheckButton( - modifier = Modifier - .fillMaxHeight() - .aspectRatio(4f / 3f), - shape = RectangleShape, - getIsChecked = { state.isLiked }, - onCheckedChange = { state.onAction(if (it) DetailScreenAction.Like else DetailScreenAction.UnLike) }, - checkedColor = MaterialTheme.colors.primary, - checkedIconRes = R.drawable.ic_heart_3_fill, - normalIconRes = R.drawable.ic_heart_3_line - ) - }, - ScreenAction.ComposeAction { - val state = DetailScreenIsPlayingPresenter(mediaId) - - AnimatedContent( - modifier = Modifier - .fillMaxHeight() - .aspectRatio(3f / 2f), - targetState = state.isPlaying, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "" - ) { isPlaying -> - val icon = - if (isPlaying) R.drawable.ic_pause_line else R.drawable.ic_play_line - IconButton( - modifier = Modifier.fillMaxSize(), - color = Color(0xFF006E7C), - shape = RectangleShape, - text = stringResource(id = R.string.text_button_play), - icon = painterResource(id = icon), - onClick = { state.onAction(DetailScreenAction.PlayPause) } - ) - } - }, - ) - } - } - - @Composable - override fun Content() { - val song = LMedia.getFlow(id = mediaId) - .collectAsState(initial = null) - - DetailScreen( - mediaId = mediaId, - song = song.value - ) - } -} - -@Composable -private fun DetailScreen( - mediaId: String, - song: LSong? -) { - val navigator: GlobalNavigator = koinInject() - - if (song == null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text(text = "[Error]加载失败 #${mediaId}") - } - return - } - - ImageBgBox( - imageData = song, - imageModifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .blur(20.dp) - .edgeTransparent(position = EDGE_BOTTOM, percent = 1.5f) - ) { - TwoColumnWithPad( - minWidthBreakPoint = 600.dp, - modifierForPad = Modifier.width(325.dp), - arrangementForPad = Arrangement.spacedBy(16.dp), - arrangementForNormal = Arrangement.spacedBy(16.dp), - columnForPad = { - songHeadContent(song = song, navigator = navigator, isPad = true) - }, - columnForNormal = { isPad -> - if (!isPad) { - songHeadContent(song = song, navigator = navigator, isPad = false) - } - - songAlbumInfoCard(song, navigator) - - songActionsCard(song, navigator) - - songDetailInfoCard(song, navigator) - } - ) - } -} - - -@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) -fun LazyListScope.songHeadContent( - isPad: Boolean = false, - song: LSong, - navigator: GlobalNavigator -) { - item { - Surface( - modifier = Modifier.padding( - horizontal = if (isPad) 0.dp else 20.dp, - vertical = if (isPad) 0.dp else 10.dp - ), - elevation = 2.dp, - shape = RoundedCornerShape(10.dp) - ) { - AsyncImage( - modifier = Modifier - .fillMaxWidth(), - model = ImageRequest.Builder(LocalContext.current) - .data(song) - .crossfade(true) - .build(), - contentScale = ContentScale.FillWidth, - contentDescription = "" - ) - } - } - item { - NavigatorHeader( - title = song.name, - columnExtraSpace = 5.dp, - paddingValues = PaddingValues( - horizontal = if (isPad) 0.dp else 20.dp - ), - columnExtraContent = { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - song.artists.forEach { - Chip( - onClick = { - navigator.navigateTo(ArtistDetailScreen(artistName = it.name)) - }, - colors = ChipDefaults.outlinedChipColors(), - ) { - Text( - text = it.name, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = contentColorFor(backgroundColor = MaterialTheme.colors.background) - .copy(alpha = 0.7f) - ) - } - } - } - } - ) - } -} - -@OptIn(ExperimentalMaterialApi::class) -fun LazyListScope.songAlbumInfoCard( - song: LSong, - navigator: GlobalNavigator -) { - song.album?.let { - item { - Surface( - modifier = Modifier.padding(start = 20.dp, end = 20.dp), - shape = RoundedCornerShape(20.dp), - onClick = { navigator.navigateTo(AlbumDetailScreen(albumId = it.id)) } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - RecommendCardCover( - width = { 125.dp }, - height = { 125.dp }, - imageData = { it } - ) - Column( - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = it.name, - style = MaterialTheme.typography.subtitle1, - color = dayNightTextColor() - ) - it.artistName?.let { it1 -> - Text( - text = it1, - style = MaterialTheme.typography.subtitle2, - color = dayNightTextColor(0.5f) - ) - } - } - } - } - } - } -} - -fun LazyListScope.songActionsCard( - song: LSong, - navigator: GlobalNavigator -) { - item { - val context = LocalContext.current - val intent = remember(song) { - Intent().apply { - component = ComponentName( - "com.xjcheng.musictageditor", - "com.xjcheng.musictageditor.SongDetailActivity" - ) - action = "android.intent.action.VIEW" - data = song.uri - } - } - - Surface( - modifier = Modifier.padding(horizontal = 20.dp), - shape = RoundedCornerShape(20.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(10.dp), - horizontalArrangement = Arrangement.spacedBy(15.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (context.checkActivityIsExist(intent)) { - IconTextButton( - text = "音乐标签编辑", - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(10.dp), - color = Color(0xFF3EA22C), - onClick = { - if (context.checkActivityIsExist(intent)) { - context.startActivity(intent) - } else { - Toast.makeText( - context, - "未安装[音乐标签]", - Toast.LENGTH_SHORT - ) - .show() - } - } - ) - } - IconTextButton( - text = "搜索LrcShare", - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(10.dp), - color = Color(0xFF3EA22C), - onClick = { - navigator.navigateTo( - SearchLyricScreen( - mediaId = song.id, - keywords = song.name - ) - ) - } - ) - } - } - } -} - -fun LazyListScope.songDetailInfoCard( - song: LSong, - navigator: GlobalNavigator -) { - item { - SongInformationCard( - modifier = Modifier.fillMaxWidth(), - song = song - ) - } -} - -fun LazyListScope.songLyricCard( - song: LSong -) { - -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt new file mode 100644 index 000000000..369cda698 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt @@ -0,0 +1,98 @@ +package com.lalilu.lmusic.compose.new_screen.detail + +import android.content.ComponentName +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.lalilu.component.IconTextButton +import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmusic.compose.new_screen.SearchLyricScreen +import com.lalilu.lmusic.utils.extension.checkActivityIsExist +import org.koin.compose.koinInject + +@Composable +fun SongActionsCard( + modifier: Modifier = Modifier, + song: LSong, +) { + val navigator: GlobalNavigator = koinInject() + val context = LocalContext.current + val intent = remember(song) { + Intent().apply { + component = ComponentName( + "com.xjcheng.musictageditor", + "com.xjcheng.musictageditor.SongDetailActivity" + ) + action = "android.intent.action.VIEW" + data = song.uri + } + } + + + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (context.checkActivityIsExist(intent)) { + IconTextButton( + text = "音乐标签编辑", + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(10.dp), + color = Color(0xFF3EA22C), + onClick = { + if (context.checkActivityIsExist(intent)) { + context.startActivity(intent) + } else { + Toast.makeText( + context, + "未安装[音乐标签]", + Toast.LENGTH_SHORT + ).show() + } + } + ) + } + IconTextButton( + text = "搜索LrcShare", + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(10.dp), + color = Color(0xFF3EA22C), + onClick = { + navigator.navigateTo( + SearchLyricScreen( + mediaId = song.id, + keywords = song.name + ) + ) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt new file mode 100644 index 000000000..0a65cb7af --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt @@ -0,0 +1,66 @@ +package com.lalilu.lmusic.compose.new_screen.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lalilu.component.extension.dayNightTextColor +import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.lalbum.screen.AlbumDetailScreen +import com.lalilu.lmedia.entity.LAlbum +import com.lalilu.lmusic.compose.component.card.RecommendCardCover +import org.koin.compose.koinInject + + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SongAlbumInfoCard( + modifier: Modifier = Modifier, + album: LAlbum, +) { + val navigator: GlobalNavigator = koinInject() + + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + onClick = { navigator.navigateTo(AlbumDetailScreen(albumId = album.id)) } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + RecommendCardCover( + width = { 125.dp }, + height = { 125.dp }, + imageData = { album } + ) + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = album.name, + style = MaterialTheme.typography.subtitle1, + color = dayNightTextColor() + ) + album.artistName?.let { artist -> + Text( + text = artist, + style = MaterialTheme.typography.subtitle2, + color = dayNightTextColor(0.5f) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt new file mode 100644 index 000000000..eaa5884e1 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt @@ -0,0 +1,52 @@ +package com.lalilu.lmusic.compose.new_screen.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material.Chip +import androidx.compose.material.ChipDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.lartist.screen.ArtistDetailScreen +import com.lalilu.lmedia.entity.LArtist +import org.koin.compose.koinInject + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) +@Composable +fun SongArtistsRow( + modifier: Modifier = Modifier, + artists: Set +) { + val navigator: GlobalNavigator = koinInject() + + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + artists.forEach { + Chip( + onClick = { + navigator.navigateTo(ArtistDetailScreen(artistName = it.name)) + }, + colors = ChipDefaults.outlinedChipColors(), + ) { + Text( + text = it.name, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = contentColorFor(backgroundColor = MaterialTheme.colors.background) + .copy(alpha = 0.7f) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt new file mode 100644 index 000000000..533d2aa10 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt @@ -0,0 +1,273 @@ +package com.lalilu.lmusic.compose.new_screen.detail + +import android.net.Uri +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.Dimension +import androidx.constraintlayout.compose.MotionLayout +import androidx.constraintlayout.compose.MotionScene +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.lalilu.common.base.SourceType +import com.lalilu.component.base.LocalPaddingValue +import com.lalilu.lmedia.entity.FileInfo +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.entity.Metadata + +private val lSong = LSong( + id = "inceptos", name = "Kim Serrano", metadata = Metadata( + title = "maluisset", + album = "honestatis", + artist = "persius", + albumArtist = "simul", + composer = "eum", + lyricist = "eos", + comment = "morbi", + genre = "dolore", + track = "oratio", + disc = "sapien", + date = "iudicabit", + duration = 5920, + dateAdded = 2540, + dateModified = 3267 + ), fileInfo = FileInfo( + mimeType = "molestiae", + directoryPath = "amet", + pathStr = null, + fileName = null, + size = 5613 + ), uri = Uri.EMPTY, + sourceType = SourceType.Local, albumId = null +) + +@Composable +fun SongDetailContent( + modifier: Modifier = Modifier, + song: LSong = lSong, + progress: Float = 0f, +) { + val paddingTop = WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() + + val scene = remember { + MotionScene { + val coverRef = createRefFor("cover") + val titleRow = createRefFor("title") + val subTitleRow = createRefFor("subTitle") + val content = createRefFor("content") + + val collapsed = constraintSet { + constrain(coverRef) { + top.linkTo(parent.top, 16.dp) + start.linkTo(parent.start, 16.dp) + + width = Dimension.value(72.dp) + height = Dimension.value(72.dp) + } + + constrain(titleRow) { + top.linkTo(coverRef.top) + start.linkTo(coverRef.end, 16.dp) + end.linkTo(parent.end, 16.dp) + + width = Dimension.fillToConstraints + height = Dimension.wrapContent + } + + constrain(subTitleRow) { + top.linkTo(titleRow.bottom, 8.dp) + start.linkTo(coverRef.end, 16.dp) + end.linkTo(parent.end, 16.dp) + + width = Dimension.fillToConstraints + height = Dimension.wrapContent + } + + val barrier = createBottomBarrier(coverRef, subTitleRow) + + constrain(content) { + top.linkTo(barrier, 16.dp) + start.linkTo(parent.start, 16.dp) + end.linkTo(parent.end, 16.dp) + + width = Dimension.fillToConstraints + height = Dimension.wrapContent + } + } + val expended = constraintSet { + constrain(coverRef) { + top.linkTo(parent.top, 16.dp + paddingTop + 24.dp) + start.linkTo(parent.start, 16.dp) + end.linkTo(parent.end, 16.dp) + + width = Dimension.fillToConstraints + height = Dimension.preferredWrapContent + } + + constrain(titleRow) { + top.linkTo(coverRef.bottom, 16.dp) + start.linkTo(parent.start, 16.dp) + end.linkTo(parent.end, 16.dp) + + width = Dimension.fillToConstraints + height = Dimension.preferredWrapContent + } + + constrain(subTitleRow) { + top.linkTo(titleRow.bottom, 8.dp) + start.linkTo(parent.start, 16.dp) + end.linkTo(parent.end, 16.dp) + + width = Dimension.fillToConstraints + height = Dimension.wrapContent + } + + val barrier = createBottomBarrier(coverRef, subTitleRow) + + constrain(content) { + top.linkTo(barrier, 16.dp) + start.linkTo(parent.start, 16.dp) + end.linkTo(parent.end, 16.dp) + + width = Dimension.fillToConstraints + height = Dimension.wrapContent + } + } + + defaultTransition(from = collapsed, to = expended) + } + } + + MotionLayout( + modifier = modifier.fillMaxSize(), + motionScene = scene, + progress = progress + ) { + Surface( + modifier = Modifier.layoutId("cover"), + elevation = 2.dp, + shape = RoundedCornerShape(10.dp) + ) { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + model = ImageRequest.Builder(LocalContext.current) + .data(song) + .size(width = 1024, height = 1024) + .crossfade(true) + .build(), + contentScale = ContentScale.FillWidth, + contentDescription = "" + ) + } + + Text( + modifier = Modifier + .layoutId("title") + .graphicsLayer { + scaleX = 1f + (0.1f * progress) + scaleY = scaleX + + transformOrigin = TransformOrigin(0f, 0f) + }, + text = song.name, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + lineHeight = 16.sp + ) + + Text( + modifier = Modifier.layoutId("subTitle"), + text = song.subTitle, + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 12.sp + ) + + Column( + modifier = Modifier + .layoutId("content") + .padding(bottom = LocalPaddingValue.current.value.calculateBottomPadding() + 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + SongArtistsRow( + modifier = Modifier.fillMaxWidth(), + artists = song.artists + ) + + song.album?.let { + SongAlbumInfoCard( + modifier = Modifier.fillMaxWidth(), + album = it + ) + } + + SongActionsCard( + modifier = Modifier.fillMaxWidth(), + song = song + ) + + SongInformationCard( + modifier = Modifier.fillMaxWidth(), + song = song + ) + } + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun SongDetailContentPreview() { + val expended = remember { mutableStateOf(false) } + val progress by animateFloatAsState( + targetValue = if (expended.value) 1f else 0f, + label = "progress" + ) + + SongDetailContent( + progress = progress, + ) +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun SongDetailContentPreview2() { + val expended = remember { mutableStateOf(false) } + val progress by animateFloatAsState( + targetValue = if (expended.value) 0f else 1f, + label = "progressReverse" + ) + + SongDetailContent( + progress = progress, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt new file mode 100644 index 000000000..86f8ba2c1 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -0,0 +1,141 @@ +package com.lalilu.lmusic.compose.new_screen.detail + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.screen.ScreenKey +import com.lalilu.R +import com.lalilu.component.IconButton +import com.lalilu.component.base.DynamicScreen +import com.lalilu.component.base.LocalBottomSheetNavigator +import com.lalilu.component.base.ScreenAction +import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.extension.DynamicTipsItem +import com.lalilu.component.override.ModalBottomSheetValue +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmusic.compose.component.base.IconCheckButton +import com.lalilu.lmusic.compose.presenter.DetailScreenAction +import com.lalilu.lmusic.compose.presenter.DetailScreenIsPlayingPresenter +import com.lalilu.lmusic.compose.presenter.DetailScreenLikeBtnPresenter +import com.lalilu.lplayer.extensions.QueueAction + +data class SongDetailScreen( + private val mediaId: String +) : DynamicScreen() { + override val key: ScreenKey = "${super.key}:$mediaId" + + override fun getScreenInfo(): ScreenInfo = ScreenInfo( + title = R.string.screen_title_song_detail + ) + + @Composable + override fun registerActions(): List { + return remember { + listOf( + ScreenAction.StaticAction( + title = R.string.button_set_song_to_next, + color = Color(0xFF00AC84), + onAction = { + val song = LMedia.get(id = mediaId) ?: return@StaticAction + QueueAction.AddToNext(song.mediaId).action() + DynamicTipsItem.Static( + title = song.title, + subTitle = "下一首播放", + imageData = song.imageSource + ).show() + } + ), + ScreenAction.ComposeAction { + val state = DetailScreenLikeBtnPresenter(mediaId) + + IconCheckButton( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(4f / 3f), + shape = RectangleShape, + getIsChecked = { state.isLiked }, + onCheckedChange = { state.onAction(if (it) DetailScreenAction.Like else DetailScreenAction.UnLike) }, + checkedColor = MaterialTheme.colors.primary, + checkedIconRes = R.drawable.ic_heart_3_fill, + normalIconRes = R.drawable.ic_heart_3_line + ) + }, + ScreenAction.ComposeAction { + val state = DetailScreenIsPlayingPresenter(mediaId) + + AnimatedContent( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(3f / 2f), + targetState = state.isPlaying, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { isPlaying -> + val icon = + if (isPlaying) R.drawable.ic_pause_line else R.drawable.ic_play_line + IconButton( + modifier = Modifier.fillMaxSize(), + color = Color(0xFF006E7C), + shape = RectangleShape, + text = stringResource(id = R.string.text_button_play), + icon = painterResource(id = icon), + onClick = { state.onAction(DetailScreenAction.PlayPause) } + ) + } + }, + ) + } + } + + @Composable + override fun Content() { + val song = LMedia.getFlow(id = mediaId) + .collectAsState(initial = null) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + when { + song.value != null -> { + val bottomSheet = LocalBottomSheetNavigator.current + val progress by remember(bottomSheet?.sheetState) { + derivedStateOf { + bottomSheet?.sheetState?.progress( + ModalBottomSheetValue.HalfExpanded, + ModalBottomSheetValue.Expanded, + ) ?: 0f + } + } + + SongDetailContent( + song = song.value!!, + progress = progress + ) + } + + else -> {} + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/card/SongInformationCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt similarity index 97% rename from app/src/main/java/com/lalilu/lmusic/compose/component/card/SongInformationCard.kt rename to app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt index ae764e7d4..8d89dcb9a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/card/SongInformationCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.component.card +package com.lalilu.lmusic.compose.new_screen.detail import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -25,9 +25,7 @@ fun SongInformationCard( song: LSong ) { Surface( - modifier = modifier - .padding(horizontal = 20.dp) - .width(intrinsicSize = IntrinsicSize.Min), + modifier = modifier, shape = RoundedCornerShape(20.dp) ) { Column( diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 6d8e3e9e0..f5277cacd 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { api("sh.calvin.reorderable:reorderable:1.1.0") api("com.github.cy745:AnyPopDialog-Compose:jitpack-SNAPSHOT") api("me.rosuh:AndroidFilePicker:1.0.1") + api("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha13") // compose // api(platform(libs.compose.bom)) From cd523959f223bccc7aa978925ba203b543365d03 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 00:43:05 +0800 Subject: [PATCH 047/213] =?UTF-8?q?[refactor]=E4=BD=BF=E7=94=A8KRouter?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=85=A8=E5=B1=80=E7=9A=84=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 + app/proguard-rules.pro | 2 + .../main/java/com/lalilu/lmusic/AppModule.kt | 3 - .../com/lalilu/lmusic/GlobalNavigatorImpl.kt | 54 ------------- .../lalilu/lmusic/aop/BackHandlerOverride.kt | 37 --------- .../lmusic/compose/NavigationWrapper.kt | 77 +++++++++---------- component/build.gradle.kts | 1 + .../lalilu/component/navigation/AppRouter.kt | 45 +++++++++++ .../component/navigation/GlobalNavigator.kt | 40 ---------- 9 files changed, 89 insertions(+), 173 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/aop/BackHandlerOverride.kt create mode 100644 component/src/main/java/com/lalilu/component/navigation/AppRouter.kt delete mode 100644 component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 342448242..39adf9149 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -175,6 +175,9 @@ dependencies { implementation(project(":lalbum")) implementation(project(":ldictionary")) + // KRouter + ksp("com.github.cy745.KRouter:compiler:fcf40f4b15") + implementation(libs.room.ktx) implementation(libs.room.runtime) ksp(libs.room.compiler) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 5bdf4c652..27b8434be 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -45,6 +45,8 @@ -dontwarn com.squareup.picasso.Picasso -dontwarn com.squareup.picasso.RequestCreator +-keepnames @com.zhangke.krouter.annotation.Destination class * { *; } + # 墨 · 状态栏歌词 -keep class StatusBarLyric.API.StatusBarLyric { *; } diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index dd83ab0f9..0ba60c1c4 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -10,7 +10,6 @@ import coil3.request.transitionFactory import coil3.util.DebugLogger import com.lalilu.R import com.lalilu.common.base.SourceType -import com.lalilu.component.navigation.GlobalNavigator import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lalbum.viewModel.AlbumsViewModel import com.lalilu.lartist.viewModel.ArtistsViewModel @@ -56,8 +55,6 @@ import java.net.URLDecoder val AppModule = module { single { androidApplication() as ViewModelStoreOwner } - single { GlobalNavigatorImpl } - single { FastKV.Builder(androidApplication(), "LMusic") .encoder( diff --git a/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt b/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt deleted file mode 100644 index fbf02e7e4..000000000 --- a/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.lalilu.lmusic - -import cafe.adriel.voyager.core.screen.Screen -import com.lalilu.component.navigation.EnhanceNavigator -import com.lalilu.component.navigation.GlobalNavigator -import com.lalilu.lmusic.compose.NavigationWrapper -import com.lalilu.lmusic.compose.new_screen.detail.SongDetailScreen -import com.lalilu.lmusic.compose.new_screen.SongsScreen -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlin.coroutines.CoroutineContext - -object GlobalNavigatorImpl : GlobalNavigator, CoroutineScope { - override val coroutineContext: CoroutineContext = Dispatchers.Default - - /** - * 跳转至某元素的详情页 - */ - override fun goToDetailOf( - mediaId: String, - navigator: EnhanceNavigator?, - ) { - val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.jump(SongDetailScreen(mediaId = mediaId)) - } - - override fun showSongs( - mediaIds: List, - title: String?, - navigator: EnhanceNavigator?, - ) { - val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.jump( - SongsScreen( - title = title, - mediaIds = mediaIds - ) - ) - } - - override fun navigateTo( - screen: Screen, - singleTop: Boolean, - navigator: EnhanceNavigator? - ) { - val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.jump(targetScreen = screen) - } - - override fun goBack(navigator: EnhanceNavigator?) { - val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.back() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/aop/BackHandlerOverride.kt b/app/src/main/java/com/lalilu/lmusic/aop/BackHandlerOverride.kt deleted file mode 100644 index 90bb048c7..000000000 --- a/app/src/main/java/com/lalilu/lmusic/aop/BackHandlerOverride.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.lalilu.lmusic.aop - -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import com.flyjingfish.android_aop_annotation.anno.AndroidAopReplaceClass -import com.flyjingfish.android_aop_annotation.anno.AndroidAopReplaceMethod -import com.flyjingfish.android_aop_annotation.enums.MatchType -import com.lalilu.component.base.LocalBottomSheetNavigator - -/** - * 全局替换所有的BackHandler - */ -@AndroidAopReplaceClass( - value = "androidx.activity.compose.BackHandlerKt", - type = MatchType.SELF -) -object BackHandlerOverride { - - @JvmStatic - @Composable - @AndroidAopReplaceMethod( - value = "void BackHandler(boolean, kotlin.jvm.functions.Function0, androidx.compose.runtime.Composer, int, int)" - ) - fun BackHandlerOverride(enabled: Boolean = true, onBack: () -> Unit) { - val sheetNavigator = LocalBottomSheetNavigator.current - // 若可获取到SheetNavigator,则说明其处于BottomSheet内,则为其关联isVisible控制 - if (sheetNavigator == null) { - BackHandler(enabled, onBack) - return - } - - BackHandler( - enabled = sheetNavigator.isVisible && enabled, - onBack = onBack - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt index 9bdb82e44..add857bf7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt @@ -7,54 +7,53 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import com.lalilu.component.base.BottomSheetNavigator import com.lalilu.component.base.BottomSheetNavigatorLayout import com.lalilu.component.base.LocalWindowSize +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lmusic.compose.component.navigate.NavigationSheetContent import com.lalilu.lmusic.compose.new_screen.HomeScreen @OptIn(ExperimentalMaterialApi::class) -object NavigationWrapper { - var navigator: BottomSheetNavigator? by mutableStateOf(null) - private set +@Composable +fun NavigationWrapper( + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {} +) { + val windowSizeClass = LocalWindowSize.current - @Composable - fun Content( - modifier: Modifier = Modifier, - content: @Composable () -> Unit = {} - ) { - val windowSizeClass = LocalWindowSize.current - val animateSpec = remember { - tween( - durationMillis = 150, - easing = CubicBezierEasing(0.1f, 0.16f, 0f, 1f) - ) - } - BottomSheetNavigatorLayout( - modifier = modifier.fillMaxSize(), - defaultScreen = HomeScreen, - scrimColor = Color.Black.copy(alpha = 0.5f), - skipHalfExpanded = false, - sheetBackgroundColor = MaterialTheme.colors.background, - enableBottomSheetMode = { windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded }, - animationSpec = animateSpec, - sheetContent = { sheetNavigator -> - this@NavigationWrapper.navigator = sheetNavigator + BottomSheetNavigatorLayout( + modifier = modifier.fillMaxSize(), + defaultScreen = HomeScreen, + scrimColor = Color.Black.copy(alpha = 0.5f), + skipHalfExpanded = false, + sheetBackgroundColor = MaterialTheme.colors.background, + enableBottomSheetMode = { windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded }, + animationSpec = tween( + durationMillis = 200, + easing = CubicBezierEasing(0.1f, 0.16f, 0f, 1f) + ), + sheetContent = { sheetNavigator -> + LaunchedEffect(sheetNavigator) { + AppRouter.intentFlow.collect { intent -> + when (intent) { + NavIntent.Pop -> sheetNavigator.back() + is NavIntent.Push -> sheetNavigator.jump(intent.screen) + is NavIntent.Replace -> sheetNavigator.replace(intent.screen) + } + } + } - NavigationSheetContent( - modifier = modifier, - transitionKeyPrefix = "bottomSheet", - navigator = sheetNavigator - ) - }, - content = { content() } - ) - } + NavigationSheetContent( + modifier = modifier, + transitionKeyPrefix = "bottomSheet", + navigator = sheetNavigator + ) + }, + content = { content() } + ) } \ No newline at end of file diff --git a/component/build.gradle.kts b/component/build.gradle.kts index f5277cacd..7a20882e9 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { api("com.github.cy745:AnyPopDialog-Compose:jitpack-SNAPSHOT") api("me.rosuh:AndroidFilePicker:1.0.1") api("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha13") + api("com.github.cy745.KRouter:core:fcf40f4b15") // compose // api(platform(libs.compose.bom)) diff --git a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt new file mode 100644 index 000000000..81ed3c767 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -0,0 +1,45 @@ +package com.lalilu.component.navigation + +import cafe.adriel.voyager.core.screen.Screen +import com.zhangke.krouter.KRouter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +sealed interface NavIntent { + data class Push(val screen: Screen) : NavIntent + data class Replace(val screen: Screen) : NavIntent + data object Pop : NavIntent +} + +object AppRouter : CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.IO + + private val channel = Channel( + capacity = Int.MAX_VALUE, + onBufferOverflow = BufferOverflow.DROP_LATEST, + ) + + val intentFlow = channel.receiveAsFlow() + + fun intent(intent: NavIntent) = launch { + channel.send(intent) + } + + fun intent(block: AppRouter.() -> NavIntent?) = launch { + val i = this@AppRouter.block() ?: return@launch + channel.send(i) + } + + fun String.push(): NavIntent.Push? = KRouter + .route(this) + ?.let(NavIntent::Push) + + fun String.replace(): NavIntent.Replace? = KRouter + .route(this) + ?.let(NavIntent::Replace) +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt deleted file mode 100644 index 23d4f8f0b..000000000 --- a/component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.lalilu.component.navigation - -import cafe.adriel.voyager.core.screen.Screen - -interface GlobalNavigator { - - /** - * 跳转至某元素的详情页 - */ - fun goToDetailOf( - mediaId: String, - navigator: EnhanceNavigator? = null - ) - - /** - * 展示一些歌曲 - */ - fun showSongs( - mediaIds: List, - title: String? = null, - navigator: EnhanceNavigator? = null - ) - - /** - * 跳转至某页面 - * - * [screen] 目标页面 - * [singleTop] 是否替换栈顶的相同类型的页面 - * [navigator] 执行操作的导航器 - */ - fun navigateTo( - screen: Screen, - singleTop: Boolean = true, - navigator: EnhanceNavigator? = null - ) - - fun goBack( - navigator: EnhanceNavigator? = null - ) -} \ No newline at end of file From c83599d08dd496ec3ed0aad6c39ae3db7144ed4a Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 00:45:24 +0800 Subject: [PATCH 048/213] =?UTF-8?q?[fix]=E8=A7=A3=E5=86=B3BottomSheet?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E5=8A=A8=E7=94=BB=E8=BF=9B=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=8B=96=E5=8A=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/override/ModalBottomSheetLayout.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt index 8e36278d4..b740726de 100644 --- a/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt +++ b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max @@ -397,7 +398,8 @@ fun ModalBottomSheetLayout( remember(sheetState.anchoredDraggableState, orientation) { ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( state = sheetState.anchoredDraggableState, - orientation = orientation + orientation = orientation, + scope = scope ) } ) @@ -569,9 +571,15 @@ object ModalBottomSheetDefaults { @OptIn(ExperimentalMaterialApi::class) private fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( state: AnchoredDraggableState<*>, - orientation: Orientation + orientation: Orientation, + scope: CoroutineScope, ): NestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // 开始拖动时取消正在进行的动画 + if (source == NestedScrollSource.UserInput) { + scope.launch { state.anchoredDrag { } } + } + val delta = available.toFloat() return if (delta < 0 && source == NestedScrollSource.Drag) { state.dispatchRawDelta(delta).toOffset() From 143b79cae6da41333911f61781b35a9ae278bb0c Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 00:46:23 +0800 Subject: [PATCH 049/213] =?UTF-8?q?[refactor]=E8=B0=83=E6=95=B4BottomSheet?= =?UTF-8?q?=E5=9C=A8=E5=8D=8A=E5=B1=95=E5=BC=80=E7=9A=84=E6=83=85=E5=86=B5?= =?UTF-8?q?=E4=B8=8B=E7=9A=84=E8=BF=94=E5=9B=9E=E4=BA=8B=E4=BB=B6=E5=A4=84?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/lalilu/component/base/BottomSheetNavigator.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt index 98c9b3567..19d65686f 100644 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt @@ -100,7 +100,11 @@ fun BottomSheetNavigatorLayout( sheetGesturesEnabled = sheetGesturesEnabled, sheetContent = { BackHandler(enabled = bottomSheetNavigator.isVisible) { - bottomSheetNavigator.back() + if (sheetState.currentValue == ModalBottomSheetValue.Expanded) { + bottomSheetNavigator.back() + } else { + coroutineScope.launch { sheetState.hide() } + } } key("SheetContent") { sheetContent(bottomSheetNavigator) From c71fbb5bbf7040f7b8d3204105ef4442a606660e Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 00:51:05 +0800 Subject: [PATCH 050/213] =?UTF-8?q?[refactor]=E6=8B=86=E5=88=86=E5=AF=B9?= =?UTF-8?q?=E5=BA=94=E9=80=BB=E8=BE=91=E5=88=B0=E5=AF=B9=E5=BA=94=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E9=9C=80=E8=A6=81=E5=AF=B9=E5=BA=94=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=9A=84Screen=E8=87=AA=E8=A1=8C=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=EF=BC=8C=E9=81=B5=E5=BE=AA=E7=BB=84=E5=90=88=E5=A4=A7=E4=BA=8E?= =?UTF-8?q?=E7=BB=A7=E6=89=BF=E7=9A=84=E5=8E=9F=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/navigate/NavigationBar.kt | 56 +++++++--- .../component/navigate/NavigationSmartBar.kt | 10 +- .../com/lalilu/component/base/CustomScreen.kt | 100 +----------------- .../base/screen/ScreenActionFactory.kt | 13 +++ .../component/base/screen/ScreenBarFactory.kt | 64 +++++++++++ .../base/screen/ScreenExtraBarFactory.kt | 64 +++++++++++ .../base/screen/ScreenInfoFactory.kt | 16 +++ 7 files changed, 205 insertions(+), 118 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt create mode 100644 component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt create mode 100644 component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt create mode 100644 component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt index 6c41ec183..b954ee724 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt @@ -56,11 +56,14 @@ import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.LocalBottomSheetNavigator import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.TabScreen +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.extension.dayNightTextColor sealed class NavigationBarState { data class ForTabScreen(val tabScreens: List) : NavigationBarState() - data class ForScreen(val screen: DynamicScreen?) : NavigationBarState() + data class ForDynamicScreen(val screen: DynamicScreen?) : NavigationBarState() + data class ForScreen(val screen: Screen?) : NavigationBarState() } @Composable @@ -72,8 +75,9 @@ fun rememberNavigationBarState( derivedStateOf { when (val screen = currentScreen()) { is TabScreen -> NavigationBarState.ForTabScreen(tabScreens()) - is DynamicScreen -> NavigationBarState.ForScreen(screen) - else -> NavigationBarState.ForScreen(null) + is DynamicScreen -> NavigationBarState.ForDynamicScreen(screen) + null -> NavigationBarState.ForScreen(null) + else -> NavigationBarState.ForScreen(screen) } } } @@ -83,16 +87,16 @@ fun rememberNavigationBarState( fun rememberPreviousScreenTitleRes( stack: Stack?, currentScreen: Screen? -): State { +): Int { val previousScreen by remember(currentScreen) { - derivedStateOf { stack?.items?.getOrNull(stack.size - 2) as? CustomScreen } - } - val previousInfo by remember { - derivedStateOf { previousScreen?.getScreenInfo() } - } - return remember { - derivedStateOf { previousInfo?.title ?: R.string.bottom_sheet_navigate_back } + derivedStateOf { stack?.items?.getOrNull(stack.size - 2) } } + + return when (previousScreen) { + is CustomScreen -> (previousScreen as CustomScreen).getScreenInfo()?.title + is ScreenInfoFactory -> (previousScreen as ScreenInfoFactory).provideScreenInfo().title + else -> null + } ?: R.string.bottom_sheet_navigate_back } @@ -120,15 +124,34 @@ fun NavigationBar( ) } + is NavigationBarState.ForDynamicScreen -> { + val actions = state.screen + ?.registerActions() + ?: emptyList() + + val previousTitle = rememberPreviousScreenTitleRes( + stack = navigator, + currentScreen = currentScreen() + ) + + NavigateCommonBar( + previousTitle = { previousTitle }, + screenActions = { actions } + ) + } + is NavigationBarState.ForScreen -> { - val actions = state.screen?.registerActions() ?: emptyList() + val actions = (state.screen as? ScreenActionFactory) + ?.provideScreenActions() + ?: emptyList() + val previousTitle = rememberPreviousScreenTitleRes( stack = navigator, currentScreen = currentScreen() ) NavigateCommonBar( - previousTitle = { previousTitle.value }, + previousTitle = { previousTitle }, screenActions = { actions } ) } @@ -152,10 +175,13 @@ fun NavigateTabBar( horizontalArrangement = Arrangement.SpaceBetween ) { tabScreens().forEach { + val screenInfo = (it as? ScreenInfoFactory) + ?.provideScreenInfo() + NavigateItem( modifier = Modifier.weight(1f), - titleRes = { it.getScreenInfo().title }, - iconRes = { it.getScreenInfo().icon ?: R.drawable.ic_close_line }, + titleRes = { screenInfo?.title ?: R.string.bottom_sheet_navigate_back }, + iconRes = { screenInfo?.icon ?: R.drawable.ic_close_line }, isSelected = { currentScreen() === it }, onClick = { onSelectTab(it) } ) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt index e5c247508..ba5229666 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt @@ -18,8 +18,6 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -27,7 +25,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen -import com.lalilu.component.base.DynamicScreen +import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.component.base.screen.ScreenExtraBarFactory import com.lalilu.lmusic.utils.extension.measureHeight @Composable @@ -40,9 +39,8 @@ fun NavigationSmartBar( val density = LocalDensity.current val measureMainHeightState = remember { mutableStateOf(PaddingValues(0.dp)) } - val screen by remember { derivedStateOf { currentScreen() as? DynamicScreen } } - val mainContent by remember { derivedStateOf { runCatching { screen?.mainContentStack?.lastOrNull() }.getOrNull() } } - val extraContent by remember { derivedStateOf { runCatching { screen?.extraContentStack?.lastOrNull() }.getOrNull() } } + val mainContent = (currentScreen() as? ScreenBarFactory)?.content() + val extraContent = (currentScreen() as? ScreenExtraBarFactory)?.content() Column( modifier = modifier diff --git a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt index 7fd59044d..230d0b71e 100644 --- a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt +++ b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt @@ -1,21 +1,13 @@ package com.lalilu.component.base -import androidx.activity.compose.BackHandler import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.tab.Tab -import cafe.adriel.voyager.navigator.tab.TabOptions +import com.lalilu.component.base.screen.ScreenInfoFactory import kotlinx.coroutines.CoroutineScope /** @@ -71,25 +63,7 @@ interface CustomScreen : Screen { fun getScreenInfo(): ScreenInfo? = null } -interface TabScreen : CustomScreen, Tab { - override fun getScreenInfo(): ScreenInfo - - override val options: TabOptions - @Composable - get() { - val screenInfo = getScreenInfo() - val titleRes = screenInfo.title - val iconRes = screenInfo.icon - requireNotNull(iconRes) { "TabScreen's screenInfo must have nonNull iconRes." } - - return TabOptions( - index = UShort.MIN_VALUE, - title = stringResource(id = titleRes), - icon = painterResource(id = iconRes) - ) - } -} - +interface TabScreen : Screen, ScreenInfoFactory interface DialogScreen : CustomScreen @@ -101,79 +75,11 @@ interface UiPresenter : CoroutineScope { fun onAction(action: UiAction) } +@Deprecated("TODO 替换完成后删除") abstract class DynamicScreen : CustomScreen { - @delegate:Transient - var extraContentStack: List by mutableStateOf(emptyList()) - private set - - @delegate:Transient - var mainContentStack: List by mutableStateOf(emptyList()) - private set - @Composable open fun registerActions(): List { return remember { emptyList() } } - - @Composable - fun RegisterExtraContent( - isVisible: MutableState = remember { mutableStateOf(true) }, - showMask: () -> Boolean = { false }, - showBackground: () -> Boolean = { true }, - onBackPressed: (() -> Unit)? = null, - content: @Composable () -> Unit - ) { - LaunchedEffect(isVisible.value) { - if (isVisible.value) { - extraContentStack += ScreenBarComponent( - state = isVisible, - showMask = showMask(), - showBackground = showBackground(), - content = { - content.invoke() - - if (onBackPressed != null) { - BackHandler { - isVisible.value = false - onBackPressed() - } - } - } - ) - } else { - val key = isVisible.hashCode().toString() - extraContentStack = extraContentStack.filter { it.key != key } - } - } - } - - @Composable - fun RegisterMainContent( - isVisible: MutableState = remember { mutableStateOf(true) }, - showMask: () -> Boolean = { false }, - showBackground: () -> Boolean = { true }, - onBackPressed: () -> Unit = {}, - content: @Composable () -> Unit - ) { - LaunchedEffect(isVisible.value) { - if (isVisible.value) { - mainContentStack += ScreenBarComponent( - state = isVisible, - showMask = showMask(), - showBackground = showBackground(), - content = { - content.invoke() - BackHandler { - isVisible.value = false - onBackPressed() - } - } - ) - } else { - val key = isVisible.hashCode().toString() - mainContentStack = mainContentStack.filter { it.key != key } - } - } - } } diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt new file mode 100644 index 000000000..ccfd329b4 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt @@ -0,0 +1,13 @@ +package com.lalilu.component.base.screen + +import androidx.compose.runtime.Composable +import com.lalilu.component.base.ScreenAction + + +interface ScreenActionFactory { + + @Composable + fun provideScreenActions(): List { + return emptyList() + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt new file mode 100644 index 000000000..ef53548fc --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt @@ -0,0 +1,64 @@ +package com.lalilu.component.base.screen + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.lalilu.component.base.ScreenBarComponent + + +class ComponentStack { + var stack: List by mutableStateOf(emptyList()) + + companion object { + private val instanceMap = mutableStateMapOf() + + fun getInstance(attach: ScreenBarFactory): ComponentStack { + return instanceMap.getOrPut(attach) { ComponentStack() } + } + } +} + +interface ScreenBarFactory { + private val stack: ComponentStack + get() = ComponentStack.getInstance(this) + + @Composable + fun content(): ScreenBarComponent? { + return stack.stack.lastOrNull() + } + + @Composable + fun RegisterContent( + isVisible: MutableState, + onBackPressed: (() -> Unit)?, + content: @Composable () -> Unit + ) { + LaunchedEffect(isVisible.value) { + if (isVisible.value) { + stack.stack += ScreenBarComponent( + state = isVisible, + showMask = false, + showBackground = false, + content = { + content.invoke() + + if (onBackPressed != null) { + BackHandler { + isVisible.value = false + onBackPressed() + } + } + } + ) + } else { + val key = isVisible.hashCode().toString() + stack.stack = stack.stack.filter { it.key != key } + } + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt new file mode 100644 index 000000000..c9b21487b --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt @@ -0,0 +1,64 @@ +package com.lalilu.component.base.screen + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.lalilu.component.base.ScreenBarComponent + + +private class ExtraComponentStack { + var stack: List by mutableStateOf(emptyList()) + + companion object { + private val instanceMap = mutableStateMapOf() + + fun getInstance(attach: ScreenExtraBarFactory): ExtraComponentStack { + return instanceMap.getOrPut(attach) { ExtraComponentStack() } + } + } +} + +interface ScreenExtraBarFactory { + private val stack: ExtraComponentStack + get() = ExtraComponentStack.getInstance(this) + + @Composable + fun content(): ScreenBarComponent? { + return stack.stack.lastOrNull() + } + + @Composable + fun RegisterExtraContent( + isVisible: MutableState, + onBackPressed: (() -> Unit)?, + content: @Composable () -> Unit + ) { + LaunchedEffect(isVisible.value) { + if (isVisible.value) { + stack.stack += ScreenBarComponent( + state = isVisible, + showMask = false, + showBackground = false, + content = { + content.invoke() + + if (onBackPressed != null) { + BackHandler { + isVisible.value = false + onBackPressed() + } + } + } + ) + } else { + val key = isVisible.hashCode().toString() + stack.stack = stack.stack.filter { it.key != key } + } + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt new file mode 100644 index 000000000..155676ebd --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt @@ -0,0 +1,16 @@ +package com.lalilu.component.base.screen + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable + +data class ScreenInfo( + @StringRes val title: Int, + @DrawableRes val icon: Int? = null, +) + +interface ScreenInfoFactory { + + @Composable + fun provideScreenInfo(): ScreenInfo +} \ No newline at end of file From afe75fd76259d92a6c9bda9222597b7d59718a67 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 00:55:02 +0800 Subject: [PATCH 051/213] =?UTF-8?q?[refactor]=E4=BC=98=E5=8C=96=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E9=A1=B5=E7=BB=84=E4=BB=B6=E7=BB=93=E6=9E=84=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AE=8C=E5=85=A8Compose=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=9A=84SeekbarLayout2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/PlayingLayout.kt | 55 ++- .../compose/screen/playing/PlaylistLayout.kt | 19 +- .../compose/screen/playing/SeekbarLayout.kt | 4 +- .../compose/screen/playing/SeekbarLayout2.kt | 383 ++++++++++++++++++ 4 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 43b8df1d2..e96979efe 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio @@ -15,6 +16,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -33,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.dirror.lyricviewx.LyricUtil import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.lalilu.component.base.LocalBottomSheetNavigator import com.lalilu.component.extension.hideControl import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar @@ -46,13 +51,15 @@ import kotlinx.coroutines.flow.mapLatest import org.koin.compose.koinInject import kotlin.math.pow -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalMaterialApi::class) @Composable fun PlayingLayout( playingVM: PlayingViewModel = singleViewModel(), - settingsSp: SettingsSp = koinInject() + settingsSp: SettingsSp = koinInject(), ) { val haptic = LocalHapticFeedback.current + val navigator = LocalBottomSheetNavigator.current + val sheetState = navigator?.sheetState val systemUiController = rememberSystemUiController() val lyricLayoutLazyListState = rememberLazyListState() @@ -246,7 +253,12 @@ fun PlayingLayout( } }, playlistContent = { modifier -> - PlaylistLayout(modifier = modifier.clipToBounds()) + Surface(color = MaterialTheme.colors.background) { + PlaylistLayout( + modifier = modifier.clipToBounds(), + forceRefresh = { draggable.state.value != DragAnchor.Min } + ) + } }, overlayContent = { val animateProgress = animateFloatAsState( @@ -255,17 +267,42 @@ fun PlayingLayout( label = "" ) - SeekbarLayout( - modifier = Modifier + Box( + Modifier .align(Alignment.BottomCenter) .graphicsLayer { alpha = animateProgress.value / 100f translationY = (1f - animateProgress.value / 100f) * 500f + } + ) { + val duration = LPlayer.runtime.info.durationFlow.collectAsState() + val currentValue = LPlayer.runtime.info.positionFlow.collectAsState() + + SeekbarLayout2( + modifier = Modifier.hideControl(enable = { hideComponent.value }), + animateColor = { animateColor.value }, + onValueChange = { seekbarTime.longValue = it.toLong() }, + maxValue = { duration.value.toFloat() }, + dataValue = { currentValue.value.toFloat() }, + onDispatchDragOffset = { + sheetState?.anchoredDraggableState?.dispatchRawDelta(it) }, - seekBarModifier = Modifier.hideControl(enable = { hideComponent.value }), - onValueChange = { seekbarTime.longValue = it }, - animateColor = animateColor - ) + onDragStop = { result -> + if (result == -1) sheetState?.hide() + else sheetState?.anchoredDraggableState?.settle(0f) + }, + onSeekTo = { position -> + PlayerAction.SeekTo(position.toLong()).action() + }, + onClick = { clickPart -> + when (clickPart) { + ClickPart.Start -> PlayerAction.SkipToPrevious.action() + ClickPart.Middle -> PlayerAction.PlayOrPause.action() + ClickPart.End -> PlayerAction.SkipToNext.action() + } + } + ) + } } ) } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 425acbf8b..30a732f21 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -35,11 +36,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback +import cafe.adriel.voyager.core.screen.Screen import coil3.compose.AsyncImage import com.lalilu.common.base.Playable +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lplayer.LPlayer +import com.zhangke.krouter.KRouter import org.koin.compose.koinInject @@ -100,6 +104,7 @@ fun List>.diff( @Composable fun PlaylistLayout( modifier: Modifier = Modifier, + forceRefresh: () -> Boolean = { false }, playingVM: IPlayingViewModel = koinInject() ) { val haptic = LocalHapticFeedback.current @@ -130,7 +135,7 @@ fun PlaylistLayout( .any { it.key == item.key } } ?: false - if (isNewListTopVisible || isOldListTopVisible) { + if (isNewListTopVisible || isOldListTopVisible || forceRefresh()) { actualItems = emptyList() view.post { actualItems = newList } } else { @@ -140,7 +145,8 @@ fun PlaylistLayout( LazyColumn( state = listState, - modifier = modifier.fillMaxSize() + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 200.dp) ) { items( items = actualItems, @@ -155,7 +161,12 @@ fun PlaylistLayout( }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - GlobalNavigatorImpl.goToDetailOf(mediaId = item.data.mediaId) + + AppRouter.intent { + KRouter.route("/pages/detail") { + with("mediaId", item.data.mediaId) + }?.let(NavIntent::Push) + } } ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt index 68b14f325..6e86db34c 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt @@ -103,12 +103,12 @@ fun BoxScope.SeekbarLayout( OnSeekBarScrollToThresholdListener({ 300f }) { override fun onScrollToThreshold() { HapticUtils.haptic(this@apply) - NavigationWrapper.navigator?.show() +// NavigationWrapper.navigator?.show() } override fun onScrollRecover() { HapticUtils.haptic(this@apply) - NavigationWrapper.navigator?.hide() +// NavigationWrapper.navigator?.hide() } }) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt new file mode 100644 index 000000000..ae0a3499f --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt @@ -0,0 +1,383 @@ +package com.lalilu.lmusic.compose.screen.playing + +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberDraggable2DState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.blankj.utilcode.util.TimeUtils +import com.lalilu.lmusic.utils.extension.durationToTime +import kotlinx.coroutines.launch + +sealed class SeekbarVerticalState { + data object ProgressBar : SeekbarVerticalState() + data object Cancel : SeekbarVerticalState() + data object Dispatcher : SeekbarVerticalState() +} + +sealed class SeekbarHorizontalState { + data object Idle : SeekbarHorizontalState() + data object Follow : SeekbarHorizontalState() +} + +sealed interface ClickPart { + data object Start : ClickPart + data object Middle : ClickPart + data object End : ClickPart +} + +@Preview +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SeekbarLayout2( + modifier: Modifier = Modifier, + minValue: () -> Float = { 0f }, + maxValue: () -> Float = { 0f }, + dataValue: () -> Float = { 0f }, + animateColor: () -> Color = { Color.DarkGray }, + onDragStart: suspend (Offset) -> Unit = {}, + onDragStop: suspend (Int) -> Unit = {}, + onDispatchDragOffset: (Float) -> Unit = {}, + onValueChange: (Float) -> Unit = {}, + onSeekTo: (Float) -> Unit = {}, + onClick: (ClickPart) -> Unit = {} +) { + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + + val scrollSensitivity = remember { 1.3f } + val scrollThreadHold = remember { 200f } + val seekbarPaddingBottom = remember { density.run { 156.dp.toPx() } } + + val currentValue = remember { mutableFloatStateOf(0f) } + val seekbarOffsetY = remember { mutableFloatStateOf(0f) } + var boxSize by remember { mutableStateOf(IntSize.Zero) } + + val seekbarVerticalState = + remember { mutableStateOf(SeekbarVerticalState.ProgressBar) } + val seekbarHorizontalState = + remember { mutableStateOf(SeekbarHorizontalState.Idle) } + + val moved = remember { mutableStateOf(false) } + val isTouching = remember { mutableStateOf(false) } + val isCanceled = remember { + derivedStateOf { seekbarVerticalState.value != SeekbarVerticalState.ProgressBar } + } + + val resultValue = remember { + derivedStateOf { + val value = if (isTouching.value && !isCanceled.value) currentValue.floatValue + else dataValue() + + value.coerceIn(minValue(), maxValue()) + } + } + + // 使值的变化平滑 + val animateValue = animateFloatAsState( + targetValue = resultValue.value, + visibilityThreshold = 0.005f, + animationSpec = if (isTouching.value && !isCanceled.value) snap() else spring(stiffness = Spring.StiffnessLow), + label = "" + ) + + val draggableState = rememberDraggable2DState { offset -> + val oldState = seekbarVerticalState.value + val deltaY = offset.y + val deltaX = offset.x + + seekbarOffsetY.floatValue += deltaY + currentValue.floatValue = if (isCanceled.value) { + animateValue.value + } else { + (currentValue.floatValue + deltaX / boxSize.width * (maxValue() - minValue()) * scrollSensitivity) + .coerceIn(minValue(), maxValue()) + } + + when { + seekbarOffsetY.floatValue < -200f -> seekbarVerticalState.value = + SeekbarVerticalState.Dispatcher + + seekbarOffsetY.floatValue < -100f -> seekbarVerticalState.value = + SeekbarVerticalState.Cancel + + else -> seekbarVerticalState.value = SeekbarVerticalState.ProgressBar + } + + // 当状态发生变化的时候,进行震动 + if (oldState != seekbarVerticalState.value) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + + when (oldState) { + seekbarVerticalState.value -> {} + SeekbarVerticalState.Dispatcher -> scope.launch { onDragStop(-1) } + + SeekbarVerticalState.Cancel -> when (seekbarVerticalState.value) { + SeekbarVerticalState.Dispatcher -> { + val animationState = AnimationState( + initialValue = 0f, + initialVelocity = 100f, + ) + scope.launch { + var lastValue = 0f + animationState.animateTo(scrollThreadHold + seekbarPaddingBottom) { + val dt = value - lastValue + lastValue = value + onDispatchDragOffset(-dt) + } + } + } + + else -> {} + } + + else -> {} + } + + if (seekbarVerticalState.value == SeekbarVerticalState.Dispatcher) { + onDispatchDragOffset(deltaY) + } + } + + + + Box( + modifier = modifier + .padding(bottom = 100.dp) + .fillMaxWidth(0.7f) + .wrapContentHeight() + .onPlaced { boxSize = it.size } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + + when (event.type) { + PointerEventType.Press -> { + // 开始触摸时,将当前可见的进度值记录下来 + currentValue.floatValue = animateValue.value + isTouching.value = true + moved.value = false + } + + PointerEventType.Release -> { + if (moved.value && !isCanceled.value) { + onSeekTo(currentValue.floatValue) + } + isTouching.value = false + } + } + } + } + } + .pointerInput(Unit) { + detectTapGestures { position -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + + val clickPart = when (position.x) { + in 0f..(boxSize.width / 3f) -> ClickPart.Start + in (boxSize.width * 2 / 3f)..boxSize.width.toFloat() -> ClickPart.End + else -> ClickPart.Middle + } + onClick(clickPart) + } + } + .combineDetectDrag( + onLongClickStart = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStart = { + moved.value = true + seekbarVerticalState.value = SeekbarVerticalState.ProgressBar + seekbarHorizontalState.value = SeekbarHorizontalState.Follow + + seekbarOffsetY.floatValue = it.y + scope.launch { onDragStart(it) } + }, + onDragEnd = { + seekbarVerticalState.value = SeekbarVerticalState.ProgressBar + seekbarHorizontalState.value = SeekbarHorizontalState.Idle + + scope.launch { onDragStop(0) } + }, + onDrag = { change, dragAmount -> + draggableState.dispatchRawDelta(dragAmount) + } + ) + ) { + val textMeasurer = rememberTextMeasurer() + val bgColor = MaterialTheme.colors.background + val alpha = animateFloatAsState( + targetValue = if (isTouching.value) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "" + ) + + Box( + modifier = Modifier + .height(56.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .drawWithCache { + val innerPath = Path() + val currentValueText = animateValue.value + .toLong() + .durationToTime() + val maxValueText = maxValue() + .toLong() + .durationToTime() + val currentValueTextResult = textMeasurer.measure(currentValueText) + val maxValueTextResult = textMeasurer.measure(maxValueText) + + onDrawBehind { + val maxPadding = 4.dp.toPx() + val paddingAnimate = maxPadding * alpha.value + + val innerRadius = 16.dp.toPx() - paddingAnimate + val innerHeight = size.height - (paddingAnimate * 2f) + val innerWidth = size.width - (paddingAnimate * 2f) + + innerPath.reset() + innerPath.addRoundRect( + RoundRect( + rect = Rect( + offset = Offset(x = paddingAnimate, y = paddingAnimate), + size = Size(width = innerWidth, height = innerHeight) + ), + cornerRadius = CornerRadius(innerRadius, innerRadius) + ) + ) + + drawRect(color = bgColor, alpha = alpha.value) + + clipPath(innerPath) { + drawRect(color = Color(100, 100, 100, 50)) + + drawText( + textLayoutResult = currentValueTextResult, + topLeft = Offset( + x = size.width - currentValueTextResult.size.width, + y = 0f + ) + ) + + val actualValue = animateValue.value + val actualProgress = actualValue.normalize(minValue(), maxValue()) + onValueChange(actualValue) + + drawRoundRect( + color = animateColor(), + cornerRadius = CornerRadius(innerRadius, innerRadius), + topLeft = Offset(x = paddingAnimate, y = paddingAnimate), + size = Size( + width = innerWidth * actualProgress, + height = innerHeight + ) + ) + + drawRoundRect( + color = Color.White, + alpha = alpha.value, + cornerRadius = CornerRadius(50f), + topLeft = Offset( + x = innerWidth * actualProgress + paddingAnimate - 8.dp.toPx(), + y = (size.height - (innerHeight * 0.5f)) / 2f + ), + size = Size( + width = 4.dp.toPx(), + height = innerHeight * 0.5f + ) + ) + } + } + } + ) { + + } + } +} + +@Composable +fun Modifier.combineDetectDrag( + key: Any = Unit, + onDragStart: (Offset) -> Unit = { }, + onDragEnd: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, + onLongClickStart: () -> Unit = {} +) = this.then( + Modifier + .pointerInput(key) { + detectDragGestures(onDragStart, onDragEnd, onDragEnd, onDrag) + } + .pointerInput(key) { + detectDragGesturesAfterLongPress( + onDragStart = { + onLongClickStart() + onDragStart(it) + }, + onDragEnd = onDragEnd, + onDragCancel = onDragEnd, + onDrag = onDrag + ) + } +) + + +private fun Float.normalize(minValue: Float, maxValue: Float): Float { + val min = minOf(minValue, maxValue) + val max = maxOf(minValue, maxValue) + + if (min == max) return 0f + if (this <= min) return 0f + if (this >= max) return 1f + + return ((this - min) / (max - min)) + .coerceIn(0f, 1f) +} \ No newline at end of file From 4a13e313706e836162d561341a862da1696ad164 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 02:29:31 +0800 Subject: [PATCH 052/213] =?UTF-8?q?[refactor]=E6=9B=BF=E6=8D=A2=E4=BD=BF?= =?UTF-8?q?=E7=94=A8AppRouter=E5=8A=A0KRouter=E7=9A=84=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E6=8B=86=E5=88=86=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=83=A8=E5=88=86=E9=A1=B5=E9=9D=A2=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/LayoutWrapper.kt | 2 +- .../lmusic/compose/new_screen/HomeScreen.kt | 21 ++- .../compose/new_screen/SearchLyricScreen.kt | 24 ++- .../lmusic/compose/new_screen/SearchScreen.kt | 49 ++++-- .../lmusic/compose/new_screen/SongsScreen.kt | 24 +-- .../new_screen/detail/SongActionsCard.kt | 15 +- .../new_screen/detail/SongAlbumInfoCard.kt | 14 +- .../new_screen/detail/SongArtistsRow.kt | 10 +- .../new_screen/detail/SongDetailScreen.kt | 22 ++- .../presenter/SearchLyricScreenPresenter.kt | 5 +- .../compose/screen/playing/PlaylistLayout.kt | 2 +- .../lalilu/lmusic/extension/DailyRecommend.kt | 28 ++-- .../com/lalilu/lmusic/extension/EntryPanel.kt | 26 +-- .../lalilu/lmusic/extension/HistoryPanel.kt | 11 +- .../lalilu/lmusic/extension/LatestPanel.kt | 15 +- .../main/java/com/lalilu/component/Songs.kt | 41 +++-- .../com/lalilu/lalbum/screen/AlbumsScreen.kt | 10 +- .../lartist/screen/ArtistDetailScreen.kt | 10 +- .../lalilu/lartist/screen/ArtistsScreen.kt | 8 +- .../com/lalilu/lplaylist/PlaylistActions.kt | 21 +-- .../lplaylist/screen/PlaylistAddToScreen.kt | 13 +- .../screen/PlaylistCreateOrEditScreen.kt | 9 +- .../lplaylist/screen/PlaylistDetailScreen.kt | 9 +- .../lalilu/lplaylist/screen/PlaylistScreen.kt | 150 ++++++++++++++++-- 24 files changed, 383 insertions(+), 156 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt index f11d2a730..64c28183b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt @@ -21,7 +21,7 @@ object LayoutWrapper { derivedStateOf { configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } } - NavigationWrapper.Content { PlayingLayout() } + NavigationWrapper { PlayingLayout() } if (isLandscape) { ShowScreen() diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt index 1e440a2b2..ba77a48d9 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt @@ -7,11 +7,12 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.TabScreen import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.extension.EntryPanel @@ -21,12 +22,18 @@ import com.lalilu.lmusic.extension.latestPanel import com.lalilu.lmusic.viewmodel.HistoryViewModel import com.lalilu.lmusic.viewmodel.LibraryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.zhangke.krouter.annotation.Destination -object HomeScreen : DynamicScreen(), TabScreen { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_home, - icon = R.drawable.ic_loader_line - ) +@Destination("/pages/home", type = Screen::class) +object HomeScreen : TabScreen, Screen { + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = R.string.screen_title_home, + icon = R.drawable.ic_loader_line + ) + } @Composable override fun Content() { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt index 33d7aba52..726e5662c 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -30,6 +32,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade @@ -37,11 +40,12 @@ import coil3.request.error import coil3.request.placeholder import com.lalilu.R import com.lalilu.lmusic.api.lrcshare.SongResult -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.LLazyColumn import com.lalilu.lmusic.compose.component.card.SearchInputBar import com.lalilu.component.base.NavigatorHeader +import com.lalilu.component.base.screen.ScreenExtraBarFactory +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.lmusic.compose.presenter.SearchLyricAction import com.lalilu.lmusic.compose.presenter.SearchLyricPresenter import com.lalilu.lmusic.compose.presenter.SearchLyricState @@ -52,11 +56,14 @@ import com.lalilu.lmusic.viewmodel.SearchLyricViewModel data class SearchLyricScreen( private val mediaId: String, private val keywords: String? = null -) : DynamicScreen() { +) : Screen, ScreenInfoFactory, ScreenExtraBarFactory { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.preference_lyric_settings - ) + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = R.string.preference_lyric_settings + ) + } @Composable override fun Content() { @@ -71,7 +78,10 @@ data class SearchLyricScreen( state.onAction(SearchLyricAction.SearchFor(keywords)) } - RegisterExtraContent { + RegisterExtraContent( + isVisible = remember { mutableStateOf(true) }, + onBackPressed = null + ) { SearchInputBar( value = keywords ?: "", onSearchFor = { SearchLyricPresenter.onAction(SearchLyricAction.SearchFor(it)) }, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt index 6a4d72a83..dfede0d14 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt @@ -19,38 +19,51 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.KeyboardUtils import com.lalilu.R import com.lalilu.component.Songs +import com.lalilu.component.base.TabScreen +import com.lalilu.component.base.screen.ScreenExtraBarFactory +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.extension.singleViewModel +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent +import com.lalilu.lartist.component.ArtistCard import com.lalilu.lmedia.entity.LArtist import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.GlobalNavigatorImpl -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.ScreenInfo -import com.lalilu.component.base.TabScreen import com.lalilu.lmusic.compose.component.base.SearchInputBar -import com.lalilu.lartist.component.ArtistCard import com.lalilu.lmusic.compose.component.card.RecommendCardForAlbum import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.utils.extension.getActivity -import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchViewModel +import com.zhangke.krouter.annotation.Destination -object SearchScreen : DynamicScreen(), TabScreen { +@Destination("/pages/search", type = Screen::class) +object SearchScreen : Screen, TabScreen, ScreenExtraBarFactory { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_search, - icon = R.drawable.ic_search_2_line - ) + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = R.string.screen_title_search, + icon = R.drawable.ic_search_2_line + ) + } @Composable override fun Content() { - RegisterExtraContent { SearchBar() } + RegisterExtraContent( + isVisible = remember { mutableStateOf(true) }, + onBackPressed = null, + content = { SearchBar() } + ) SearchScreen() } @@ -72,7 +85,7 @@ fun SearchBar( ExperimentalMaterialApi::class ) @Composable -private fun DynamicScreen.SearchScreen( +private fun Screen.SearchScreen( playingVM: PlayingViewModel = singleViewModel(), searchVM: SearchViewModel = singleViewModel(), ) { @@ -95,10 +108,12 @@ private fun DynamicScreen.SearchScreen( title = "歌曲", onClick = { if (searchVM.songsResult.value.isNotEmpty()) { - GlobalNavigatorImpl.showSongs( - title = "[${searchVM.keyword.value}]\n歌曲搜索结果", - mediaIds = searchVM.songsResult.value.map { it.mediaId } - ) + AppRouter.intent(NavIntent.Push( + SongsScreen( + title = "[${searchVM.keyword.value}]\n歌曲搜索结果", + mediaIds = searchVM.songsResult.value.map { it.mediaId } + ) + )) } } ) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongsScreen.kt index ecf3648ee..f212b478f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongsScreen.kt @@ -13,13 +13,16 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R import com.lalilu.component.Songs import com.lalilu.component.SongsScreenModel -import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenAction -import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.extension.LazyListScrollToHelper import com.lalilu.component.extension.SelectAction import com.lalilu.component.extension.rememberLazyListScrollToHelper @@ -34,12 +37,7 @@ import com.lalilu.lplaylist.PlaylistActions data class SongsScreen( private val title: String? = null, private val mediaIds: List = emptyList() -) : DynamicScreen() { - - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_songs, - icon = R.drawable.ic_music_2_line - ) +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { @Transient private var scrollHelper: LazyListScrollToHelper? = null @@ -48,7 +46,15 @@ data class SongsScreen( private var songsSM: SongsScreenModel? = null @Composable - override fun registerActions(): List { + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = R.string.screen_title_songs, + icon = R.drawable.ic_music_2_line + ) + } + + @Composable + override fun provideScreenActions(): List { val playingVM: PlayingViewModel = singleViewModel() return remember { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt index 369cda698..d034a1455 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt @@ -19,18 +19,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.lalilu.component.IconTextButton -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lmedia.entity.LSong import com.lalilu.lmusic.compose.new_screen.SearchLyricScreen import com.lalilu.lmusic.utils.extension.checkActivityIsExist -import org.koin.compose.koinInject @Composable fun SongActionsCard( modifier: Modifier = Modifier, song: LSong, ) { - val navigator: GlobalNavigator = koinInject() val context = LocalContext.current val intent = remember(song) { Intent().apply { @@ -85,10 +84,12 @@ fun SongActionsCard( shape = RoundedCornerShape(10.dp), color = Color(0xFF3EA22C), onClick = { - navigator.navigateTo( - SearchLyricScreen( - mediaId = song.id, - keywords = song.name + AppRouter.intent( + NavIntent.Push( + SearchLyricScreen( + mediaId = song.id, + keywords = song.name + ) ) ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt index 0a65cb7af..109879ede 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt @@ -14,11 +14,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lalbum.screen.AlbumDetailScreen import com.lalilu.lmedia.entity.LAlbum import com.lalilu.lmusic.compose.component.card.RecommendCardCover -import org.koin.compose.koinInject @OptIn(ExperimentalMaterialApi::class) @@ -27,12 +27,16 @@ fun SongAlbumInfoCard( modifier: Modifier = Modifier, album: LAlbum, ) { - val navigator: GlobalNavigator = koinInject() - Surface( modifier = modifier, shape = RoundedCornerShape(20.dp), - onClick = { navigator.navigateTo(AlbumDetailScreen(albumId = album.id)) } + onClick = { + AppRouter.intent( + NavIntent.Push( + AlbumDetailScreen(albumId = album.id) + ) + ) + } ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt index eaa5884e1..934948c1a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt @@ -14,10 +14,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lartist.screen.ArtistDetailScreen import com.lalilu.lmedia.entity.LArtist -import org.koin.compose.koinInject @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) @Composable @@ -25,8 +25,6 @@ fun SongArtistsRow( modifier: Modifier = Modifier, artists: Set ) { - val navigator: GlobalNavigator = koinInject() - FlowRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -34,7 +32,9 @@ fun SongArtistsRow( artists.forEach { Chip( onClick = { - navigator.navigateTo(ArtistDetailScreen(artistName = it.name)) + AppRouter.intent( + NavIntent.Push(ArtistDetailScreen(artistName = it.name)) + ) }, colors = ChipDefaults.outlinedChipColors(), ) { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index 86f8ba2c1..7db4a2310 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -21,13 +21,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import com.lalilu.R import com.lalilu.component.IconButton -import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.LocalBottomSheetNavigator import com.lalilu.component.base.ScreenAction -import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.override.ModalBottomSheetValue import com.lalilu.lmedia.LMedia @@ -37,18 +39,22 @@ import com.lalilu.lmusic.compose.presenter.DetailScreenAction import com.lalilu.lmusic.compose.presenter.DetailScreenIsPlayingPresenter import com.lalilu.lmusic.compose.presenter.DetailScreenLikeBtnPresenter import com.lalilu.lplayer.extensions.QueueAction +import com.zhangke.krouter.annotation.Destination +import com.zhangke.krouter.annotation.Param +@Destination("/song/detail", type = Screen::class) data class SongDetailScreen( - private val mediaId: String -) : DynamicScreen() { + @Param val mediaId: String +) : Screen, ScreenActionFactory, ScreenInfoFactory { override val key: ScreenKey = "${super.key}:$mediaId" - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_song_detail - ) + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo(title = R.string.screen_title_song_detail) + } @Composable - override fun registerActions(): List { + override fun provideScreenActions(): List { return remember { listOf( ScreenAction.StaticAction( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/presenter/SearchLyricScreenPresenter.kt b/app/src/main/java/com/lalilu/lmusic/compose/presenter/SearchLyricScreenPresenter.kt index 0f0aa7385..6a2ce1c51 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/presenter/SearchLyricScreenPresenter.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/presenter/SearchLyricScreenPresenter.kt @@ -5,10 +5,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.lalilu.lmusic.compose.NavigationWrapper import com.lalilu.component.base.UiAction import com.lalilu.component.base.UiPresenter import com.lalilu.component.base.UiState +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lmusic.viewmodel.SearchLyricViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -58,7 +59,7 @@ object SearchLyricPresenter : UiPresenter { is SearchLyricAction.SaveFor -> { vm.saveLyricInto(lyricId = selectedId, mediaId = mediaId) { - launch { NavigationWrapper.navigator?.pop() } + launch { AppRouter.intent(NavIntent.Pop) } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 30a732f21..3c3a8e689 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -163,7 +163,7 @@ fun PlaylistLayout( haptic.performHapticFeedback(HapticFeedbackType.LongPress) AppRouter.intent { - KRouter.route("/pages/detail") { + KRouter.route("/song/detail") { with("mediaId", item.data.mediaId) }?.let(NavIntent::Push) } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index ebdf12131..be5b3f927 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -23,12 +23,16 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.base.LocalWindowSize -import com.lalilu.lmusic.GlobalNavigatorImpl +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lmusic.compose.component.card.RecommendCard2 import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle +import com.lalilu.lmusic.compose.new_screen.SongsScreen import com.lalilu.lmusic.viewmodel.LibraryViewModel +import com.zhangke.krouter.KRouter @OptIn(ExperimentalMaterialApi::class) fun LazyListScope.dailyRecommend( @@ -40,7 +44,7 @@ fun LazyListScope.dailyRecommend( title = "每日推荐", onClick = { val ids = libraryVM.dailyRecommends.value.map { it.mediaId } - GlobalNavigatorImpl.showSongs(ids) + AppRouter.intent(NavIntent.Push(SongsScreen(mediaIds = ids))) } ) { Chip(onClick = { libraryVM.forceUpdate() }) { @@ -66,7 +70,13 @@ fun LazyListScope.dailyRecommend( RecommendCard2( item = { it }, modifier = Modifier.size(width = 250.dp, height = 250.dp), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + onClick = { + AppRouter.intent { + KRouter.route("/song/detail") { + with("mediaId", it.id) + }?.let(NavIntent::Push) + } + } ) } } @@ -92,7 +102,7 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { modifier = Modifier .fillMaxHeight() .weight(1f), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + onClick = { AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } ) } @@ -102,7 +112,7 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { modifier = Modifier .width(150.dp) .fillMaxHeight(), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + onClick = { AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } ) } @@ -112,7 +122,7 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { modifier = Modifier .width(150.dp) .fillMaxHeight(), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + onClick = { AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } ) } } @@ -136,7 +146,7 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { modifier = Modifier .fillMaxHeight() .weight(1f), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + onClick = { AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } ) } @@ -154,7 +164,7 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { .fillMaxWidth() .weight(1f), onClick = { - GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) + AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } ) } @@ -166,7 +176,7 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { .fillMaxWidth() .weight(1f), onClick = { - GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) + AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } ) } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt index 2cd8da87f..aa9be941c 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt @@ -18,8 +18,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.lalilu.component.base.DynamicScreen +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lalbum.screen.AlbumsScreen import com.lalilu.lartist.screen.ArtistsScreen import com.lalilu.ldictionary.screen.DictionaryScreen @@ -27,12 +30,9 @@ import com.lalilu.lhistory.screen.HistoryScreen import com.lalilu.lmusic.compose.new_screen.SettingsScreen import com.lalilu.lmusic.compose.new_screen.SongsScreen import com.lalilu.lplaylist.screen.PlaylistScreen -import org.koin.compose.koinInject @Composable fun EntryPanel() { - val navigator: GlobalNavigator = koinInject() - val screenEntry = remember { listOf( SongsScreen(), @@ -51,26 +51,34 @@ fun EntryPanel() { ) { Column { for (entry in screenEntry) { - val info = entry.getScreenInfo() ?: continue + val (icon, title) = when (entry) { + is DynamicScreen -> entry.getScreenInfo()?.let { it.icon to it.title } + is ScreenInfoFactory -> entry.provideScreenInfo().let { it.icon to it.title } + else -> null + } ?: continue Row( modifier = Modifier .fillMaxWidth() - .clickable { navigator.navigateTo(entry) } + .clickable { + AppRouter.intent( + NavIntent.Push(entry) + ) + } .padding(horizontal = 20.dp, vertical = 15.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(20.dp) ) { - info.icon?.let { icon -> + icon?.let { icon -> Icon( painter = painterResource(id = icon), - contentDescription = stringResource(id = info.title), + contentDescription = stringResource(id = title), tint = dayNightTextColor(0.7f) ) } Text( - text = stringResource(id = info.title), + text = stringResource(id = title), color = dayNightTextColor(0.6f), style = MaterialTheme.typography.subtitle2 ) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt index 96f6e268a..3065bae1e 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt @@ -12,13 +12,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen import com.lalilu.common.base.Playable import com.lalilu.component.card.SongCard +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.GlobalNavigatorImpl import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.HistoryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.zhangke.krouter.KRouter @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) fun LazyListScope.historyPanel( @@ -67,7 +70,11 @@ fun LazyListScope.historyPanel( }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - GlobalNavigatorImpl.goToDetailOf(mediaId = item.id) + AppRouter.intent { + KRouter.route("/song/detail") { + with("mediaId", item.mediaId) + }?.let(NavIntent::Push) + } } ) } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt index 4b99cc52d..df6e2541a 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt @@ -8,13 +8,16 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen import com.lalilu.common.base.Playable -import com.lalilu.lmusic.GlobalNavigatorImpl +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lmusic.compose.component.card.RecommendCard import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.LibraryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.zhangke.krouter.KRouter @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @@ -44,8 +47,14 @@ fun LazyListScope.latestPanel( item = { it }, width = { 100.dp }, height = { 100.dp }, - modifier = Modifier.animateItemPlacement(), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) }, + modifier = Modifier.animateItem(), + onClick = { + AppRouter.intent { + KRouter.route("/song/detail") { + with("mediaId", it.mediaId) + }?.let(NavIntent::Push) + } + }, isPlaying = { playingVM.isItemPlaying(it.id, Playable::mediaId) }, onClickButton = { playingVM.play( diff --git a/component/src/main/java/com/lalilu/component/Songs.kt b/component/src/main/java/com/lalilu/component/Songs.kt index ee1ffa72d..666a5295c 100644 --- a/component/src/main/java/com/lalilu/component/Songs.kt +++ b/component/src/main/java/com/lalilu/component/Songs.kt @@ -51,10 +51,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ToastUtils import com.lalilu.common.base.BaseSp import com.lalilu.common.base.Playable -import com.lalilu.component.base.DynamicScreen +import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.extension.DialogItem import com.lalilu.component.extension.DialogWrapper import com.lalilu.component.extension.ItemSelectHelper @@ -64,14 +65,15 @@ import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.extension.rememberItemSelectHelper import com.lalilu.component.extension.rememberLazyListScrollToHelper import com.lalilu.component.extension.singleViewModel -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.component.viewmodel.SongsViewModel import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmedia.extension.Sortable -import org.koin.compose.koinInject +import com.zhangke.krouter.KRouter class SongsScreenModel : ScreenModel { val isFastJumping = mutableStateOf(false) @@ -98,7 +100,7 @@ fun DefaultEmptyContent() { } @Composable -fun DynamicScreen.Songs( +fun Screen.Songs( modifier: Modifier = Modifier, mediaIds: List, showAll: Boolean = false, @@ -115,7 +117,6 @@ fun DynamicScreen.Songs( headerContent: LazyListScope.(State>>) -> Unit = {}, footerContent: LazyListScope.(State>>) -> Unit = {} ) { - val navigator: GlobalNavigator = koinInject() val songsVM: SongsViewModel = singleViewModel() val playingVM: IPlayingViewModel = singleViewModel() val songsState = songsVM.output @@ -147,10 +148,12 @@ fun DynamicScreen.Songs( isVisible = songsSM.isFastJumping ) - registerSelectPanel( - selectActions = { selectActions { songsState.value.values.flatten() } }, - selector = selectorHelper - ) + if (this is ScreenBarFactory) { + registerSelectPanel( + selectActions = { selectActions { songsState.value.values.flatten() } }, + selector = selectorHelper + ) + } if (onDragMoveEnd != null) { ReorderableSongListWrapper( @@ -168,7 +171,13 @@ fun DynamicScreen.Songs( footerContent = { footerContent(songsState) }, emptyContent = emptyContent, prefixContent = { prefixContent(it, sortRuleStr) }, - onLongClickItem = { navigator.goToDetailOf(it.mediaId) }, + onLongClickItem = { + AppRouter.intent { + KRouter.route("/song/detail?mediaId=${it.mediaId}") { + with("mediaId", it.mediaId) + }?.let(NavIntent::Push) + } + }, onClickItem = { playingVM.play( mediaId = it.mediaId, @@ -201,7 +210,13 @@ fun DynamicScreen.Songs( footerContent = { footerContent(songsState) }, emptyContent = emptyContent, prefixContent = { prefixContent(it, sortRuleStr) }, - onLongClickItem = { navigator.goToDetailOf(it.mediaId) }, + onLongClickItem = { + AppRouter.intent { + KRouter.route("/song/detail?mediaId=${it.mediaId}") { + with("mediaId", it.mediaId) + }?.let(NavIntent::Push) + } + }, onClickItem = { playingVM.play( mediaId = it.mediaId, @@ -250,12 +265,12 @@ private fun registerSortPanel( @Composable -fun DynamicScreen.registerSelectPanel( +fun ScreenBarFactory.registerSelectPanel( modifier: Modifier = Modifier, selector: ItemSelectHelper = rememberItemSelectHelper(), selectActions: () -> List = { emptyList() } ) { - RegisterMainContent( + RegisterContent( isVisible = selector.isSelecting, onBackPressed = { selector.clear() } ) { diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index 6b2d48edc..79ea4cef5 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -25,7 +25,8 @@ import com.lalilu.component.base.LoadingScaffold import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.component.viewmodel.SongsSp import com.lalilu.lalbum.R @@ -88,7 +89,6 @@ private fun DynamicScreen.AlbumsScreen( ) { val albumsState = albumsSM.albums.collectAsLoadingState() val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val navigator = koinInject() LoadingScaffold( targetState = albumsState @@ -130,7 +130,11 @@ private fun DynamicScreen.AlbumsScreen( }, showTitle = { albumsSM.showTitle.value }, onClick = { - navigator.navigateTo(AlbumDetailScreen(item.id)) + AppRouter.intent( + NavIntent.Push( + AlbumDetailScreen(item.id) + ) + ) } ) } diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt index caae89fba..dbc279cf6 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt @@ -19,7 +19,8 @@ import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState import com.lalilu.component.extension.SelectAction -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lartist.R import com.lalilu.lartist.component.ArtistCard import com.lalilu.lmedia.LMedia @@ -28,7 +29,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch -import org.koin.compose.koinInject data class ArtistDetailScreen( private val artistName: String @@ -65,7 +65,6 @@ class ArtistDetailScreenModel : ScreenModel { private fun DynamicScreen.ArtistDetail( artistDetailSM: ArtistDetailScreenModel ) { - val navigator = koinInject() val artistState = artistDetailSM.artist.collectAsLoadingState() LoadingScaffold(targetState = artistState) { artist -> @@ -109,9 +108,8 @@ private fun DynamicScreen.ArtistDetail( ArtistCard( artist = it, onClick = { - navigator.navigateTo( - screen = ArtistDetailScreen(it.id), - singleTop = false + AppRouter.intent( + NavIntent.Push(ArtistDetailScreen(it.id)) ) } ) diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt index 76fef8eb4..82034741b 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt @@ -20,7 +20,8 @@ import com.lalilu.component.base.LoadingScaffold import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lartist.R import com.lalilu.lartist.component.ArtistCard @@ -73,7 +74,6 @@ private fun DynamicScreen.ArtistsScreen( artistsSM: ArtistsScreenModel, playingVM: IPlayingViewModel = koinInject() ) { - val navigator = koinInject() val artistsState = artistsSM.artists.collectAsLoadingState() LoadingScaffold( @@ -112,7 +112,9 @@ private fun DynamicScreen.ArtistsScreen( } }, onClick = { - navigator.navigateTo(ArtistDetailScreen(item.name)) + AppRouter.intent( + NavIntent.Push(ArtistDetailScreen(item.id)) + ) } ) } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt index e5204430a..aa06cdef5 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt @@ -4,7 +4,8 @@ import androidx.compose.ui.graphics.Color import com.blankj.utilcode.util.ToastUtils import com.lalilu.common.base.Playable import com.lalilu.component.extension.SelectAction -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository import com.lalilu.lplaylist.screen.PlaylistAddToScreen @@ -12,7 +13,6 @@ import org.koin.java.KoinJavaComponent import com.lalilu.component.R as componentR object PlaylistActions { - private val navigator: GlobalNavigator by KoinJavaComponent.inject(GlobalNavigator::class.java) private val playlistRepo: PlaylistRepository by KoinJavaComponent.inject(PlaylistRepository::class.java) /** @@ -26,13 +26,16 @@ object PlaylistActions { val mediaIds = selector.selected.value .mapNotNull { (it as? Playable)?.mediaId } - navigator.navigateTo( - PlaylistAddToScreen( - ids = mediaIds, - callback = { - selector.clear() - navigator.goBack() - } + AppRouter.intent( + NavIntent.Push( + PlaylistAddToScreen( + ids = mediaIds, + callback = { + selector.clear() + + AppRouter.intent(NavIntent.Pop) + } + ) ) ) } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt index e23b2ea48..b6f3d402b 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt @@ -24,7 +24,8 @@ import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.ScreenInfo import com.lalilu.component.extension.ItemSelectHelper import com.lalilu.component.extension.rememberItemSelectHelper -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lplaylist.R import com.lalilu.lplaylist.component.PlaylistCard import com.lalilu.lplaylist.entity.LPlaylist @@ -92,8 +93,6 @@ private fun DynamicScreen.PlaylistAddToScreen( selector: ItemSelectHelper, playlists: () -> List, ) { - val navigator = koinInject() - LLazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp) @@ -107,7 +106,13 @@ private fun DynamicScreen.PlaylistAddToScreen( subTitle = "[S: ${mediaIds.size}] -> [P: ${selector.selected.value.size}]" ) { IconButton( - onClick = { navigator.navigateTo(PlaylistCreateOrEditScreen()) } + onClick = { + AppRouter.intent( + NavIntent.Push( + PlaylistCreateOrEditScreen() + ) + ) + } ) { Icon( painter = painterResource(componentR.drawable.ic_add_line), diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt index 7f61bd024..3b663590f 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt @@ -48,7 +48,8 @@ import com.lalilu.component.base.ScreenAction import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.extension.rememberLazyListScrollToHelper import com.lalilu.component.extension.toMutableState -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lplaylist.R import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository @@ -61,7 +62,6 @@ import com.lalilu.component.R as componentR @OptIn(ExperimentalCoroutinesApi::class) class PlaylistCreateOrEditScreenModel( - private val navigator: GlobalNavigator, private val playlistRepo: PlaylistRepository ) : ScreenModel { private val playlistId = MutableStateFlow("") @@ -97,7 +97,8 @@ class PlaylistCreateOrEditScreenModel( mediaIds = emptyList() ) ) - navigator.goBack() + + AppRouter.intent(NavIntent.Pop) } } @@ -126,7 +127,7 @@ class PlaylistCreateOrEditScreenModel( mediaIds = playlist?.mediaIds ?: emptyList() ) ) - navigator.goBack() + AppRouter.intent(NavIntent.Pop) } } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt index e7035cb65..291b39cdd 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt @@ -29,14 +29,14 @@ import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState import com.lalilu.component.extension.SelectAction -import com.lalilu.component.navigation.GlobalNavigator +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lplaylist.PlaylistActions import com.lalilu.lplaylist.R import com.lalilu.lplaylist.repository.PlaylistRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import org.koin.compose.koinInject import com.lalilu.component.R as componentR data class PlaylistDetailScreen( @@ -147,7 +147,6 @@ private fun DynamicScreen.PlaylistDetailScreen( playlistId: String, playlistDetailSM: PlaylistDetailScreenModel, ) { - val navigator = koinInject() val playlistState = playlistDetailSM.playlist.collectAsLoadingState() LoadingScaffold(targetState = playlistState) { playlist -> @@ -189,8 +188,8 @@ private fun DynamicScreen.PlaylistDetailScreen( ) { IconButton( onClick = { - navigator.navigateTo( - PlaylistCreateOrEditScreen(targetPlaylistId = playlistId) + AppRouter.intent( + NavIntent.Push(PlaylistCreateOrEditScreen(targetPlaylistId = playlistId)) ) } ) { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index e1a6fcc89..012c812a8 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -1,14 +1,27 @@ package com.lalilu.lplaylist.screen import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -16,20 +29,29 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import com.blankj.utilcode.util.ToastUtils import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.DynamicScreen +import com.lalilu.component.LongClickableTextButton import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.TabScreen +import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.component.extension.SelectAction import com.lalilu.component.extension.rememberItemSelectHelper -import com.lalilu.component.navigation.GlobalNavigator -import com.lalilu.component.registerSelectPanel +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent import com.lalilu.lplaylist.PlaylistActions import com.lalilu.lplaylist.R import com.lalilu.lplaylist.component.PlaylistCard @@ -45,11 +67,15 @@ class PlaylistScreenModel : ScreenModel { val selectedItems = mutableStateOf>(emptyList()) } -data object PlaylistScreen : DynamicScreen(), TabScreen { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.playlist_screen_title, - icon = ComponentR.drawable.ic_play_list_fill - ) +data object PlaylistScreen : TabScreen, ScreenBarFactory { + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = R.string.playlist_screen_title, + icon = ComponentR.drawable.ic_play_list_fill + ) + } @Composable override fun Content() { @@ -59,10 +85,9 @@ data object PlaylistScreen : DynamicScreen(), TabScreen { @OptIn(ExperimentalFoundationApi::class) @Composable -private fun DynamicScreen.PlaylistScreen( +private fun Screen.PlaylistScreen( playlistSM: PlaylistScreenModel = rememberScreenModel { PlaylistScreenModel() }, playlistRepo: PlaylistRepository = koinInject(), - navigator: GlobalNavigator = koinInject() ) { val listState = rememberLazyListState() val playlists by remember { derivedStateOf { playlistRepo.getPlaylists() } } @@ -88,11 +113,92 @@ private fun DynamicScreen.PlaylistScreen( isSelecting = playlistSM.isSelecting, selected = playlistSM.selectedItems ) + val selectActions = remember { + listOf(PlaylistActions.removePlaylists) + } - registerSelectPanel( - selectActions = { listOf(PlaylistActions.removePlaylists) }, - selector = selectHelper - ) + if (this is ScreenBarFactory) { + RegisterContent( + isVisible = selectHelper.isSelecting, + onBackPressed = { selectHelper.clear() } + ) { + Row( + modifier = Modifier + .clickable(enabled = false) {} + .height(52.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(start = 16.dp, end = 24.dp), + colors = ButtonDefaults.textButtonColors( + backgroundColor = Color(0x2F006E7C), + contentColor = Color(0xFF006E7C) + ), + onClick = { selectHelper.clear() } + ) { + Image( + painter = painterResource(id = com.lalilu.component.R.drawable.ic_close_line), + contentDescription = "cancelButton", + colorFilter = ColorFilter.tint(color = Color(0xFF006E7C)) + ) + Text( + text = "取消 [${selectHelper.selected.value.size}]", + fontSize = 14.sp + ) + } + + LazyRow( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.End + ) { + items(items = selectActions) { + if (it is SelectAction.ComposeAction) { + it.content.invoke(selectHelper) + return@items + } + + if (it is SelectAction.StaticAction) { + LongClickableTextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = 20.dp), + colors = ButtonDefaults.textButtonColors( + backgroundColor = it.color.copy(alpha = 0.15f), + contentColor = it.color + ), + enableLongClickMask = it.forLongClick, + onLongClick = { if (it.forLongClick) it.onAction(selectHelper) }, + onClick = { + if (it.forLongClick) { + ToastUtils.showShort("请长按此按钮以继续") + } else { + it.onAction(selectHelper) + } + }, + ) { + it.icon?.let { icon -> + Image( + modifier = Modifier.size(20.dp), + painter = painterResource(id = icon), + contentDescription = stringResource(id = it.title), + colorFilter = ColorFilter.tint(color = it.color) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text( + text = stringResource(id = it.title), + fontSize = 14.sp + ) + } + } + } + } + } + } + } LLazyColumn( state = listState, @@ -108,7 +214,13 @@ private fun DynamicScreen.PlaylistScreen( title = stringResource(id = R.string.playlist_screen_title) ) { IconButton( - onClick = { navigator.navigateTo(PlaylistCreateOrEditScreen()) } + onClick = { + AppRouter.intent( + NavIntent.Push( + PlaylistCreateOrEditScreen() + ) + ) + } ) { Icon( painter = painterResource(ComponentR.drawable.ic_add_line), @@ -139,7 +251,11 @@ private fun DynamicScreen.PlaylistScreen( if (selectHelper.isSelecting()) { selectHelper.onSelect(playlist) } else { - navigator.navigateTo(PlaylistDetailScreen(playlistId = playlist.id)) + AppRouter.intent( + NavIntent.Push( + PlaylistDetailScreen(playlistId = playlist.id) + ) + ) } }, onLongClick = { selectHelper.onSelect(playlist) } From d73f97430cd01ceadf78489e19ea15d04a424e3f Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 02:32:43 +0800 Subject: [PATCH 053/213] =?UTF-8?q?[refactor]=E8=B0=83=E6=95=B4=E6=B7=B7?= =?UTF-8?q?=E6=B7=86=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=8F=AA=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E7=9B=AE=E6=A0=87=E7=B1=BB=E7=9A=84=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E6=9E=84=E9=80=A0=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/proguard-rules.pro | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 27b8434be..e9a039098 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -45,7 +45,8 @@ -dontwarn com.squareup.picasso.Picasso -dontwarn com.squareup.picasso.RequestCreator --keepnames @com.zhangke.krouter.annotation.Destination class * { *; } +# 针对KRouter,保留所需的类的构造函数 +-keepclassmembers @com.zhangke.krouter.annotation.Destination public class * { public (*); } # 墨 · 状态栏歌词 -keep class StatusBarLyric.API.StatusBarLyric { *; } From 19f7f9479f144bb093f94c693809cec1734968eb Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 02:33:21 +0800 Subject: [PATCH 054/213] =?UTF-8?q?[fix]=E8=A7=A3=E5=86=B3=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=A4=B1=E8=B4=A5=E5=90=8E=E6=97=A0=E6=B3=95=E5=86=8D?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=AF=B7=E6=B1=82=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/lalilu/lmusic/viewmodel/SearchLyricViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchLyricViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchLyricViewModel.kt index b2d95a1ba..da19b713f 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchLyricViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchLyricViewModel.kt @@ -86,6 +86,7 @@ class SearchLyricViewModel( ToastUtils.showShort("歌词保存成功") onSuccess() } catch (e: Exception) { + searchState.value = SearchState.Error ToastUtils.showShort(e.message) } } From 41660c617a831d2f0958bc2335841b8b72ca4be2 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 22 Jul 2024 02:34:07 +0800 Subject: [PATCH 055/213] =?UTF-8?q?[chore]flyjingfish-aop=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 26a92b55b..ae85ce978 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,7 @@ lifecycle_version = "2.6.2" navigation_version = "2.7.4" room_version = "2.5.2" media = "1.7.0" -flyjingfish-aop = "1.6.3" +flyjingfish-aop = "1.9.7" [libraries] # kotlin From 54b3d6d217d3ca09743037d7e1dbb020ce7cd22a Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 23 Jul 2024 01:09:49 +0800 Subject: [PATCH 056/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E8=BF=87=E6=B8=A1=E5=8A=A8=E7=94=BB=E6=95=88=E6=9E=9C?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E5=BF=AB=E9=80=9F=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E6=97=B6=E5=8A=A8=E7=94=BB=E4=B8=A2=E5=A4=B1=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/NavigationWrapper.kt | 1 - .../compose/component/CustomTransition.kt | 42 ++++++++++++------- .../navigate/NavigationSheetContent.kt | 29 ++++--------- .../component/base/BottomSheetNavigator.kt | 7 +++- 4 files changed, 41 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt index add857bf7..d4b9ff710 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt @@ -50,7 +50,6 @@ fun NavigationWrapper( NavigationSheetContent( modifier = modifier, - transitionKeyPrefix = "bottomSheet", navigator = sheetNavigator ) }, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt index c47f9ccbe..0ce80e8f8 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt @@ -1,39 +1,51 @@ package com.lalilu.lmusic.compose.component -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.transitions.ScreenTransition +@OptIn(ExperimentalVoyagerApi::class) @Composable fun CustomTransition( modifier: Modifier = Modifier, navigator: Navigator, - keyPrefix: String = "", - getScreenFrom: (Navigator) -> Screen = { navigator.lastItem }, + animationSpec: FiniteAnimationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ), content: @Composable (AnimatedVisibilityScope.(Screen) -> Unit) = { it.Content() } ) { - AnimatedContent( + ScreenTransition( + navigator = navigator, modifier = modifier, - contentKey = { it.key }, - targetState = getScreenFrom(navigator), - transitionSpec = { - fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)) + slideInVertically { 100 } togetherWith - fadeOut(tween(0)) - }, - label = "CustomAnimateTransition" - ) { screen -> - navigator.saveableState("${keyPrefix}_transition", screen) { - content(screen) + disposeScreenAfterTransitionEnd = true, + content = content, + transition = { + val (initialOffset, targetOffset) = when (navigator.lastEvent) { + StackEvent.Pop -> ({ size: Int -> -100 }) to ({ size: Int -> 100 }) + else -> ({ size: Int -> 100 }) to ({ size: Int -> -100 }) + } + + slideInVertically(animationSpec, initialOffset) + fadeIn( + animationSpec = spring(stiffness = Spring.StiffnessMedium) + ) togetherWith + slideOutVertically(animationSpec, targetOffset) + fadeOut(tween(50)) } - } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt index f51a1dc06..dccf73e62 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt @@ -6,14 +6,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.currentComposer import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.Navigator import com.lalilu.component.base.BottomSheetNavigator import com.lalilu.component.base.LocalPaddingValue import com.lalilu.lmusic.compose.component.CustomTransition @@ -25,34 +22,24 @@ import com.lalilu.lplaylist.screen.PlaylistScreen @Composable fun NavigationSheetContent( modifier: Modifier, - transitionKeyPrefix: String, navigator: BottomSheetNavigator, - getScreenFrom: (Navigator) -> Screen = { it.lastItem }, ) { Box(modifier = Modifier.fillMaxSize()) { - val currentScreen = remember { mutableStateOf(null) } val currentPaddingValue = remember { mutableStateOf(PaddingValues(0.dp)) } - CustomTransition( - modifier = Modifier.fillMaxSize(), - keyPrefix = transitionKeyPrefix, - navigator = navigator.getNavigator(), - getScreenFrom = getScreenFrom, - ) { - if (!currentComposer.skipping) { - currentScreen.value = it - } - - CompositionLocalProvider(LocalPaddingValue provides currentPaddingValue) { - it.Content() - } + CompositionLocalProvider(LocalPaddingValue provides currentPaddingValue) { + CustomTransition( + modifier = Modifier.fillMaxSize(), + navigator = navigator.getNavigator(), + ) } + NavigationSmartBar( modifier .fillMaxWidth() .align(Alignment.BottomCenter), measureHeightState = currentPaddingValue, - currentScreen = { currentScreen.value } + currentScreen = { navigator.lastItemOrNull } ) { modifier -> NavigationBar( modifier = modifier.align(Alignment.BottomCenter), @@ -63,7 +50,7 @@ fun NavigationSheetContent( SearchScreen ) }, - currentScreen = { currentScreen.value } + currentScreen = { navigator.lastItemOrNull } ) } } diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt index 19d65686f..122c2b27d 100644 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt @@ -29,6 +29,7 @@ import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.stack.Stack import cafe.adriel.voyager.navigator.CurrentScreen import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior import com.lalilu.component.navigation.EnhanceNavigator import com.lalilu.component.override.ModalBottomSheetDefaults import com.lalilu.component.override.ModalBottomSheetLayout @@ -66,7 +67,11 @@ fun BottomSheetNavigatorLayout( animationSpec = animationSpec ) - Navigator(screen = defaultScreen, onBackPressed = null) { navigator -> + Navigator( + screen = defaultScreen, + onBackPressed = null, + disposeBehavior = NavigatorDisposeBehavior(disposeSteps = false) + ) { navigator -> val bottomSheetNavigator = remember { BottomSheetNavigator( navigator = navigator, From 08e4d4c0bd89fb51cf98623fa60aeab6c983b88d Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 12 Aug 2024 01:10:21 +0800 Subject: [PATCH 057/213] =?UTF-8?q?[refactor]=E5=BC=95=E5=85=A5=E6=96=B0?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=9A=84KRouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 --- build.gradle.kts | 15 ++++++--------- common/build.gradle.kts | 2 ++ gradle/libs.versions.toml | 11 ++++++----- settings.gradle.kts | 2 +- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 39adf9149..342448242 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -175,9 +175,6 @@ dependencies { implementation(project(":lalbum")) implementation(project(":ldictionary")) - // KRouter - ksp("com.github.cy745.KRouter:compiler:fcf40f4b15") - implementation(libs.room.ktx) implementation(libs.room.runtime) ksp(libs.room.compiler) diff --git a/build.gradle.kts b/build.gradle.kts index 92379033f..026039ee5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,12 +8,9 @@ plugins { alias(libs.plugins.compose.compiler) apply false } -gradle.taskGraph.whenReady { - allTasks.onEach { - // 避免ksp类型任务被跳过 - if (it.name.startsWith("ksp") && it.name.endsWith("Kotlin")) { - it.setOnlyIf { true } - it.outputs.upToDateWhen { false } - } - } -} \ No newline at end of file +buildscript { + dependencies { classpath(libs.krouter.plugin) } +} + +ext { set("targetInjectProjectName", "app") } +apply(plugin = "krouter-plugin") diff --git a/common/build.gradle.kts b/common/build.gradle.kts index feefc1c91..be1d405b1 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -37,4 +37,6 @@ dependencies { api(libs.koin.android) api(libs.koin.compose) + + api(libs.krouter.core) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae85ce978..9c68ce197 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,8 @@ room_version = "2.5.2" media = "1.7.0" flyjingfish-aop = "1.9.7" +krouter_version = "0.0.1" + [libraries] # kotlin kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin_version" } @@ -104,11 +106,6 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "room_ve room-runtime = { module = "androidx.room:room-runtime", version.ref = "room_version" } media = { module = "androidx.media:media", version.ref = "media" } -# thirdparty / others -kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } -kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet" } -ksp-symbol-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp_version" } - # [Apache-2.0 License] 安卓工具类库 https://github.com/Blankj/AndroidUtilCode/ utilcodex = { module = "com.blankj:utilcodex", version.ref = "utilcodex_version" } @@ -117,6 +114,10 @@ flyjingfish-aop-core = { module = "io.github.FlyJingFish.AndroidAop:android-aop- flyjingfish-aop-annotation = { module = "io.github.FlyJingFish.AndroidAop:android-aop-annotation", version.ref = "flyjingfish-aop" } flyjingfish-aop-ksp = { module = "io.github.FlyJingFish.AndroidAop:android-aop-ksp", version.ref = "flyjingfish-aop" } +# KRouter +krouter-core = { module = "com.github.cy745.KRouter:core", version.ref = "krouter_version" } +krouter-plugin = { module = "com.github.cy745.KRouter:plugin", version.ref = "krouter_version" } + [plugins] application = { id = "com.android.application", version.ref = "agp_version" } library = { id = "com.android.library", version.ref = "agp_version" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3f2f3c286..15454fca0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,11 +3,11 @@ pluginManagement { google() mavenCentral() gradlePluginPortal() + maven("https://jitpack.io") } } dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() From 630562b5f6babe0607f330bc86f6a067a251607d Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 12 Aug 2024 01:12:13 +0800 Subject: [PATCH 058/213] =?UTF-8?q?[refactor]=E9=80=82=E9=85=8D=E6=96=B0KR?= =?UTF-8?q?outer=E7=9A=84=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/lalilu/lmusic/LMusicApp.kt | 4 ++++ .../com/lalilu/lmusic/compose/new_screen/HomeScreen.kt | 2 +- .../lalilu/lmusic/compose/new_screen/SearchScreen.kt | 2 +- .../compose/new_screen/detail/SongDetailScreen.kt | 2 +- .../lmusic/compose/screen/playing/PlaylistLayout.kt | 5 ++--- .../java/com/lalilu/lmusic/extension/DailyRecommend.kt | 5 ++--- .../java/com/lalilu/lmusic/extension/HistoryPanel.kt | 5 ++--- .../java/com/lalilu/lmusic/extension/LatestPanel.kt | 5 ++--- component/src/main/java/com/lalilu/component/Songs.kt | 10 ++++------ 9 files changed, 19 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 0d67aad4b..7c9af51d8 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -18,6 +18,8 @@ import com.lalilu.lmedia.indexer.FilterProvider import com.lalilu.lmusic.utils.extension.ignoreSSLVerification import com.lalilu.lplayer.LPlayer import com.lalilu.lplaylist.PlaylistModule +import com.zhangke.krouter.KRouter +import com.zhangke.krouter.generated.KRouterInjectMap import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -34,6 +36,8 @@ class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, V override fun onCreate() { super.onCreate() + KRouter.init(KRouterInjectMap::getMap) + SingletonImageLoader .setSafe(this) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt index ba77a48d9..18668af79 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt @@ -24,7 +24,7 @@ import com.lalilu.lmusic.viewmodel.LibraryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.zhangke.krouter.annotation.Destination -@Destination("/pages/home", type = Screen::class) +@Destination("/pages/home") object HomeScreen : TabScreen, Screen { @Composable diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt index dfede0d14..e5ca259a7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt @@ -46,7 +46,7 @@ import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchViewModel import com.zhangke.krouter.annotation.Destination -@Destination("/pages/search", type = Screen::class) +@Destination("/pages/search") object SearchScreen : Screen, TabScreen, ScreenExtraBarFactory { @Composable diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index 7db4a2310..1bc6c7071 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -42,7 +42,7 @@ import com.lalilu.lplayer.extensions.QueueAction import com.zhangke.krouter.annotation.Destination import com.zhangke.krouter.annotation.Param -@Destination("/song/detail", type = Screen::class) +@Destination("/song/detail") data class SongDetailScreen( @Param val mediaId: String ) : Screen, ScreenActionFactory, ScreenInfoFactory { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 3c3a8e689..18fd1b11c 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -163,9 +163,8 @@ fun PlaylistLayout( haptic.performHapticFeedback(HapticFeedbackType.LongPress) AppRouter.intent { - KRouter.route("/song/detail") { - with("mediaId", item.data.mediaId) - }?.let(NavIntent::Push) + KRouter.route("/song/detail?mediaId=${item.data.mediaId}") + ?.let(NavIntent::Push) } } ) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index be5b3f927..3b39009f5 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -72,9 +72,8 @@ fun LazyListScope.dailyRecommend( modifier = Modifier.size(width = 250.dp, height = 250.dp), onClick = { AppRouter.intent { - KRouter.route("/song/detail") { - with("mediaId", it.id) - }?.let(NavIntent::Push) + KRouter.route("/song/detail?mediaId=${it.id}") + ?.let(NavIntent::Push) } } ) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt index 3065bae1e..0a060f4e0 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt @@ -71,9 +71,8 @@ fun LazyListScope.historyPanel( onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) AppRouter.intent { - KRouter.route("/song/detail") { - with("mediaId", item.mediaId) - }?.let(NavIntent::Push) + KRouter.route("/song/detail?mediaId=${item.mediaId}") + ?.let(NavIntent::Push) } } ) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt index df6e2541a..08ac9d86c 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt @@ -50,9 +50,8 @@ fun LazyListScope.latestPanel( modifier = Modifier.animateItem(), onClick = { AppRouter.intent { - KRouter.route("/song/detail") { - with("mediaId", it.mediaId) - }?.let(NavIntent::Push) + KRouter.route("/song/detail?mediaId=${it.mediaId}") + ?.let(NavIntent::Push) } }, isPlaying = { playingVM.isItemPlaying(it.id, Playable::mediaId) }, diff --git a/component/src/main/java/com/lalilu/component/Songs.kt b/component/src/main/java/com/lalilu/component/Songs.kt index 666a5295c..d16a640b6 100644 --- a/component/src/main/java/com/lalilu/component/Songs.kt +++ b/component/src/main/java/com/lalilu/component/Songs.kt @@ -173,9 +173,8 @@ fun Screen.Songs( prefixContent = { prefixContent(it, sortRuleStr) }, onLongClickItem = { AppRouter.intent { - KRouter.route("/song/detail?mediaId=${it.mediaId}") { - with("mediaId", it.mediaId) - }?.let(NavIntent::Push) + KRouter.route("/song/detail?mediaId=${it.mediaId}") + ?.let(NavIntent::Push) } }, onClickItem = { @@ -212,9 +211,8 @@ fun Screen.Songs( prefixContent = { prefixContent(it, sortRuleStr) }, onLongClickItem = { AppRouter.intent { - KRouter.route("/song/detail?mediaId=${it.mediaId}") { - with("mediaId", it.mediaId) - }?.let(NavIntent::Push) + KRouter.route("/song/detail?mediaId=${it.mediaId}") + ?.let(NavIntent::Push) } }, onClickItem = { From b8cafe329c48020c262e268c085686d1fba4878a Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 12 Aug 2024 19:07:32 +0800 Subject: [PATCH 059/213] =?UTF-8?q?[modify]=E5=AE=8C=E5=96=84AppRouter?= =?UTF-8?q?=E9=93=BE=E5=BC=8F=E8=B0=83=E7=94=A8=E7=9A=84=E6=96=B9=E6=B3=95?= =?UTF-8?q?=EF=BC=8CNavigationSheetContent=E8=A7=A3=E8=80=A6=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/NavigationWrapper.kt | 2 +- .../navigate/NavigationSheetContent.kt | 26 +++++++++---------- .../lalilu/component/navigation/AppRouter.kt | 23 +++++++++++----- .../lalilu/lplaylist/screen/PlaylistScreen.kt | 4 ++- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt index d4b9ff710..230ebf8c6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt @@ -50,7 +50,7 @@ fun NavigationWrapper( NavigationSheetContent( modifier = modifier, - navigator = sheetNavigator + navigator = sheetNavigator.getNavigator() ) }, content = { content() } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt index dccf73e62..171b43ae2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt @@ -11,26 +11,32 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.lalilu.component.base.BottomSheetNavigator +import cafe.adriel.voyager.navigator.Navigator import com.lalilu.component.base.LocalPaddingValue +import com.lalilu.component.base.TabScreen +import com.lalilu.component.navigation.AppRouter import com.lalilu.lmusic.compose.component.CustomTransition -import com.lalilu.lmusic.compose.new_screen.HomeScreen -import com.lalilu.lmusic.compose.new_screen.SearchScreen -import com.lalilu.lplaylist.screen.PlaylistScreen @Composable fun NavigationSheetContent( modifier: Modifier, - navigator: BottomSheetNavigator, + navigator: Navigator, ) { + val tabScreenRoutes = remember { + listOf("/pages/home", "/pages/playlist", "/pages/search") + } + Box(modifier = Modifier.fillMaxSize()) { val currentPaddingValue = remember { mutableStateOf(PaddingValues(0.dp)) } + val tabScreens = remember(tabScreenRoutes) { + tabScreenRoutes.mapNotNull { AppRouter.route(it).get() as? TabScreen } + } CompositionLocalProvider(LocalPaddingValue provides currentPaddingValue) { CustomTransition( modifier = Modifier.fillMaxSize(), - navigator = navigator.getNavigator(), + navigator = navigator, ) } @@ -43,13 +49,7 @@ fun NavigationSheetContent( ) { modifier -> NavigationBar( modifier = modifier.align(Alignment.BottomCenter), - tabScreens = { - listOf( - HomeScreen, - PlaylistScreen, - SearchScreen - ) - }, + tabScreens = { tabScreens }, currentScreen = { navigator.lastItemOrNull } ) } diff --git a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt index 81ed3c767..d6657f923 100644 --- a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -35,11 +35,22 @@ object AppRouter : CoroutineScope { channel.send(i) } - fun String.push(): NavIntent.Push? = KRouter - .route(this) - ?.let(NavIntent::Push) + fun route(baseUrl: String): Request { + return Request(baseUrl) + } + + class Request internal constructor( + private val baseUrl: String, + private val params: MutableMap = mutableMapOf() + ) { + fun with(key: String, value: T) = apply { params[key] = value } - fun String.replace(): NavIntent.Replace? = KRouter - .route(this) - ?.let(NavIntent::Replace) + fun push() = requestResult()?.let { intent(NavIntent.Push(it)) } + fun replace() = requestResult()?.let { intent(NavIntent.Replace(it)) } + fun get() = requestResult() + + private fun requestResult(): Screen? = + runCatching { KRouter.route(baseUrl, params) } + .getOrNull() + } } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index 012c812a8..2aa40ae3c 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -45,9 +45,9 @@ import com.blankj.utilcode.util.ToastUtils import com.lalilu.component.LLazyColumn import com.lalilu.component.LongClickableTextButton import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.extension.SelectAction import com.lalilu.component.extension.rememberItemSelectHelper import com.lalilu.component.navigation.AppRouter @@ -57,6 +57,7 @@ import com.lalilu.lplaylist.R import com.lalilu.lplaylist.component.PlaylistCard import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository +import com.zhangke.krouter.annotation.Destination import org.koin.compose.koinInject import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyColumnState @@ -67,6 +68,7 @@ class PlaylistScreenModel : ScreenModel { val selectedItems = mutableStateOf>(emptyList()) } +@Destination("/pages/playlist") data object PlaylistScreen : TabScreen, ScreenBarFactory { @Composable From fb292d149925ae5e9582f2256a5d1f3d97743426 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 12 Aug 2024 19:09:07 +0800 Subject: [PATCH 060/213] =?UTF-8?q?[refactor]=E9=87=8D=E6=9E=84=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E6=9E=B6=E6=9E=84=E5=92=8C=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=B5=8C=E5=A5=97=E8=B7=AF=E7=94=B1=E5=92=8C?= =?UTF-8?q?=E5=B9=B3=E6=9D=BF=E7=A7=BB=E5=8A=A8=E7=AB=AF=E7=9A=84=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=95=88=E6=9E=9C=EF=BC=8C=E8=A7=A3=E5=86=B3debug?= =?UTF-8?q?=E5=8C=85=E5=9C=A8Android14=E4=B8=8A=E5=8D=A1=E9=A1=BF=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 7 + .../java/com/lalilu/lmusic/compose/App.kt | 11 +- .../lalilu/lmusic/compose/DrawerWrapper.kt | 70 ------ .../lalilu/lmusic/compose/LayoutWrapper.kt | 181 +++++++++++++- .../lmusic/compose/NavigationWrapper.kt | 58 ----- .../compose/component/CustomTransition.kt | 8 +- .../navigate/NavigationSheetContent.kt | 57 ----- .../component/navigate/NavigationSmartBar.kt | 92 ------- .../lmusic/compose/new_screen/SearchScreen.kt | 1 + .../new_screen/detail/SongDetailScreen.kt | 10 +- .../compose/screen/playing/PlayingLayout.kt | 13 +- .../PlayingLayoutExpended.kt} | 175 +++++--------- .../screen/playing/PlayingSmartCard.kt | 97 ++++++++ .../compose/screen/playing/SeekbarLayout.kt | 1 - .../compose/screen/songs/SongsScreen.kt | 127 ++++++++++ .../songs/SubsSongsScreen.kt} | 18 +- .../lalilu/lmusic/extension/DailyRecommend.kt | 14 +- .../com/lalilu/lmusic/extension/EntryPanel.kt | 2 +- .../main/java/com/lalilu/component/Songs.kt | 17 +- .../component/base/BottomSheetLayout.kt | 108 +++++++++ .../component/base/BottomSheetLayout2.kt | 50 ++++ .../component/base/BottomSheetNavigator.kt | 203 ---------------- .../component/base/EnhanceSheetState.kt | 105 ++++++++ .../base/screen/ScreenExtraBarFactory.kt | 1 + .../component/base/screen/ScreenType.kt | 6 + .../BottomSheetNestedScrollInterceptor.kt | 56 ----- .../lalilu/component/navigation/AppRouter.kt | 41 ++-- .../component/navigation/EmptyScreen.kt | 16 ++ .../component/navigation/HostNavigator.kt | 86 +++++++ .../component/navigation/NavigationContext.kt | 57 +++++ .../navigation/NavigationSmartBar.kt | 228 ++++++++---------- .../component/navigation/NestedNavigator.kt | 68 ++++++ .../component/navigation/ScreenTransition.kt | 92 +++++++ .../component/navigation/SheetController.kt | 122 ---------- gradle/libs.versions.toml | 4 +- .../lalilu/lalbum/screen/AlbumDetailScreen.kt | 5 +- 36 files changed, 1234 insertions(+), 973 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt rename app/src/main/java/com/lalilu/lmusic/compose/screen/{ShowScreen.kt => playing/PlayingLayoutExpended.kt} (53%) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt rename app/src/main/java/com/lalilu/lmusic/compose/{new_screen/SongsScreen.kt => screen/songs/SubsSongsScreen.kt} (94%) create mode 100644 component/src/main/java/com/lalilu/component/base/BottomSheetLayout.kt create mode 100644 component/src/main/java/com/lalilu/component/base/BottomSheetLayout2.kt delete mode 100644 component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt create mode 100644 component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt create mode 100644 component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt delete mode 100644 component/src/main/java/com/lalilu/component/extension/BottomSheetNestedScrollInterceptor.kt create mode 100644 component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt create mode 100644 component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt create mode 100644 component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt rename app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt => component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt (61%) create mode 100644 component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt create mode 100644 component/src/main/java/com/lalilu/component/navigation/ScreenTransition.kt delete mode 100644 component/src/main/java/com/lalilu/component/navigation/SheetController.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 342448242..21327909d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -216,7 +216,14 @@ dependencies { debugImplementation("com.github.getActivity:Logcat:11.8") // debugImplementation("io.github.knight-zxw:blockcanary:0.0.5") // debugImplementation("io.github.knight-zxw:blockcanary-ui:0.0.5") + debugImplementation("com.github.cy745:wytrace:d0df4c2d15") + debugImplementation("com.bytedance.android:shadowhook:1.0.10") implementation(libs.bundles.flyjingfish.aop) ksp(libs.flyjingfish.aop.ksp) + + implementation("com.google.accompanist:accompanist-adaptive:0.35.1-alpha") + implementation("androidx.compose.material3.adaptive:adaptive:1.0.0-beta04") + implementation("androidx.compose.material3.adaptive:adaptive-layout:1.0.0-beta04") + implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-beta04") } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/App.kt b/app/src/main/java/com/lalilu/lmusic/compose/App.kt index 3b820ad5d..6c0ce479e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/App.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/App.kt @@ -3,6 +3,7 @@ package com.lalilu.lmusic.compose import android.app.Activity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable @@ -26,10 +27,12 @@ object App { @Composable fun Environment(activity: Activity, content: @Composable () -> Unit) { LMusicTheme { - CompositionLocalProvider( - LocalWindowSize provides calculateWindowSizeClass(activity = activity), - content = content - ) + MaterialTheme { + CompositionLocalProvider( + LocalWindowSize provides calculateWindowSizeClass(activity = activity), + content = content + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt deleted file mode 100644 index 7d4f2547f..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.lalilu.lmusic.compose - -import androidx.compose.material.Surface -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.MeasurePolicy -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import com.lalilu.component.base.LocalWindowSize - -object DrawerWrapper { - - @Composable - fun Content( - windowClass: WindowSizeClass = LocalWindowSize.current, - mainContent: @Composable () -> Unit, - secondContent: @Composable () -> Unit, - ) { - val density = LocalDensity.current - val mainContentWidthPx = remember { density.run { 360.dp.toPx() }.toInt() } - - val policy = remember(windowClass.widthSizeClass) { - when (windowClass.widthSizeClass) { - WindowWidthSizeClass.Expanded -> rowPolicy( - mainContentWidthPx = mainContentWidthPx - ) - - else -> boxPolicy() - } - } - - Layout( - content = { - Surface { mainContent() } - secondContent() - }, - measurePolicy = policy - ) - } - - private fun rowPolicy( - mainContentWidthPx: Int, - ): MeasurePolicy = MeasurePolicy { measurables, constraints -> - val mainPlaceable = measurables[0] - .measure(constraints.copy(maxWidth = mainContentWidthPx)) - - val secondWidth = constraints.maxWidth - mainContentWidthPx - val secondPlaceable = measurables[1] - .measure(constraints.copy(maxWidth = secondWidth)) - - layout(constraints.maxWidth, constraints.maxHeight) { - mainPlaceable.place(0, 0) - - secondPlaceable.place(mainContentWidthPx, 0) - } - } - - private fun boxPolicy(): MeasurePolicy = MeasurePolicy { measurables, constraints -> - val placeables = measurables.map { it.measure(constraints) } - - layout(constraints.maxWidth, constraints.maxHeight) { - placeables.forEach { placeable -> - placeable.place(0, 0) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt index 64c28183b..5323e5891 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt @@ -1,34 +1,193 @@ package com.lalilu.lmusic.compose -import android.content.res.Configuration +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.BottomSheetValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import com.lalilu.component.base.BottomSheetLayout +import com.lalilu.component.base.BottomSheetLayout2 +import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.extension.DialogWrapper import com.lalilu.component.extension.DynamicTipsHost -import com.lalilu.lmusic.compose.screen.ShowScreen +import com.lalilu.component.navigation.HostNavigator +import com.lalilu.component.navigation.NavigationSmartBar +import com.lalilu.lmusic.compose.component.CustomTransition +import com.lalilu.lmusic.compose.new_screen.HomeScreen import com.lalilu.lmusic.compose.screen.playing.PlayingLayout +import com.lalilu.lmusic.compose.screen.playing.PlayingLayoutExpended +import com.lalilu.lmusic.compose.screen.playing.PlayingSmartCard object LayoutWrapper { @Composable fun BoxScope.Content() { - val configuration = LocalConfiguration.current - val isLandscape by remember(configuration.orientation) { - derivedStateOf { configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } + val windowClass = LocalWindowSize.current + + val navHostContent = remember { + movableContentOf<(Navigator) -> Unit> { content -> + HostNavigator(HomeScreen) { navigator -> + content(navigator) + + CustomTransition( + modifier = Modifier.fillMaxSize(), + navigator = navigator, + ) + } + } } - NavigationWrapper { PlayingLayout() } + val navigationSmartBar = remember { + movableContentOf { modifier -> + NavigationSmartBar(modifier = modifier) + } + } - if (isLandscape) { - ShowScreen() + if (windowClass.widthSizeClass == WindowWidthSizeClass.Expanded) { + LayoutForPad( + navHostContent = navHostContent, + navigationSmartBar = navigationSmartBar + ) + } else { + LayoutForMobile( + navHostContent = navHostContent, + navigationSmartBar = navigationSmartBar + ) } DialogWrapper.Content() with(DynamicTipsHost) { Content() } } +} + +@Composable +fun LayoutForPad( + modifier: Modifier = Modifier, + navigatorBarHeight: Dp = 56.dp, + navHostContent: @Composable ((Navigator) -> Unit) -> Unit, + navigationSmartBar: @Composable (Modifier) -> Unit +) { + val navigator = remember { mutableStateOf(null) } + + BottomSheetLayout2( + modifier = modifier, + sheetPeekHeight = navigatorBarHeight, + sheetContent = { enhanceSheetState -> + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + val progress = enhanceSheetState.progress( + BottomSheetValue.Collapsed, + BottomSheetValue.Expanded + ) + + alpha = (progress * 4f).coerceIn(0f, 1f) + } + ) { + PlayingLayoutExpended() + } + + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .height(navigatorBarHeight) + .align(Alignment.TopCenter) + .graphicsLayer { + val progress = enhanceSheetState.progress( + BottomSheetValue.Collapsed, + BottomSheetValue.Expanded + ) + + translationY = constraints.maxHeight * progress + alpha = (1f - progress) + } + ) { + PlayingSmartCard( + modifier = Modifier + .fillMaxHeight() + .width(360.dp) + ) + + CompositionLocalProvider(LocalNavigator provides navigator.value) { + navigationSmartBar( + Modifier + // 拦截滑动事件 + .pointerInput(Unit) { detectDragGestures { _, _ -> } } + .fillMaxHeight() + .weight(1f) + ) + } + } + } + }, + content = { + navHostContent { navigator.value = it } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun LayoutForMobile( + modifier: Modifier = Modifier, + navHostContent: @Composable ((Navigator) -> Unit) -> Unit, + navigationSmartBar: @Composable (Modifier) -> Unit +) { + BottomSheetLayout( + modifier = modifier.fillMaxSize(), + scrimColor = Color.Black.copy(alpha = 0.5f), + skipHalfExpanded = false, + sheetBackgroundColor = MaterialTheme.colors.background, + animationSpec = tween( + durationMillis = 200, + easing = CubicBezierEasing(0.1f, 0.16f, 0f, 1f) + ), + sheetContent = { + val navigator = remember { mutableStateOf(null) } + + Box( + modifier = Modifier.fillMaxSize() + ) { + navHostContent { navigator.value = it } + + CompositionLocalProvider(value = LocalNavigator provides navigator.value) { + navigationSmartBar( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) + } + } + }, + content = { PlayingLayout() } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt deleted file mode 100644 index 230ebf8c6..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.lalilu.lmusic.compose - -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.lalilu.component.base.BottomSheetNavigatorLayout -import com.lalilu.component.base.LocalWindowSize -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.lmusic.compose.component.navigate.NavigationSheetContent -import com.lalilu.lmusic.compose.new_screen.HomeScreen - - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun NavigationWrapper( - modifier: Modifier = Modifier, - content: @Composable () -> Unit = {} -) { - val windowSizeClass = LocalWindowSize.current - - BottomSheetNavigatorLayout( - modifier = modifier.fillMaxSize(), - defaultScreen = HomeScreen, - scrimColor = Color.Black.copy(alpha = 0.5f), - skipHalfExpanded = false, - sheetBackgroundColor = MaterialTheme.colors.background, - enableBottomSheetMode = { windowSizeClass.widthSizeClass != WindowWidthSizeClass.Expanded }, - animationSpec = tween( - durationMillis = 200, - easing = CubicBezierEasing(0.1f, 0.16f, 0f, 1f) - ), - sheetContent = { sheetNavigator -> - LaunchedEffect(sheetNavigator) { - AppRouter.intentFlow.collect { intent -> - when (intent) { - NavIntent.Pop -> sheetNavigator.back() - is NavIntent.Push -> sheetNavigator.jump(intent.screen) - is NavIntent.Replace -> sheetNavigator.replace(intent.screen) - } - } - } - - NavigationSheetContent( - modifier = modifier, - navigator = sheetNavigator.getNavigator() - ) - }, - content = { content() } - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt index 0ce80e8f8..6c3e3f675 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt @@ -18,7 +18,7 @@ import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.transitions.ScreenTransition +import com.lalilu.component.navigation.CustomScreenTransition @OptIn(ExperimentalVoyagerApi::class) @Composable @@ -29,9 +29,11 @@ fun CustomTransition( stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntOffset.VisibilityThreshold ), - content: @Composable (AnimatedVisibilityScope.(Screen) -> Unit) = { it.Content() } + content: @Composable (AnimatedVisibilityScope.(Screen) -> Unit) = { + navigator.saveableState("transition", it) { it.Content() } + } ) { - ScreenTransition( + CustomScreenTransition( navigator = navigator, modifier = modifier, disposeScreenAfterTransitionEnd = true, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt deleted file mode 100644 index 171b43ae2..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.lalilu.lmusic.compose.component.navigate - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.navigator.Navigator -import com.lalilu.component.base.LocalPaddingValue -import com.lalilu.component.base.TabScreen -import com.lalilu.component.navigation.AppRouter -import com.lalilu.lmusic.compose.component.CustomTransition - - -@Composable -fun NavigationSheetContent( - modifier: Modifier, - navigator: Navigator, -) { - val tabScreenRoutes = remember { - listOf("/pages/home", "/pages/playlist", "/pages/search") - } - - Box(modifier = Modifier.fillMaxSize()) { - val currentPaddingValue = remember { mutableStateOf(PaddingValues(0.dp)) } - val tabScreens = remember(tabScreenRoutes) { - tabScreenRoutes.mapNotNull { AppRouter.route(it).get() as? TabScreen } - } - - CompositionLocalProvider(LocalPaddingValue provides currentPaddingValue) { - CustomTransition( - modifier = Modifier.fillMaxSize(), - navigator = navigator, - ) - } - - NavigationSmartBar( - modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - measureHeightState = currentPaddingValue, - currentScreen = { navigator.lastItemOrNull } - ) { modifier -> - NavigationBar( - modifier = modifier.align(Alignment.BottomCenter), - tabScreens = { tabScreens }, - currentScreen = { navigator.lastItemOrNull } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt deleted file mode 100644 index ba5229666..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.lalilu.lmusic.compose.component.navigate - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen -import com.lalilu.component.base.screen.ScreenBarFactory -import com.lalilu.component.base.screen.ScreenExtraBarFactory -import com.lalilu.lmusic.utils.extension.measureHeight - -@Composable -fun NavigationSmartBar( - modifier: Modifier = Modifier, - measureHeightState: MutableState, - currentScreen: () -> Screen?, - content: @Composable (Modifier) -> Unit -) { - val density = LocalDensity.current - val measureMainHeightState = remember { mutableStateOf(PaddingValues(0.dp)) } - - val mainContent = (currentScreen() as? ScreenBarFactory)?.content() - val extraContent = (currentScreen() as? ScreenExtraBarFactory)?.content() - - Column( - modifier = modifier - .fillMaxWidth() - .background(color = MaterialTheme.colors.background.copy(alpha = 0.95f)) - .measureHeight { _, height -> - measureHeightState.value = PaddingValues(bottom = density.run { height.toDp() }) - }, - verticalArrangement = Arrangement.Bottom - ) { - AnimatedContent( - targetState = extraContent, - label = "", - content = { it?.content?.invoke() } - ) - - if (extraContent != null) { - Spacer( - modifier = modifier - .fillMaxWidth() - .consumeWindowInsets(measureMainHeightState.value) - .imePadding() - ) - } - - AnimatedContent( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .measureHeight { _, height -> - measureMainHeightState.value = - PaddingValues(bottom = density.run { height.toDp() }) - } - .navigationBarsPadding(), - transitionSpec = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow) - ) togetherWith slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Down) - }, - contentAlignment = Alignment.BottomCenter, - targetState = mainContent, - label = "" - ) { item -> - item?.content?.invoke() - ?: content(Modifier.fillMaxWidth()) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt index e5ca259a7..eef8ab146 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt @@ -41,6 +41,7 @@ import com.lalilu.lmusic.compose.component.base.SearchInputBar import com.lalilu.lmusic.compose.component.card.RecommendCardForAlbum import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle +import com.lalilu.lmusic.compose.screen.songs.SongsScreen import com.lalilu.lmusic.utils.extension.getActivity import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchViewModel diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index 1bc6c7071..5c6325ab6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -25,7 +25,7 @@ import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import com.lalilu.R import com.lalilu.component.IconButton -import com.lalilu.component.base.LocalBottomSheetNavigator +import com.lalilu.component.base.LocalEnhanceSheetState import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenInfo @@ -55,7 +55,7 @@ data class SongDetailScreen( @Composable override fun provideScreenActions(): List { - return remember { + return remember(this) { listOf( ScreenAction.StaticAction( title = R.string.button_set_song_to_next, @@ -124,10 +124,10 @@ data class SongDetailScreen( ) { when { song.value != null -> { - val bottomSheet = LocalBottomSheetNavigator.current - val progress by remember(bottomSheet?.sheetState) { + val enhanceSheetState = LocalEnhanceSheetState.current + val progress by remember(enhanceSheetState) { derivedStateOf { - bottomSheet?.sheetState?.progress( + enhanceSheetState?.progress( ModalBottomSheetValue.HalfExpanded, ModalBottomSheetValue.Expanded, ) ?: 0f diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index e96979efe..4f8fe6f39 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.dirror.lyricviewx.LyricUtil import com.google.accompanist.systemuicontroller.rememberSystemUiController -import com.lalilu.component.base.LocalBottomSheetNavigator +import com.lalilu.component.base.LocalEnhanceSheetState import com.lalilu.component.extension.hideControl import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar @@ -58,8 +58,7 @@ fun PlayingLayout( settingsSp: SettingsSp = koinInject(), ) { val haptic = LocalHapticFeedback.current - val navigator = LocalBottomSheetNavigator.current - val sheetState = navigator?.sheetState + val enhanceSheetState = LocalEnhanceSheetState.current val systemUiController = rememberSystemUiController() val lyricLayoutLazyListState = rememberLazyListState() @@ -284,12 +283,10 @@ fun PlayingLayout( onValueChange = { seekbarTime.longValue = it.toLong() }, maxValue = { duration.value.toFloat() }, dataValue = { currentValue.value.toFloat() }, - onDispatchDragOffset = { - sheetState?.anchoredDraggableState?.dispatchRawDelta(it) - }, + onDispatchDragOffset = { enhanceSheetState?.dispatch(it) }, onDragStop = { result -> - if (result == -1) sheetState?.hide() - else sheetState?.anchoredDraggableState?.settle(0f) + if (result == -1) enhanceSheetState?.hide() + else enhanceSheetState?.settle(0f) }, onSeekTo = { position -> PlayerAction.SeekTo(position.toLong()).action() diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt similarity index 53% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt rename to app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt index b2e5b696a..2ee8cab1e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt @@ -1,25 +1,22 @@ -package com.lalilu.lmusic.compose.screen +package com.lalilu.lmusic.compose.screen.playing -import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight 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.material.IconButton import androidx.compose.material.IconToggleButton -import androidx.compose.material.Surface +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -30,143 +27,98 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.FixedScale -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter -import coil3.compose.SubcomposeAsyncImage -import coil3.compose.SubcomposeAsyncImageContent import coil3.request.ImageRequest import coil3.request.crossfade import coil3.request.transformations -import com.blankj.utilcode.util.SizeUtils import com.lalilu.R import com.lalilu.common.base.Playable import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.utils.coil.BlurTransformation -import com.lalilu.component.base.LocalWindowSize -import com.lalilu.component.extension.rememberIsPad import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplayer.LPlayer import com.lalilu.lplayer.extensions.PlayerAction import com.lalilu.lplayer.playback.PlayMode @Composable -fun ShowScreen( +fun PlayingLayoutExpended( + modifier: Modifier = Modifier, playingVM: PlayingViewModel = singleViewModel(), ) { - val windowSize = LocalWindowSize.current - val configuration = LocalConfiguration.current - val isPad by windowSize.rememberIsPad() - - val visible = remember(isPad, configuration.orientation) { - !isPad && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val currentPlaying by playingVM.playing + val context = LocalContext.current + val data = remember(currentPlaying) { + ImageRequest.Builder(context) + .data(currentPlaying) + .size(500) + .crossfade(true) + .transformations(BlurTransformation(context, 25f, 8f)) + .build() } - if (visible) { - val song by LPlayer.runtime.info.playingFlow.collectAsState(null) + Box( + modifier = modifier + .fillMaxSize() + .background(color = MaterialTheme.colors.background) + ) { + AnimatedContent( + modifier = Modifier.fillMaxSize(), + transitionSpec = { + fadeIn(tween(500)) togetherWith fadeOut(tween(300, 500)) + }, + targetState = data, + label = "" + ) { model -> + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = model, + contentScale = ContentScale.Crop, + contentDescription = null + ) + } - Box( + Row( modifier = Modifier .fillMaxSize() - .background(color = Color.DarkGray) + .padding(64.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - BlurImageBg(playable = song) - Row( + Column( modifier = Modifier .fillMaxSize() - .clickable(enabled = false) { } - .padding(64.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .padding(24.dp) + .weight(1f), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.SpaceBetween ) { - ImageCover(playable = song) - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp) - .weight(1f), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.SpaceBetween - ) { - SongDetailPanel(playable = song) - ControlPanel(playingVM) - } - } - } - } -} - -@Composable -fun RowScope.ImageCover(playable: Playable?) { - Box( - modifier = Modifier - .fillMaxSize() - .weight(1f) - ) { - Surface( - modifier = Modifier.align(Alignment.Center), - shape = RoundedCornerShape(10.dp), - color = Color(0x55000000), - elevation = 0.dp - ) { - SubcomposeAsyncImage( - model = ImageRequest.Builder(context = LocalContext.current) - .data(playable?.imageSource) - .size(SizeUtils.dp2px(256f)) - .crossfade(true) - .build(), - contentDescription = "" - ) { - val state by painter.state.collectAsState() - if (state is AsyncImagePainter.State.Loading || state is AsyncImagePainter.State.Error) { - Image( - painter = painterResource(id = R.drawable.ic_music_line), - contentDescription = "", - contentScale = FixedScale(1f), - colorFilter = ColorFilter.tint(color = Color.LightGray), - modifier = Modifier - .fillMaxHeight() - .aspectRatio(1f) - ) - } else { - SubcomposeAsyncImageContent( - modifier = Modifier.fillMaxHeight(), - contentScale = ContentScale.FillHeight + AnimatedContent( + modifier = Modifier.size(300.dp), + transitionSpec = { + fadeIn(tween(500)) togetherWith fadeOut(tween(300, 500)) + }, + targetState = currentPlaying, + label = "" + ) { model -> + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = model, + contentScale = ContentScale.Crop, + contentDescription = null ) } + + SongDetailPanel(playable = currentPlaying) + ControlPanel(playingVM) } } } } -@Composable -fun BoxScope.BlurImageBg(playable: Playable?) { - - AsyncImage( - modifier = Modifier - .fillMaxSize() - .align(Alignment.Center), - model = ImageRequest.Builder(LocalContext.current) - .data(playable?.imageSource) - .size(SizeUtils.dp2px(128f)) - .transformations(BlurTransformation(LocalContext.current, 25f, 4f)) - .crossfade(true) - .build(), - contentDescription = "", - contentScale = ContentScale.Crop - ) - Spacer( - modifier = Modifier - .fillMaxSize() - .background(color = Color(0x40000000)) - ) -} - @Composable fun SongDetailPanel( playable: Playable?, @@ -201,6 +153,7 @@ fun SongDetailPanel( } } + @Composable fun ControlPanel( playingVM: PlayingViewModel = singleViewModel(), diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt new file mode 100644 index 000000000..7cc65ccc1 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt @@ -0,0 +1,97 @@ +package com.lalilu.lmusic.compose.screen.playing + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.MarqueeSpacing +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.lalilu.component.extension.singleViewModel +import com.lalilu.lmusic.viewmodel.PlayingViewModel + +@Composable +fun PlayingSmartCard( + modifier: Modifier = Modifier, + playingVM: PlayingViewModel = singleViewModel(), +) { + val currentPlaying by playingVM.playing + + Surface(modifier) { + AnimatedContent( + modifier = Modifier.fillMaxSize(), + transitionSpec = { + slideInVertically { -it } togetherWith slideOutVertically { it } + }, + targetState = currentPlaying, + label = "" + ) { playing -> + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f), + model = playing, + contentScale = ContentScale.Crop, + contentDescription = null + ) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + ) { + Text( + modifier = Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + spacing = MarqueeSpacing(30.dp) + ), + text = playing?.title ?: "Unknown", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground, + fontSize = 14.sp, + lineHeight = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + modifier = Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + spacing = MarqueeSpacing(30.dp) + ), + text = playing?.subTitle ?: "Unknown", + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 10.sp, + lineHeight = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt index 6e86db34c..f59b66b6a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt @@ -23,7 +23,6 @@ import com.lalilu.R import com.lalilu.common.HapticUtils import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.extension.collectWithLifeCycleOwner -import com.lalilu.lmusic.compose.NavigationWrapper import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.utils.extension.durationToTime import com.lalilu.lmusic.utils.extension.getActivity diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt new file mode 100644 index 000000000..1df7d2f48 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -0,0 +1,127 @@ +package com.lalilu.lmusic.compose.screen.songs + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import com.lalilu.R +import com.lalilu.component.base.LocalWindowSize +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.base.screen.ScreenType +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.EmptyScreen +import com.lalilu.component.navigation.NavIntent +import com.lalilu.component.navigation.NestedNavigator +import com.lalilu.lmusic.compose.component.CustomTransition +import com.zhangke.krouter.KRouter +import com.zhangke.krouter.annotation.Destination +import com.zhangke.krouter.annotation.Param + +@Destination("/pages/songs") +data class SongsScreen( + @Param(required = true, name = KRouter.PRESET_ROUTER) + private val router: String = "/pages/songs", + private val title: String? = null, + private val mediaIds: List = emptyList() +) : Screen, ScreenInfoFactory, ScreenType.List { + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = R.string.screen_title_songs, + icon = R.drawable.ic_music_2_line + ) + } + + @Composable + override fun Content() { + val windowSizeClass = LocalWindowSize.current + val isPad = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded + + // TODO 待检查从下一级路由返回时该嵌套路由被重置的问题 + NestedNavigator( + key = router, + startScreen = SubsSongsScreen(title, mediaIds), + ) { navigator -> + LaunchedEffect(navigator) { + AppRouter.bindFor(router).collect { intent -> + when (intent) { + NavIntent.Pop -> navigator.pop() + is NavIntent.Push -> navigator.push(intent.screen) + is NavIntent.Replace -> navigator.replace(intent.screen) + is NavIntent.Jump -> { + // 此处完善自定义跳转逻辑 + navigator.push(intent.screen) + } + } + } + } + + val listContent = remember(navigator) { + movableContentOf { + navigator.items + .firstOrNull { it is ScreenType.List } + ?.let { + navigator.saveableState(key = "list", screen = it) { + it.Content() + } + } + } + } + + val detailContent = remember { + movableContentOf { isPad -> + CustomTransition( + navigator = navigator, + content = { + when { + isPad && it is ScreenType.List -> EmptyScreen.Content() + !isPad && it is ScreenType.List -> listContent() + else -> navigator.saveableState( + screen = it, + key = "transition", + content = { it.Content() } + ) + } + } + ) + } + } + + if (isPad) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(360.dp), + content = { listContent() } + ) + + Box( + modifier = Modifier + .fillMaxHeight() + .weight(1f), + content = { detailContent(true) } + ) + } + } else { + detailContent(false) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SubsSongsScreen.kt similarity index 94% rename from app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongsScreen.kt rename to app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SubsSongsScreen.kt index f212b478f..a51271482 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SubsSongsScreen.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.new_screen +package com.lalilu.lmusic.compose.screen.songs import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListState @@ -20,9 +20,9 @@ import com.lalilu.component.SongsScreenModel import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory -import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.base.screen.ScreenType import com.lalilu.component.extension.LazyListScrollToHelper import com.lalilu.component.extension.SelectAction import com.lalilu.component.extension.rememberLazyListScrollToHelper @@ -33,11 +33,13 @@ import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmusic.viewmodel.HistoryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplaylist.PlaylistActions +import com.zhangke.krouter.annotation.Destination -data class SongsScreen( +@Destination("/pages/songs") +data class SubsSongsScreen( private val title: String? = null, private val mediaIds: List = emptyList() -) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { +) : Screen, ScreenActionFactory, ScreenInfoFactory, ScreenType.List { @Transient private var scrollHelper: LazyListScrollToHelper? = null @@ -45,6 +47,7 @@ data class SongsScreen( @Transient private var songsSM: SongsScreenModel? = null + @Composable override fun provideScreenInfo(): ScreenInfo = remember { ScreenInfo( @@ -81,15 +84,16 @@ data class SongsScreen( @Composable override fun Content() { val listState: LazyListState = rememberLazyListState() - val historyVM: HistoryViewModel = singleViewModel() val songsSM = rememberScreenModel { SongsScreenModel() } .also { this.songsSM = it } val scrollHelper = rememberLazyListScrollToHelper(listState = listState) .also { this.scrollHelper = it } + val historyVM: HistoryViewModel = singleViewModel() Songs( + modifier = Modifier, showAll = true, - mediaIds = mediaIds, + mediaIds = emptyList(), listState = listState, songsSM = songsSM, scrollToHelper = scrollHelper, @@ -146,4 +150,4 @@ data class SongsScreen( } ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index 3b39009f5..390c91f1f 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -30,7 +30,7 @@ import com.lalilu.component.navigation.NavIntent import com.lalilu.lmusic.compose.component.card.RecommendCard2 import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle -import com.lalilu.lmusic.compose.new_screen.SongsScreen +import com.lalilu.lmusic.compose.screen.songs.SongsScreen import com.lalilu.lmusic.viewmodel.LibraryViewModel import com.zhangke.krouter.KRouter @@ -95,7 +95,7 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - items[0].let { + items.getOrNull(0)?.let { RecommendCard2( item = { it }, modifier = Modifier @@ -105,7 +105,7 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { ) } - items[1].let { + items.getOrNull(1)?.let { RecommendCard2( item = { it }, modifier = Modifier @@ -115,7 +115,7 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { ) } - items[2].let { + items.getOrNull(2)?.let { RecommendCard2( item = { it }, modifier = Modifier @@ -139,7 +139,7 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - items[0].let { + items.getOrNull(0)?.let { RecommendCard2( item = { it }, modifier = Modifier @@ -156,7 +156,7 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - items[1].let { + items.getOrNull(1)?.let { RecommendCard2( item = { it }, modifier = Modifier @@ -168,7 +168,7 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { ) } - items[2].let { + items.getOrNull(2)?.let { RecommendCard2( item = { it }, modifier = Modifier diff --git a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt index aa9be941c..0d268a3e6 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt @@ -28,7 +28,7 @@ import com.lalilu.lartist.screen.ArtistsScreen import com.lalilu.ldictionary.screen.DictionaryScreen import com.lalilu.lhistory.screen.HistoryScreen import com.lalilu.lmusic.compose.new_screen.SettingsScreen -import com.lalilu.lmusic.compose.new_screen.SongsScreen +import com.lalilu.lmusic.compose.screen.songs.SongsScreen import com.lalilu.lplaylist.screen.PlaylistScreen @Composable diff --git a/component/src/main/java/com/lalilu/component/Songs.kt b/component/src/main/java/com/lalilu/component/Songs.kt index d16a640b6..5df00e929 100644 --- a/component/src/main/java/com/lalilu/component/Songs.kt +++ b/component/src/main/java/com/lalilu/component/Songs.kt @@ -99,6 +99,7 @@ fun DefaultEmptyContent() { } } +@Deprecated("耦合度过高,待重新实现") @Composable fun Screen.Songs( modifier: Modifier = Modifier, @@ -172,10 +173,10 @@ fun Screen.Songs( emptyContent = emptyContent, prefixContent = { prefixContent(it, sortRuleStr) }, onLongClickItem = { - AppRouter.intent { - KRouter.route("/song/detail?mediaId=${it.mediaId}") - ?.let(NavIntent::Push) - } + AppRouter.route( + baseUrl = "/song/detail?mediaId=${it.mediaId}", + key = "/pages/songs" + ).push() }, onClickItem = { playingVM.play( @@ -210,10 +211,10 @@ fun Screen.Songs( emptyContent = emptyContent, prefixContent = { prefixContent(it, sortRuleStr) }, onLongClickItem = { - AppRouter.intent { - KRouter.route("/song/detail?mediaId=${it.mediaId}") - ?.let(NavIntent::Push) - } + AppRouter.route( + baseUrl = "/song/detail?mediaId=${it.mediaId}", + key = "/pages/songs" + ).push() }, onClickItem = { playingVM.play( diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetLayout.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetLayout.kt new file mode 100644 index 000000000..ca93fd2da --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetLayout.kt @@ -0,0 +1,108 @@ +package com.lalilu.component.base + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.lalilu.component.override.ModalBottomSheetDefaults +import com.lalilu.component.override.ModalBottomSheetLayout +import com.lalilu.component.override.ModalBottomSheetValue +import com.lalilu.component.override.rememberModalBottomSheetState + +@ExperimentalMaterialApi +@Composable +fun BottomSheetLayout( + modifier: Modifier = Modifier, + scrimColor: Color = ModalBottomSheetDefaults.scrimColor, + sheetShape: Shape = MaterialTheme.shapes.large, + sheetElevation: Dp = 0.dp, + sheetBackgroundColor: Color = MaterialTheme.colors.surface, + sheetContentColor: Color = contentColorFor(sheetBackgroundColor), + sheetGesturesEnabled: Boolean = true, + skipHalfExpanded: Boolean = true, + animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, + sheetContent: @Composable (enhanceSheetState: EnhanceSheetState) -> Unit = { }, + content: @Composable (enhanceSheetState: EnhanceSheetState) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + skipHalfExpanded = skipHalfExpanded, + animationSpec = animationSpec + ) + + val enhanceSheetState = remember(sheetState) { + EnhanceModalSheetState( + sheetState = sheetState, + scope = coroutineScope + ) + } + + val scaleValue = remember(sheetState) { + derivedStateOf { + val state = sheetState.anchoredDraggableState + val min = state.anchors.minAnchor() + val max = state.anchors.maxAnchor() + val offset = state.offset + + val fraction = offset.normalize(min, max) + val scale = 0.8f + 0.2f * fraction + scale.takeIf { !it.isNaN() } ?: 1f + } + } + + CompositionLocalProvider(LocalEnhanceSheetState provides enhanceSheetState) { + ModalBottomSheetLayout( + modifier = modifier, + scrimColor = scrimColor, + sheetState = sheetState, + sheetShape = sheetShape, + sheetElevation = sheetElevation, + sheetBackgroundColor = sheetBackgroundColor, + sheetContentColor = sheetContentColor, + sheetGesturesEnabled = sheetGesturesEnabled, + sheetContent = { sheetContent(enhanceSheetState) }, + content = { + Surface(color = Color.Black) { + Box(modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scaleValue.value + scaleY = scaleX + } + .clip(RoundedCornerShape(32.dp)), + content = { content(enhanceSheetState) } + ) + } + } + ) + } +} + +private fun Float.normalize(minValue: Float, maxValue: Float): Float { + val min = minOf(minValue, maxValue) + val max = maxOf(minValue, maxValue) + + if (min == max) return 0f + if (this <= min) return 0f + if (this >= max) return 1f + + return ((this - min) / (max - min)) + .coerceIn(0f, 1f) +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetLayout2.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetLayout2.kt new file mode 100644 index 000000000..0a5f595f0 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/BottomSheetLayout2.kt @@ -0,0 +1,50 @@ +package com.lalilu.component.base + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.BottomSheetScaffold +import androidx.compose.material.BottomSheetScaffoldState +import androidx.compose.material.rememberBottomSheetScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun BottomSheetLayout2( + modifier: Modifier = Modifier, + sheetPeekHeight: Dp = 56.dp, + sheetContent: @Composable (EnhanceSheetState) -> Unit, + content: @Composable (PaddingValues) -> Unit +) { + val scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState() + val scope = rememberCoroutineScope() + val enhanceSheetState = remember(scaffoldState.bottomSheetState) { + EnhanceBottomSheetState( + bottomSheetState = scaffoldState.bottomSheetState, + scope = scope + ) + } + + CompositionLocalProvider(LocalEnhanceSheetState provides enhanceSheetState) { + BottomSheetScaffold( + modifier = modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetElevation = 0.dp, + sheetBackgroundColor = Color.Transparent, + sheetPeekHeight = sheetPeekHeight, + sheetContent = { + BackHandler(enabled = enhanceSheetState.isVisible) { + enhanceSheetState.hide() + } + sheetContent(enhanceSheetState) + }, + content = content + ) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt b/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt deleted file mode 100644 index 122c2b27d..000000000 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.lalilu.component.base - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.stack.Stack -import cafe.adriel.voyager.navigator.CurrentScreen -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior -import com.lalilu.component.navigation.EnhanceNavigator -import com.lalilu.component.override.ModalBottomSheetDefaults -import com.lalilu.component.override.ModalBottomSheetLayout -import com.lalilu.component.override.ModalBottomSheetState -import com.lalilu.component.override.ModalBottomSheetValue -import com.lalilu.component.override.rememberModalBottomSheetState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -val LocalBottomSheetNavigator: ProvidableCompositionLocal = - staticCompositionLocalOf { null } - -@ExperimentalMaterialApi -@Composable -fun BottomSheetNavigatorLayout( - modifier: Modifier = Modifier, - defaultScreen: Screen, - scrimColor: Color = ModalBottomSheetDefaults.scrimColor, - sheetShape: Shape = MaterialTheme.shapes.large, - sheetElevation: Dp = 0.dp, - sheetBackgroundColor: Color = MaterialTheme.colors.surface, - sheetContentColor: Color = contentColorFor(sheetBackgroundColor), - sheetGesturesEnabled: Boolean = true, - skipHalfExpanded: Boolean = true, - enableBottomSheetMode: () -> Boolean = { true }, - animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, - sheetContent: @Composable (bottomSheetNavigator: BottomSheetNavigator) -> Unit = { CurrentScreen() }, - content: @Composable (sheetState: ModalBottomSheetState) -> Unit -) { - val coroutineScope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = skipHalfExpanded, - enableBottomSheetMode = enableBottomSheetMode, - animationSpec = animationSpec - ) - - Navigator( - screen = defaultScreen, - onBackPressed = null, - disposeBehavior = NavigatorDisposeBehavior(disposeSteps = false) - ) { navigator -> - val bottomSheetNavigator = remember { - BottomSheetNavigator( - navigator = navigator, - sheetState = sheetState, - coroutineScope = coroutineScope - ) - } - - val scaleValue = remember(sheetState) { - derivedStateOf { - val state = sheetState.anchoredDraggableState - val min = state.anchors.minAnchor() - val max = state.anchors.maxAnchor() - val offset = state.offset - - val fraction = offset.normalize(min, max) - val scale = 0.8f + 0.2f * fraction - scale.takeIf { !it.isNaN() } ?: 1f - } - } - - CompositionLocalProvider(LocalBottomSheetNavigator provides bottomSheetNavigator) { - ModalBottomSheetLayout( - modifier = modifier, - scrimColor = scrimColor, - sheetState = sheetState, - sheetShape = sheetShape, - sheetElevation = sheetElevation, - sheetBackgroundColor = sheetBackgroundColor, - sheetContentColor = sheetContentColor, - sheetGesturesEnabled = sheetGesturesEnabled, - sheetContent = { - BackHandler(enabled = bottomSheetNavigator.isVisible) { - if (sheetState.currentValue == ModalBottomSheetValue.Expanded) { - bottomSheetNavigator.back() - } else { - coroutineScope.launch { sheetState.hide() } - } - } - key("SheetContent") { - sheetContent(bottomSheetNavigator) - } - }, - content = { - Surface(color = Color.Black) { - Box(modifier = Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = scaleValue.value - scaleY = scaleX - } - .clip(RoundedCornerShape(32.dp)), - content = { content(sheetState) } - ) - } - } - ) - } - } -} - -class BottomSheetNavigator internal constructor( - private val navigator: Navigator, - private val coroutineScope: CoroutineScope, - val sheetState: ModalBottomSheetState, -) : Stack by navigator, EnhanceNavigator { - - val isVisible: Boolean by derivedStateOf { - if (!sheetState.enabled) { - return@derivedStateOf items.size > 1 - } - - if (!sheetState.isSkipHalfExpanded) { - return@derivedStateOf sheetState.progress( - from = ModalBottomSheetValue.Hidden, - to = ModalBottomSheetValue.HalfExpanded - ) >= 0.95 - } - - sheetState.progress( - from = ModalBottomSheetValue.Hidden, - to = ModalBottomSheetValue.Expanded - ) >= 0.95 - } - - fun hide() { - if (isVisible) { - coroutineScope.launch { sheetState.hide() } - } - } - - fun show() { - if (!isVisible) { - coroutineScope.launch { sheetState.show() } - } - } - - override fun preBack(currentScreen: Screen?): Boolean { - // 若当前只剩一个页面,则不清空元素了 - if (items.size <= 1) { - hide() - return false - } - return true - } - - override fun postBack(fromScreen: Screen?) { - hide() - } - - override fun postJump(fromScreen: Screen?, toScreen: Screen): Boolean { - show() - return true - } - - override fun getNavigator(): Navigator = navigator -} - -private fun Float.normalize(minValue: Float, maxValue: Float): Float { - val min = minOf(minValue, maxValue) - val max = maxOf(minValue, maxValue) - - if (min == max) return 0f - if (this <= min) return 0f - if (this >= max) return 1f - - return ((this - min) / (max - min)) - .coerceIn(0f, 1f) -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt b/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt new file mode 100644 index 000000000..dfa82a84b --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt @@ -0,0 +1,105 @@ +package com.lalilu.component.base + +import androidx.compose.material.BottomSheetState +import androidx.compose.material.BottomSheetValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import com.lalilu.component.override.ModalBottomSheetState +import com.lalilu.component.override.ModalBottomSheetValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +interface EnhanceSheetState { + val isVisible: Boolean + fun show() + fun hide() + fun progress(from: Any, to: Any): Float + fun dispatch(rawValue: Float) {} + fun settle(velocity: Float) {} +} + +val LocalEnhanceSheetState = compositionLocalOf { null } + +class EnhanceBottomSheetState( + private val bottomSheetState: BottomSheetState, + private val scope: CoroutineScope, +) : EnhanceSheetState { + override val isVisible: Boolean + get() = bottomSheetState.isExpanded + + override fun show() { + scope.launch { + if (bottomSheetState.isCollapsed) { + bottomSheetState.expand() + } + } + } + + override fun hide() { + scope.launch { + if (bottomSheetState.isExpanded) { + bottomSheetState.collapse() + } + } + } + + override fun progress(from: Any, to: Any): Float { + if (from !is BottomSheetValue || to !is BottomSheetValue) { + return 0f + } + return bottomSheetState.progress(from, to) + } +} + + +class EnhanceModalSheetState( + private val sheetState: ModalBottomSheetState, + private val scope: CoroutineScope +) : EnhanceSheetState { + override val isVisible: Boolean by derivedStateOf { + if (!sheetState.isSkipHalfExpanded) { + return@derivedStateOf sheetState.progress( + from = ModalBottomSheetValue.Hidden, + to = ModalBottomSheetValue.HalfExpanded + ) >= 0.95 + } + + sheetState.progress( + from = ModalBottomSheetValue.Hidden, + to = ModalBottomSheetValue.Expanded + ) >= 0.95 + } + + override fun hide() { + if (isVisible) { + scope.launch { sheetState.hide() } + } + } + + override fun show() { + if (!isVisible) { + scope.launch { sheetState.show() } + } + } + + override fun progress(from: Any, to: Any): Float { + if (from !is ModalBottomSheetValue || to !is ModalBottomSheetValue) { + return 0f + } + return sheetState.progress(from, to) + } + + @OptIn(ExperimentalMaterialApi::class) + override fun dispatch(rawValue: Float) { + sheetState.anchoredDraggableState.dispatchRawDelta(rawValue) + } + + @OptIn(ExperimentalMaterialApi::class) + override fun settle(velocity: Float) { + scope.launch { + sheetState.anchoredDraggableState.settle(velocity) + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt index c9b21487b..2b8a9f9c0 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt @@ -23,6 +23,7 @@ private class ExtraComponentStack { } } +@Deprecated("移除") interface ScreenExtraBarFactory { private val stack: ExtraComponentStack get() = ExtraComponentStack.getInstance(this) diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt new file mode 100644 index 000000000..92f48fd01 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt @@ -0,0 +1,6 @@ +package com.lalilu.component.base.screen + +sealed interface ScreenType { + interface List : ScreenType + interface Empty : ScreenType +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/extension/BottomSheetNestedScrollInterceptor.kt b/component/src/main/java/com/lalilu/component/extension/BottomSheetNestedScrollInterceptor.kt deleted file mode 100644 index 27ed62899..000000000 --- a/component/src/main/java/com/lalilu/component/extension/BottomSheetNestedScrollInterceptor.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.lalilu.component.extension - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.unit.Velocity -import kotlin.math.abs - -class BottomSheetNestedScrollInterceptor : NestedScrollConnection { - private var arrivedBoundarySource: NestedScrollSource? = null - - override fun onPreScroll( - available: Offset, - source: NestedScrollSource - ): Offset { - // 重置到达边界时的状态 - if (source == NestedScrollSource.Drag && arrivedBoundarySource == NestedScrollSource.Fling) { - arrivedBoundarySource = null - } - - return super.onPreScroll(available, source) - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - // 子布局无法消费完即到达边界 - if (arrivedBoundarySource == null && abs(available.y) > 0) { - arrivedBoundarySource = source - } - - // 根据到达边界时的子布局消费情况决定是否消费 - if (arrivedBoundarySource == NestedScrollSource.Fling) { - return available - } - - return Offset.Zero - } - - override suspend fun onPostFling( - consumed: Velocity, - available: Velocity - ): Velocity { - arrivedBoundarySource = null - return super.onPostFling(consumed, available) - } -} - -@Composable -fun rememberBottomSheetNestedScrollInterceptor(): BottomSheetNestedScrollInterceptor { - return remember { BottomSheetNestedScrollInterceptor() } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt index d6657f923..9a387eca9 100644 --- a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -4,49 +4,58 @@ import cafe.adriel.voyager.core.screen.Screen import com.zhangke.krouter.KRouter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext sealed interface NavIntent { + data class Jump(val screen: Screen) : NavIntent data class Push(val screen: Screen) : NavIntent data class Replace(val screen: Screen) : NavIntent data object Pop : NavIntent } object AppRouter : CoroutineScope { - override val coroutineContext: CoroutineContext = Dispatchers.IO + override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() + private val sharedFlow = MutableSharedFlow>() - private val channel = Channel( - capacity = Int.MAX_VALUE, - onBufferOverflow = BufferOverflow.DROP_LATEST, - ) - - val intentFlow = channel.receiveAsFlow() + fun bindFor(baseUrl: String = "") = sharedFlow + .filter { it.first == baseUrl } + .map { it.second } + @Deprecated("弃用") fun intent(intent: NavIntent) = launch { - channel.send(intent) + sharedFlow.emit("" to intent) } + @Deprecated("弃用") fun intent(block: AppRouter.() -> NavIntent?) = launch { val i = this@AppRouter.block() ?: return@launch - channel.send(i) + sharedFlow.emit("" to i) + } + + fun intent(key: String, intent: NavIntent) = launch { + sharedFlow.emit(key to intent) } - fun route(baseUrl: String): Request { - return Request(baseUrl) + fun route(baseUrl: String): Request = route("", baseUrl) + fun route(key: String, baseUrl: String): Request { + return Request(key, baseUrl) } class Request internal constructor( + private val key: String, private val baseUrl: String, private val params: MutableMap = mutableMapOf() ) { fun with(key: String, value: T) = apply { params[key] = value } - fun push() = requestResult()?.let { intent(NavIntent.Push(it)) } - fun replace() = requestResult()?.let { intent(NavIntent.Replace(it)) } + fun jump() = requestResult()?.let { intent(key, NavIntent.Jump(it)) } + fun push() = requestResult()?.let { intent(key, NavIntent.Push(it)) } + fun replace() = requestResult()?.let { intent(key, NavIntent.Replace(it)) } fun get() = requestResult() private fun requestResult(): Screen? = diff --git a/component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt b/component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt new file mode 100644 index 000000000..c0ee327e4 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt @@ -0,0 +1,16 @@ +package com.lalilu.component.navigation + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.screen.Screen +import com.lalilu.component.base.screen.ScreenType + +data object EmptyScreen : Screen, ScreenType.Empty { + + @Composable + override fun Content() { + Spacer(modifier = Modifier.fillMaxSize()) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt new file mode 100644 index 000000000..ddd7a75aa --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt @@ -0,0 +1,86 @@ +package com.lalilu.component.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.CurrentScreen +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior +import com.lalilu.component.base.EnhanceBottomSheetState +import com.lalilu.component.base.EnhanceModalSheetState +import com.lalilu.component.base.LocalEnhanceSheetState +import com.lalilu.component.base.TabScreen + +@Composable +fun HostNavigator( + startScreen: Screen, + content: @Composable (Navigator) -> Unit = { CurrentScreen() } +) { + val enhanceSheetState = LocalEnhanceSheetState.current + + Navigator( + screen = startScreen, + onBackPressed = null, + disposeBehavior = NavigatorDisposeBehavior( + disposeSteps = false, + disposeNestedNavigators = false + ) + ) { navigator -> + LaunchedEffect(navigator) { + AppRouter.bindFor().collect { intent -> + when (intent) { + NavIntent.Pop -> navigator.pop() + is NavIntent.Push -> navigator.push(intent.screen) + is NavIntent.Replace -> navigator.replace(intent.screen) + is NavIntent.Jump -> { + val screen = intent.screen + when { + // Tab类型页面 + screen is TabScreen -> { + // 移除栈顶的页面,直到栈顶的页面是startScreen + navigator.popUntil { it == startScreen } + + // 如果栈顶的页面与目标页面不同则替换 + if (navigator.lastItemOrNull != screen) { + navigator.push(screen) + } + } + + // 不同类型的页面,添加至导航栈 + navigator.lastItemOrNull + ?.let { !it::class.isInstance(screen) } + ?: true -> navigator.push(screen) + + // 同类型页面,替换 + else -> navigator.replace(screen) + } + + // 尝试跳转的时候若底部sheet不可见,则显示 + if (enhanceSheetState is EnhanceModalSheetState && !enhanceSheetState.isVisible) { + enhanceSheetState.show() + } + } + } + } + } + + BackHandler( + enabled = when (enhanceSheetState) { + is EnhanceBottomSheetState -> !enhanceSheetState.isVisible && navigator.canPop + is EnhanceModalSheetState -> enhanceSheetState.isVisible + else -> false + } + ) { + enhanceSheetState.apply { + when (this) { + is EnhanceBottomSheetState -> navigator.pop() + is EnhanceModalSheetState -> if (!navigator.pop()) hide() + else -> {} + } + } + } + + content(navigator) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt new file mode 100644 index 000000000..632799d25 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt @@ -0,0 +1,57 @@ +package com.lalilu.component.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.Navigator +import com.lalilu.component.base.screen.ScreenType + +val LocalNavigatorParent = compositionLocalOf { null } +val LocalNavigatorBaseScreen = compositionLocalOf { error("No base screen found") } +val LocalNavigatorKey = compositionLocalOf { "" } + +private val screenNavigatorMap = mutableStateMapOf() + +@Composable +fun Navigator.currentScreen(): State { + return remember(this) { + derivedStateOf { + var screen = lastItemOrNull + + // 若该页面存在指向嵌套页面的路由 + while (screenNavigatorMap[screen] != null) { + val temp = screenNavigatorMap[screen] + ?.lastItemOrNull + + if (temp is ScreenType.Empty) break + else screen = temp + } + + screen + } + } +} + +@Composable +fun Navigator.previousScreen(): State { + return remember(this) { + derivedStateOf { + val screens = items + .flatMap { screenNavigatorMap[it]?.items ?: listOf(it) } + + screens.getOrNull(screens.size - 2) + } + } +} + +@Composable +fun RegisterNavigator(screen: Screen, navigator: Navigator) { + LaunchedEffect(screen, navigator) { + screenNavigatorMap[screen] = navigator + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt similarity index 61% rename from app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt rename to component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt index b954ee724..923c4a658 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -1,10 +1,18 @@ -package com.lalilu.lmusic.compose.component.navigate +package com.lalilu.component.navigation +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow @@ -27,12 +36,8 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton -import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -48,112 +53,96 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.stack.Stack -import com.lalilu.R -import com.lalilu.component.base.BottomSheetNavigator -import com.lalilu.component.base.CustomScreen -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.LocalBottomSheetNavigator +import cafe.adriel.voyager.navigator.LocalNavigator +import com.lalilu.component.R import com.lalilu.component.base.ScreenAction +import com.lalilu.component.base.ScreenBarComponent import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.extension.dayNightTextColor +import com.lalilu.component.extension.toColorFilter -sealed class NavigationBarState { - data class ForTabScreen(val tabScreens: List) : NavigationBarState() - data class ForDynamicScreen(val screen: DynamicScreen?) : NavigationBarState() - data class ForScreen(val screen: Screen?) : NavigationBarState() -} -@Composable -fun rememberNavigationBarState( - tabScreens: () -> List, - currentScreen: () -> Screen?, -): State { - return remember { - derivedStateOf { - when (val screen = currentScreen()) { - is TabScreen -> NavigationBarState.ForTabScreen(tabScreens()) - is DynamicScreen -> NavigationBarState.ForDynamicScreen(screen) - null -> NavigationBarState.ForScreen(null) - else -> NavigationBarState.ForScreen(screen) - } - } - } +sealed interface NavigationBarType { + data object TabBar : NavigationBarType + data object CommonBar : NavigationBarType + data class NormalBar(val barComponent: ScreenBarComponent) : NavigationBarType } @Composable -fun rememberPreviousScreenTitleRes( - stack: Stack?, - currentScreen: Screen? -): Int { - val previousScreen by remember(currentScreen) { - derivedStateOf { stack?.items?.getOrNull(stack.size - 2) } - } - - return when (previousScreen) { - is CustomScreen -> (previousScreen as CustomScreen).getScreenInfo()?.title - is ScreenInfoFactory -> (previousScreen as ScreenInfoFactory).provideScreenInfo().title - else -> null - } ?: R.string.bottom_sheet_navigate_back -} - - -@Composable -fun NavigationBar( +fun NavigationSmartBar( modifier: Modifier = Modifier, - tabScreens: () -> List, - currentScreen: () -> Screen?, - navigator: BottomSheetNavigator? = LocalBottomSheetNavigator.current ) { - val navigationBarState = - rememberNavigationBarState(tabScreens = tabScreens, currentScreen = currentScreen) + val currentScreen = LocalNavigator.current + ?.currentScreen() + ?.value - AnimatedContent( - modifier = modifier.fillMaxWidth(), - targetState = navigationBarState.value, - label = "NavigateBarTransform" - ) { state -> - when (state) { - is NavigationBarState.ForTabScreen -> { - NavigateTabBar( - tabScreens = state::tabScreens::get, - currentScreen = currentScreen, - onSelectTab = { navigator?.jump(it) } - ) - } + val previousScreen = LocalNavigator.current + ?.previousScreen() + ?.value - is NavigationBarState.ForDynamicScreen -> { - val actions = state.screen - ?.registerActions() - ?: emptyList() + val previousTitle = (previousScreen as? ScreenInfoFactory)?.provideScreenInfo() + ?.let { stringResource(id = it.title) } + ?: "返回" - val previousTitle = rememberPreviousScreenTitleRes( - stack = navigator, - currentScreen = currentScreen() - ) + val mainContent = (currentScreen as? ScreenBarFactory)?.content() + val tabScreenRoutes = remember { + listOf("/pages/home", "/pages/playlist", "/pages/search") + } - NavigateCommonBar( - previousTitle = { previousTitle }, - screenActions = { actions } - ) - } + val tabScreens = remember(tabScreenRoutes) { + tabScreenRoutes.mapNotNull { AppRouter.route(it).get() as? TabScreen } + } - is NavigationBarState.ForScreen -> { - val actions = (state.screen as? ScreenActionFactory) - ?.provideScreenActions() - ?: emptyList() + val navigationBar: NavigationBarType = remember(mainContent, currentScreen) { + when { + mainContent != null -> NavigationBarType.NormalBar(mainContent) + currentScreen is TabScreen -> NavigationBarType.TabBar + else -> NavigationBarType.CommonBar + } + } - val previousTitle = rememberPreviousScreenTitleRes( - stack = navigator, - currentScreen = currentScreen() - ) + AnimatedContent( + modifier = modifier.fillMaxWidth(), + transitionSpec = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) togetherWith slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Down) + }, + contentAlignment = Alignment.BottomCenter, + targetState = navigationBar, + label = "" + ) { item -> + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.background.copy(0.95f)) + .navigationBarsPadding() + .height(56.dp) + ) { + when (item) { + is NavigationBarType.NormalBar -> { + item.barComponent.content() + } - NavigateCommonBar( - previousTitle = { previousTitle }, - screenActions = { actions } - ) + is NavigationBarType.TabBar -> { + NavigateTabBar( + currentScreen = { currentScreen }, + tabScreens = { tabScreens }, + onSelectTab = { AppRouter.intent(NavIntent.Jump(it)) } + ) + } + + is NavigationBarType.CommonBar -> { + NavigateCommonBar( + modifier = Modifier.fillMaxSize(), + previousTitle = previousTitle, + currentScreen = currentScreen + ) + } } } } @@ -180,7 +169,7 @@ fun NavigateTabBar( NavigateItem( modifier = Modifier.weight(1f), - titleRes = { screenInfo?.title ?: R.string.bottom_sheet_navigate_back }, + titleRes = { screenInfo?.title ?: R.string.empty_screen_no_items }, iconRes = { screenInfo?.icon ?: R.drawable.ic_close_line }, isSelected = { currentScreen() === it }, onClick = { onSelectTab(it) } @@ -192,37 +181,37 @@ fun NavigateTabBar( @Composable fun NavigateCommonBar( modifier: Modifier = Modifier, - previousTitle: () -> Int, - screenActions: () -> List, - navigator: BottomSheetNavigator? = LocalBottomSheetNavigator.current + previousTitle: String, + currentScreen: Screen? ) { val itemFitImePadding = remember { mutableStateOf(false) } + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val screenActions = (currentScreen as? ScreenActionFactory)?.provideScreenActions() Row( modifier = modifier + .fillMaxWidth() .clickable(enabled = false) {} - .run { if (itemFitImePadding.value) this.imePadding() else this } - .height(52.dp) - .fillMaxWidth(), + .run { if (itemFitImePadding.value) this.imePadding() else this }, verticalAlignment = Alignment.CenterVertically, ) { - val contentColor = - contentColorFor(backgroundColor = MaterialTheme.colors.background) TextButton( modifier = Modifier.fillMaxHeight(), shape = RectangleShape, contentPadding = PaddingValues(start = 16.dp, end = 24.dp), - colors = ButtonDefaults.textButtonColors(contentColor = contentColor), - onClick = { navigator?.back() } + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colors.onBackground + ), + onClick = { onBackPressedDispatcher?.onBackPressed() } ) { Image( painter = painterResource(id = R.drawable.ic_arrow_left_s_line), contentDescription = "backButtonIcon", - colorFilter = ColorFilter.tint(color = contentColor) + colorFilter = MaterialTheme.colors.onBackground.toColorFilter() ) - AnimatedContent(targetState = previousTitle(), label = "") { + AnimatedContent(targetState = previousTitle, label = "") { Text( - text = stringResource(id = it), + text = it, fontSize = 14.sp ) } @@ -232,14 +221,15 @@ fun NavigateCommonBar( modifier = Modifier .weight(1f) .fillMaxHeight(), - targetState = screenActions(), + transitionSpec = { fadeIn() togetherWith fadeOut() }, + targetState = screenActions, label = "ExtraActions" ) { actions -> LazyRow( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.End ) { - items(items = actions) { + items(items = actions ?: emptyList()) { if (it is ScreenAction.ComposeAction) { it.content.invoke() return@items @@ -278,26 +268,6 @@ fun NavigateCommonBar( } } } - - if (actions.isEmpty()) { - item { - TextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(start = 20.dp, end = 28.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = Color(0x25FE4141), - contentColor = Color(0xFFFE4141) - ), - onClick = { navigator?.hide() } - ) { - Text( - text = stringResource(id = R.string.bottom_sheet_navigate_close), - fontSize = 14.sp - ) - } - } - } } } } @@ -361,4 +331,4 @@ fun NavigateItem( } } } -} +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt new file mode 100644 index 000000000..d73caba51 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt @@ -0,0 +1,68 @@ +package com.lalilu.component.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import cafe.adriel.voyager.core.annotation.InternalVoyagerApi +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.CurrentScreen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.NavigatorContent +import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior +import cafe.adriel.voyager.navigator.OnBackPressed +import cafe.adriel.voyager.navigator.compositionUniqueId +import com.lalilu.component.base.EnhanceBottomSheetState +import com.lalilu.component.base.EnhanceModalSheetState +import com.lalilu.component.base.LocalEnhanceSheetState + +@OptIn(InternalVoyagerApi::class) +@Composable +fun Screen.NestedNavigator( + startScreen: Screen, + disposeBehavior: NavigatorDisposeBehavior = NavigatorDisposeBehavior(disposeSteps = false), + onBackPressed: OnBackPressed = null, + key: String = compositionUniqueId(), + content: NavigatorContent = { CurrentScreen() } +) { + val parentNavigator = LocalNavigator.current + val enhanceSheetState = LocalEnhanceSheetState.current + + CompositionLocalProvider( + LocalNavigatorParent provides parentNavigator, + LocalNavigatorKey provides key, + LocalNavigatorBaseScreen provides this + ) { + Navigator( + screen = startScreen, + disposeBehavior = disposeBehavior, + onBackPressed = onBackPressed, + key = key, + ) { navigator -> + RegisterNavigator( + screen = this, + navigator = navigator + ) + + if (onBackPressed == null) { + BackHandler( + enabled = when (enhanceSheetState) { + is EnhanceBottomSheetState -> !enhanceSheetState.isVisible && navigator.canPop + is EnhanceModalSheetState -> enhanceSheetState.isVisible && navigator.canPop + else -> false + } + ) { + enhanceSheetState.apply { + when (this) { + is EnhanceBottomSheetState -> navigator.pop() + is EnhanceModalSheetState -> if (!navigator.pop()) hide() + else -> {} + } + } + } + } + + content(navigator) + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/ScreenTransition.kt b/component/src/main/java/com/lalilu/component/navigation/ScreenTransition.kt new file mode 100644 index 000000000..a7b9abcc9 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/ScreenTransition.kt @@ -0,0 +1,92 @@ +package com.lalilu.component.navigation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.core.annotation.InternalVoyagerApi +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.stack.StackEvent +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.transitions.ScreenTransition +import cafe.adriel.voyager.transitions.ScreenTransitionContent + +@ExperimentalVoyagerApi +@OptIn(InternalVoyagerApi::class) +@Composable +fun CustomScreenTransition( + navigator: Navigator, + transition: AnimatedContentTransitionScope.() -> ContentTransform, + modifier: Modifier = Modifier, + disposeScreenAfterTransitionEnd: Boolean = false, + content: ScreenTransitionContent = { + navigator.saveableState("transition", it) { it.Content() } + } +) { + val screenCandidatesToDispose = rememberSaveable(saver = screenCandidatesToDisposeSaver()) { + mutableStateOf(emptySet()) + } + + val currentScreens = navigator.items + + if (disposeScreenAfterTransitionEnd) { + DisposableEffect(currentScreens) { + onDispose { + val newScreenKeys = navigator.items.map { it.key } + screenCandidatesToDispose.value += currentScreens.filter { it.key !in newScreenKeys } + } + } + } + + AnimatedContent( + targetState = navigator.lastItem, + transitionSpec = { + val contentTransform = transition() + + val sourceScreenTransition = when (navigator.lastEvent) { + StackEvent.Pop, StackEvent.Replace -> initialState + else -> targetState + } as? ScreenTransition + + val screenEnterTransition = sourceScreenTransition?.enter(navigator.lastEvent) + ?: contentTransform.targetContentEnter + + val screenExitTransition = sourceScreenTransition?.exit(navigator.lastEvent) + ?: contentTransform.initialContentExit + + screenEnterTransition togetherWith screenExitTransition + }, + modifier = modifier + ) { screen -> + if (this.transition.targetState == this.transition.currentState && disposeScreenAfterTransitionEnd) { + LaunchedEffect(Unit) { + val newScreens = navigator.items.map { it.key } + val screensToDispose = + screenCandidatesToDispose.value.filterNot { it.key in newScreens } + if (screensToDispose.isNotEmpty()) { + screensToDispose.forEach { navigator.dispose(it) } + navigator.clearEvent() + } + screenCandidatesToDispose.value = emptySet() + } + } + + content(screen) + } +} + +private fun screenCandidatesToDisposeSaver(): Saver>, List> { + return Saver( + save = { it.value.toList() }, + restore = { mutableStateOf(it.toSet()) } + ) +} diff --git a/component/src/main/java/com/lalilu/component/navigation/SheetController.kt b/component/src/main/java/com/lalilu/component/navigation/SheetController.kt deleted file mode 100644 index 6756880be..000000000 --- a/component/src/main/java/com/lalilu/component/navigation/SheetController.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.lalilu.component.navigation - -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.stack.Stack -import cafe.adriel.voyager.navigator.Navigator -import com.lalilu.component.base.TabScreen - -interface EnhanceNavigator : Stack { - - /** - * 跳转前的操作 - * - * @param targetScreen 目标跳转页面 - * @return 返回值决定是否继续下一步操作 - */ - fun preJump(targetScreen: Screen): Boolean = true - - /** - * 实际进行跳转的操作 - * - * @param targetScreen 目标跳转页面 - * @return 返回值决定是否继续下一步操作 - */ - fun doJump(targetScreen: Screen): Boolean { - when { - // Tab类型页面 - targetScreen is TabScreen -> { - val firstItem = items.firstOrNull()?.let { listOf(it) } ?: emptyList() - replaceAll(firstItem) - - - if (targetScreen != firstItem) { - push(targetScreen) - } - } - - // 不同类型的页面,添加至导航栈 - lastItemOrNull - ?.let { it::class.java != targetScreen::class.java } - ?: true -> push(targetScreen) - - // 同类型页面,替换 - else -> replace(targetScreen) - } - return true - } - - /** - * 执行跳转后的操作,可在此进行撤销的逻辑 - * - * @param fromScreen 跳转起始时的页面 - * @param toScreen 跳转目标页面 - * @return 返回值决定是否撤销跳转的操作,返回true表示不撤销,返回false表示撤销 - */ - fun postJump(fromScreen: Screen?, toScreen: Screen): Boolean = true - - /** - * 进行恢复跳转操作前页面的操作 - * - * @param fromScreen 跳转起始时的页面 - */ - fun resetTo(fromScreen: Screen?) {} - - /** - * 获取当前显示的页面 - * - * @return 当前用户可见的页面 - */ - fun getCurrentScreen(): Screen? = lastItemOrNull - - /** - * 执行跳转操作 - * - * @param targetScreen 目标跳转页面 - */ - fun jump(targetScreen: Screen) { - val currentScreen = getCurrentScreen() - - if (!preJump(targetScreen)) return - - if (!doJump(targetScreen)) return - - if (postJump(currentScreen, targetScreen)) return - - resetTo(currentScreen) - } - - /** - * 执行返回操作前的操作 - * - * @return 返回值决定是否继续下一步操作 - */ - fun preBack(currentScreen: Screen?): Boolean = true - - /** - * 执行返回操作 - * - * @return 返回值决定是否继续下一步操作 - */ - fun doBack(currentScreen: Screen?): Boolean { - return pop().not() - } - - - /** - * 执行返回操作后的操作 - */ - fun postBack(fromScreen: Screen?) {} - - /** - * 执行返回操作 - */ - fun back() { - val currentScreen = getCurrentScreen() - if (!preBack(currentScreen)) return - if (!doBack(currentScreen)) return - postBack(currentScreen) - } - - fun getNavigator(): Navigator -} - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c68ce197..783407ab1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ ksp_version = "2.0.0-1.0.22" #serialization_json_version = "1.6.0" koin_version = "3.5.6" -compose_bom_alpha_version = "2024.06.00-alpha01" +compose_bom_alpha_version = "2024.08.00-alpha01" compose_bom_version = "2024.06.00" accompanist_version = "0.32.0" voyager = "1.1.0-beta02" @@ -140,7 +140,7 @@ compose = [ "compose-foundation", "compose-foundation-layout", "compose-material", - # "compose-material3", + "compose-material3", "compose-material3-window-size", "compose-runtime-livedata", ] diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt index ac0d922ba..73429db12 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt @@ -13,12 +13,13 @@ import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.koin.getScreenModel import com.lalilu.component.Songs -import com.lalilu.lalbum.R import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.LoadingScaffold import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState +import com.lalilu.component.base.screen.ScreenType +import com.lalilu.lalbum.R import com.lalilu.lalbum.component.AlbumCoverCard import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LAlbum @@ -29,7 +30,7 @@ import kotlinx.coroutines.launch data class AlbumDetailScreen( private val albumId: String -) : DynamicScreen() { +) : DynamicScreen(), ScreenType.List { override fun getScreenInfo(): ScreenInfo = ScreenInfo( title = R.string.album_screen_title, From 6527de5d88f01ef5579affa7e00fd84b13d6f548 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 19 Aug 2024 14:17:53 +0800 Subject: [PATCH 061/213] =?UTF-8?q?[refactor]=E8=A7=A3=E8=80=A6=E5=90=88Li?= =?UTF-8?q?stDetail=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=E8=87=B3ListDetail?= =?UTF-8?q?Container=EF=BC=8C=E5=AE=8C=E5=96=84AppRouter=E7=9A=84=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/LayoutWrapper.kt | 2 +- .../new_screen/detail/SongDetailScreen.kt | 3 +- .../compose/screen/guiding/GuidingScreen.kt | 7 +- .../compose/screen/songs/SongsScreen.kt | 198 ++++++++++-------- .../compose/screen/songs/SubsSongsScreen.kt | 153 -------------- component/build.gradle.kts | 2 +- .../main/java/com/lalilu/component/Songs.kt | 16 +- .../component/base/screen/ScreenType.kt | 4 +- .../lalilu/component/navigation/AppRouter.kt | 116 ++++++++-- .../component/navigation}/CustomTransition.kt | 3 +- .../component/navigation/HostNavigator.kt | 40 +--- .../navigation/ListDetailContainer.kt | 89 ++++++++ .../component/navigation/NavigationContext.kt | 5 + 13 files changed, 322 insertions(+), 316 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SubsSongsScreen.kt rename {app/src/main/java/com/lalilu/lmusic/compose/component => component/src/main/java/com/lalilu/component/navigation}/CustomTransition.kt (95%) create mode 100644 component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt index 5323e5891..4368db1d7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt @@ -38,7 +38,7 @@ import com.lalilu.component.extension.DialogWrapper import com.lalilu.component.extension.DynamicTipsHost import com.lalilu.component.navigation.HostNavigator import com.lalilu.component.navigation.NavigationSmartBar -import com.lalilu.lmusic.compose.component.CustomTransition +import com.lalilu.component.navigation.CustomTransition import com.lalilu.lmusic.compose.new_screen.HomeScreen import com.lalilu.lmusic.compose.screen.playing.PlayingLayout import com.lalilu.lmusic.compose.screen.playing.PlayingLayoutExpended diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index 5c6325ab6..c3af03a52 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -30,6 +30,7 @@ import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.base.screen.ScreenType import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.override.ModalBottomSheetValue import com.lalilu.lmedia.LMedia @@ -45,7 +46,7 @@ import com.zhangke.krouter.annotation.Param @Destination("/song/detail") data class SongDetailScreen( @Param val mediaId: String -) : Screen, ScreenActionFactory, ScreenInfoFactory { +) : Screen, ScreenActionFactory, ScreenInfoFactory, ScreenType.Detail { override val key: ScreenKey = "${super.key}:$mediaId" @Composable diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt index 44d8a6eb0..ed4ae4211 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt @@ -3,7 +3,6 @@ package com.lalilu.lmusic.compose.screen.guiding import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -51,7 +50,7 @@ import com.lalilu.R import com.lalilu.component.base.CustomScreen import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.extension.rememberIsPad -import com.lalilu.lmusic.compose.component.CustomTransition +import com.lalilu.component.navigation.CustomTransition @Composable fun GuidingScreen() { @@ -133,9 +132,7 @@ fun GuidingScreen() { navigatorState.value = navigator CustomTransition( navigator = navigator - ) { - it.Content() - } + ) } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 1df7d2f48..b2a9deff5 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -1,29 +1,38 @@ package com.lalilu.lmusic.compose.screen.songs -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R -import com.lalilu.component.base.LocalWindowSize +import com.lalilu.component.Songs +import com.lalilu.component.SongsScreenModel +import com.lalilu.component.base.NavigatorHeader +import com.lalilu.component.base.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.EmptyScreen -import com.lalilu.component.navigation.NavIntent -import com.lalilu.component.navigation.NestedNavigator -import com.lalilu.lmusic.compose.component.CustomTransition +import com.lalilu.component.extension.LazyListScrollToHelper +import com.lalilu.component.extension.SelectAction +import com.lalilu.component.extension.rememberLazyListScrollToHelper +import com.lalilu.component.extension.singleViewModel +import com.lalilu.lhistory.SortRuleLastPlayTime +import com.lalilu.lhistory.SortRulePlayCount +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lmusic.viewmodel.HistoryViewModel +import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.lalilu.lplaylist.PlaylistActions import com.zhangke.krouter.KRouter import com.zhangke.krouter.annotation.Destination import com.zhangke.krouter.annotation.Param @@ -34,7 +43,7 @@ data class SongsScreen( private val router: String = "/pages/songs", private val title: String? = null, private val mediaIds: List = emptyList() -) : Screen, ScreenInfoFactory, ScreenType.List { +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenType.List { @Composable override fun provideScreenInfo(): ScreenInfo = remember { @@ -44,84 +53,105 @@ data class SongsScreen( ) } + @Transient + private var scrollHelper: LazyListScrollToHelper? = null + + @Transient + private var songsSM: SongsScreenModel? = null + + @Composable - override fun Content() { - val windowSizeClass = LocalWindowSize.current - val isPad = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded + override fun provideScreenActions(): List { + val playingVM: PlayingViewModel = singleViewModel() - // TODO 待检查从下一级路由返回时该嵌套路由被重置的问题 - NestedNavigator( - key = router, - startScreen = SubsSongsScreen(title, mediaIds), - ) { navigator -> - LaunchedEffect(navigator) { - AppRouter.bindFor(router).collect { intent -> - when (intent) { - NavIntent.Pop -> navigator.pop() - is NavIntent.Push -> navigator.push(intent.screen) - is NavIntent.Replace -> navigator.replace(intent.screen) - is NavIntent.Jump -> { - // 此处完善自定义跳转逻辑 - navigator.push(intent.screen) - } + return remember { + listOf( + ScreenAction.StaticAction( + title = R.string.screen_action_sort, + icon = R.drawable.ic_sort_desc, + color = Color(0xFF1793FF), + onAction = { songsSM?.showSortPanel?.value = true } + ), + ScreenAction.StaticAction( + title = R.string.screen_action_locate_playing_item, + icon = R.drawable.ic_focus_3_line, + color = Color(0xFF9317FF), + onAction = { + val playingId = playingVM.playing.value?.mediaId ?: return@StaticAction + scrollHelper?.scrollToItem(playingId) } - } - } + ), + ) + } + } - val listContent = remember(navigator) { - movableContentOf { - navigator.items - .firstOrNull { it is ScreenType.List } - ?.let { - navigator.saveableState(key = "list", screen = it) { - it.Content() - } - } - } - } + @Composable + override fun Content() { + val listState: LazyListState = rememberLazyListState() + val songsSM = rememberScreenModel { SongsScreenModel() } + .also { this.songsSM = it } + val scrollHelper = rememberLazyListScrollToHelper(listState = listState) + .also { this.scrollHelper = it } + val historyVM: HistoryViewModel = singleViewModel() - val detailContent = remember { - movableContentOf { isPad -> - CustomTransition( - navigator = navigator, - content = { - when { - isPad && it is ScreenType.List -> EmptyScreen.Content() - !isPad && it is ScreenType.List -> listContent() - else -> navigator.saveableState( - screen = it, - key = "transition", - content = { it.Content() } - ) - } - } + Songs( + modifier = Modifier, + showAll = true, + mediaIds = emptyList(), + listState = listState, + songsSM = songsSM, + scrollToHelper = scrollHelper, + selectActions = { getAll -> + listOf( + SelectAction.StaticAction.SelectAll(getAll = getAll), + SelectAction.StaticAction.ClearAll, + PlaylistActions.addToPlaylistAction, + PlaylistActions.addToFavorite, + ) + }, + supportListAction = { + listOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.AddTime, + SortStaticAction.Duration, + SortRulePlayCount, + SortRuleLastPlayTime, + SortStaticAction.Shuffle + ) + }, + showPrefixContent = { it.value == SortRulePlayCount::class.java.name }, + headerContent = { + item { + NavigatorHeader( + title = title ?: "全部歌曲", + subTitle = "共 ${it.value.values.flatten().size} 首歌曲" ) } - } - - if (isPad) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(360.dp), - content = { listContent() } + }, + prefixContent = { item, sortRuleStr -> + var icon = -1 + var text = "" + when (sortRuleStr.value) { + SortRulePlayCount::class.java.name -> { + icon = R.drawable.headphone_fill + text = historyVM.requiteHistoryCountById(item.mediaId).toString() + } + } + if (icon != -1) { + Icon( + modifier = Modifier.size(10.dp), + painter = painterResource(id = icon), + contentDescription = "" ) - - Box( - modifier = Modifier - .fillMaxHeight() - .weight(1f), - content = { detailContent(true) } + } + if (text.isNotEmpty()) { + Text( + text = text, + fontSize = 10.sp ) } - } else { - detailContent(false) } - } + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SubsSongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SubsSongsScreen.kt deleted file mode 100644 index a51271482..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SubsSongsScreen.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.lalilu.lmusic.compose.screen.songs - -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import cafe.adriel.voyager.core.model.rememberScreenModel -import cafe.adriel.voyager.core.screen.Screen -import com.lalilu.R -import com.lalilu.component.Songs -import com.lalilu.component.SongsScreenModel -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenAction -import com.lalilu.component.base.screen.ScreenActionFactory -import com.lalilu.component.base.screen.ScreenInfo -import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.component.base.screen.ScreenType -import com.lalilu.component.extension.LazyListScrollToHelper -import com.lalilu.component.extension.SelectAction -import com.lalilu.component.extension.rememberLazyListScrollToHelper -import com.lalilu.component.extension.singleViewModel -import com.lalilu.lhistory.SortRuleLastPlayTime -import com.lalilu.lhistory.SortRulePlayCount -import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lmusic.viewmodel.HistoryViewModel -import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.lalilu.lplaylist.PlaylistActions -import com.zhangke.krouter.annotation.Destination - -@Destination("/pages/songs") -data class SubsSongsScreen( - private val title: String? = null, - private val mediaIds: List = emptyList() -) : Screen, ScreenActionFactory, ScreenInfoFactory, ScreenType.List { - - @Transient - private var scrollHelper: LazyListScrollToHelper? = null - - @Transient - private var songsSM: SongsScreenModel? = null - - - @Composable - override fun provideScreenInfo(): ScreenInfo = remember { - ScreenInfo( - title = R.string.screen_title_songs, - icon = R.drawable.ic_music_2_line - ) - } - - @Composable - override fun provideScreenActions(): List { - val playingVM: PlayingViewModel = singleViewModel() - - return remember { - listOf( - ScreenAction.StaticAction( - title = R.string.screen_action_sort, - icon = R.drawable.ic_sort_desc, - color = Color(0xFF1793FF), - onAction = { songsSM?.showSortPanel?.value = true } - ), - ScreenAction.StaticAction( - title = R.string.screen_action_locate_playing_item, - icon = R.drawable.ic_focus_3_line, - color = Color(0xFF9317FF), - onAction = { - val playingId = playingVM.playing.value?.mediaId ?: return@StaticAction - scrollHelper?.scrollToItem(playingId) - } - ), - ) - } - } - - @Composable - override fun Content() { - val listState: LazyListState = rememberLazyListState() - val songsSM = rememberScreenModel { SongsScreenModel() } - .also { this.songsSM = it } - val scrollHelper = rememberLazyListScrollToHelper(listState = listState) - .also { this.scrollHelper = it } - val historyVM: HistoryViewModel = singleViewModel() - - Songs( - modifier = Modifier, - showAll = true, - mediaIds = emptyList(), - listState = listState, - songsSM = songsSM, - scrollToHelper = scrollHelper, - selectActions = { getAll -> - listOf( - SelectAction.StaticAction.SelectAll(getAll = getAll), - SelectAction.StaticAction.ClearAll, - PlaylistActions.addToPlaylistAction, - PlaylistActions.addToFavorite, - ) - }, - supportListAction = { - listOf( - SortStaticAction.Normal, - SortStaticAction.Title, - SortStaticAction.AddTime, - SortStaticAction.Duration, - SortRulePlayCount, - SortRuleLastPlayTime, - SortStaticAction.Shuffle - ) - }, - showPrefixContent = { it.value == SortRulePlayCount::class.java.name }, - headerContent = { - item { - NavigatorHeader( - title = title ?: "全部歌曲", - subTitle = "共 ${it.value.values.flatten().size} 首歌曲" - ) - } - }, - prefixContent = { item, sortRuleStr -> - var icon = -1 - var text = "" - when (sortRuleStr.value) { - SortRulePlayCount::class.java.name -> { - icon = R.drawable.headphone_fill - text = historyVM.requiteHistoryCountById(item.mediaId).toString() - } - } - if (icon != -1) { - Icon( - modifier = Modifier.size(10.dp), - painter = painterResource(id = icon), - contentDescription = "" - ) - } - if (text.isNotEmpty()) { - Text( - text = text, - fontSize = 10.sp - ) - } - } - ) - } -} diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 7a20882e9..81e4ba13c 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -52,7 +52,7 @@ dependencies { // https://github.com/Calvin-LL/Reorderable // Apache-2.0 license api("sh.calvin.reorderable:reorderable:1.1.0") - api("com.github.cy745:AnyPopDialog-Compose:jitpack-SNAPSHOT") + api("com.github.cy745:AnyPopDialog-Compose:cb92c5b6dc") api("me.rosuh:AndroidFilePicker:1.0.1") api("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha13") api("com.github.cy745.KRouter:core:fcf40f4b15") diff --git a/component/src/main/java/com/lalilu/component/Songs.kt b/component/src/main/java/com/lalilu/component/Songs.kt index 5df00e929..50a9f9442 100644 --- a/component/src/main/java/com/lalilu/component/Songs.kt +++ b/component/src/main/java/com/lalilu/component/Songs.kt @@ -66,14 +66,12 @@ import com.lalilu.component.extension.rememberItemSelectHelper import com.lalilu.component.extension.rememberLazyListScrollToHelper import com.lalilu.component.extension.singleViewModel import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.component.viewmodel.SongsViewModel import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmedia.extension.Sortable -import com.zhangke.krouter.KRouter class SongsScreenModel : ScreenModel { val isFastJumping = mutableStateOf(false) @@ -173,10 +171,9 @@ fun Screen.Songs( emptyContent = emptyContent, prefixContent = { prefixContent(it, sortRuleStr) }, onLongClickItem = { - AppRouter.route( - baseUrl = "/song/detail?mediaId=${it.mediaId}", - key = "/pages/songs" - ).push() + AppRouter.route(baseUrl = "/song/detail") + .with("mediaId", it.mediaId) + .push() }, onClickItem = { playingVM.play( @@ -211,10 +208,9 @@ fun Screen.Songs( emptyContent = emptyContent, prefixContent = { prefixContent(it, sortRuleStr) }, onLongClickItem = { - AppRouter.route( - baseUrl = "/song/detail?mediaId=${it.mediaId}", - key = "/pages/songs" - ).push() + AppRouter.route(baseUrl = "/song/detail") + .with("mediaId", it.mediaId) + .push() }, onClickItem = { playingVM.play( diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt index 92f48fd01..4eb2bdfab 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt @@ -1,6 +1,8 @@ package com.lalilu.component.base.screen sealed interface ScreenType { - interface List : ScreenType + interface ListHost : ScreenType interface Empty : ScreenType + interface List : ScreenType + interface Detail : ScreenType } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt index 9a387eca9..d49b4088a 100644 --- a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -1,13 +1,14 @@ package com.lalilu.component.navigation import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.Navigator +import com.lalilu.component.base.TabScreen +import com.lalilu.component.base.screen.ScreenType import com.zhangke.krouter.KRouter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext @@ -16,46 +17,115 @@ sealed interface NavIntent { data class Push(val screen: Screen) : NavIntent data class Replace(val screen: Screen) : NavIntent data object Pop : NavIntent + data object None : NavIntent +} + +fun interface NavInterceptor { + fun intercept(navigator: Navigator, intent: NavIntent): NavIntent +} + +fun interface NavHandler { + fun handle(navigator: Navigator, intent: NavIntent) +} + +/** + * 针对TabScreen的拦截处理逻辑 + */ +val DefaultInterceptorForTabScreen = NavInterceptor { navigator, intent -> + val screen = when (intent) { + is NavIntent.Jump -> intent.screen + is NavIntent.Push -> intent.screen + is NavIntent.Replace -> intent.screen + else -> return@NavInterceptor intent + } + + if (screen !is TabScreen) { + return@NavInterceptor intent + } + + navigator.popUntilRoot() + + // 如果栈顶的页面与目标页面不同则替换 + if (navigator.lastItemOrNull != screen) { + NavIntent.Push(screen) + } else { + NavIntent.None + } +} + +val DefaultInterceptorForListScreen = NavInterceptor { _, intent -> + fun transform(screen: Screen): Screen = + if (screen is ScreenType.List) ListDetailContainer(screen) else screen + + when (intent) { + is NavIntent.Jump -> intent.copy(transform(intent.screen)) + is NavIntent.Push -> intent.copy(transform(intent.screen)) + is NavIntent.Replace -> intent.copy(transform(intent.screen)) + else -> intent + } +} + +val DefaultHandler = NavHandler { navigator, intent -> + val screen = when (intent) { + is NavIntent.Push -> intent.screen + is NavIntent.Replace -> intent.screen + is NavIntent.Jump -> intent.screen + else -> null + } + + val actualNavigator = if (screen is ScreenType.Detail) { + navigator.lastNestedNavigator() ?: navigator + } else { + navigator + } + + when (intent) { + NavIntent.Pop -> actualNavigator.pop() + is NavIntent.Push -> actualNavigator.push(intent.screen) + is NavIntent.Replace -> actualNavigator.replace(intent.screen) + is NavIntent.Jump -> actualNavigator.push(intent.screen) + NavIntent.None -> {} + } } object AppRouter : CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() - private val sharedFlow = MutableSharedFlow>() + private val sharedFlow = MutableSharedFlow() + private var handler: NavHandler = DefaultHandler + private val interceptors = mutableListOf( + DefaultInterceptorForTabScreen, + DefaultInterceptorForListScreen, + ) - fun bindFor(baseUrl: String = "") = sharedFlow - .filter { it.first == baseUrl } - .map { it.second } + suspend fun bind( + navigator: Navigator, + onHandler: () -> Unit = {} + ): Unit = sharedFlow.collect { intent -> + interceptors + .fold(intent) { temp, interceptor -> interceptor.intercept(navigator, temp) } + .let { handler.handle(navigator, it) } + onHandler() + } - @Deprecated("弃用") fun intent(intent: NavIntent) = launch { - sharedFlow.emit("" to intent) + sharedFlow.emit(intent) } - @Deprecated("弃用") fun intent(block: AppRouter.() -> NavIntent?) = launch { - val i = this@AppRouter.block() ?: return@launch - sharedFlow.emit("" to i) + this@AppRouter.block()?.let { sharedFlow.emit(it) } } - fun intent(key: String, intent: NavIntent) = launch { - sharedFlow.emit(key to intent) - } - - fun route(baseUrl: String): Request = route("", baseUrl) - fun route(key: String, baseUrl: String): Request { - return Request(key, baseUrl) - } + fun route(baseUrl: String): Request = Request(baseUrl) class Request internal constructor( - private val key: String, private val baseUrl: String, private val params: MutableMap = mutableMapOf() ) { fun with(key: String, value: T) = apply { params[key] = value } - fun jump() = requestResult()?.let { intent(key, NavIntent.Jump(it)) } - fun push() = requestResult()?.let { intent(key, NavIntent.Push(it)) } - fun replace() = requestResult()?.let { intent(key, NavIntent.Replace(it)) } + fun jump() = requestResult()?.let { intent(NavIntent.Jump(it)) } + fun push() = requestResult()?.let { intent(NavIntent.Push(it)) } + fun replace() = requestResult()?.let { intent(NavIntent.Replace(it)) } fun get() = requestResult() private fun requestResult(): Screen? = diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt b/component/src/main/java/com/lalilu/component/navigation/CustomTransition.kt similarity index 95% rename from app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt rename to component/src/main/java/com/lalilu/component/navigation/CustomTransition.kt index 6c3e3f675..94fa3b7ae 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt +++ b/component/src/main/java/com/lalilu/component/navigation/CustomTransition.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.component +package com.lalilu.component.navigation import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.core.FiniteAnimationSpec @@ -18,7 +18,6 @@ import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator -import com.lalilu.component.navigation.CustomScreenTransition @OptIn(ExperimentalVoyagerApi::class) @Composable diff --git a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt index ddd7a75aa..f6e35ec7d 100644 --- a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt +++ b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt @@ -10,7 +10,6 @@ import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior import com.lalilu.component.base.EnhanceBottomSheetState import com.lalilu.component.base.EnhanceModalSheetState import com.lalilu.component.base.LocalEnhanceSheetState -import com.lalilu.component.base.TabScreen @Composable fun HostNavigator( @@ -27,40 +26,11 @@ fun HostNavigator( disposeNestedNavigators = false ) ) { navigator -> - LaunchedEffect(navigator) { - AppRouter.bindFor().collect { intent -> - when (intent) { - NavIntent.Pop -> navigator.pop() - is NavIntent.Push -> navigator.push(intent.screen) - is NavIntent.Replace -> navigator.replace(intent.screen) - is NavIntent.Jump -> { - val screen = intent.screen - when { - // Tab类型页面 - screen is TabScreen -> { - // 移除栈顶的页面,直到栈顶的页面是startScreen - navigator.popUntil { it == startScreen } - - // 如果栈顶的页面与目标页面不同则替换 - if (navigator.lastItemOrNull != screen) { - navigator.push(screen) - } - } - - // 不同类型的页面,添加至导航栈 - navigator.lastItemOrNull - ?.let { !it::class.isInstance(screen) } - ?: true -> navigator.push(screen) - - // 同类型页面,替换 - else -> navigator.replace(screen) - } - - // 尝试跳转的时候若底部sheet不可见,则显示 - if (enhanceSheetState is EnhanceModalSheetState && !enhanceSheetState.isVisible) { - enhanceSheetState.show() - } - } + LaunchedEffect(navigator, enhanceSheetState) { + AppRouter.bind(navigator) { + // 尝试跳转的时候若底部sheet不可见,则显示 + if (enhanceSheetState is EnhanceModalSheetState && !enhanceSheetState.isVisible) { + enhanceSheetState.show() } } } diff --git a/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt b/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt new file mode 100644 index 000000000..bd961bc92 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt @@ -0,0 +1,89 @@ +package com.lalilu.component.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import com.lalilu.component.base.LocalWindowSize +import com.lalilu.component.base.screen.ScreenType + +class ListDetailContainer( + private val listScreen: Screen +) : Screen, ScreenType.ListHost { + override val key: ScreenKey = "${super.key}:${listScreen.key}" + + @Composable + override fun Content() { + NestedNavigator( + startScreen = listScreen, + ) { navigator -> + val windowSizeClass = LocalWindowSize.current + val isPad = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded + + val listContent = remember(navigator) { + movableContentOf { + navigator.items + .firstOrNull { it is ScreenType.List } + ?.let { + navigator.saveableState(key = "list", screen = it) { + it.Content() + } + } + } + } + + val detailContent = remember { + movableContentOf { isPad -> + CustomTransition( + navigator = navigator, + content = { + when { + isPad && it is ScreenType.List -> EmptyScreen.Content() + !isPad && it is ScreenType.List -> listContent() + else -> navigator.saveableState( + screen = it, + key = "transition", + content = { it.Content() } + ) + } + } + ) + } + } + + if (isPad) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(360.dp), + content = { listContent() } + ) + + Box( + modifier = Modifier + .fillMaxHeight() + .weight(1f), + content = { detailContent(true) } + ) + } + } else { + detailContent(false) + } + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt index 632799d25..9aab14f9c 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt @@ -17,6 +17,11 @@ val LocalNavigatorKey = compositionLocalOf { "" } private val screenNavigatorMap = mutableStateMapOf() +fun Navigator.lastNestedNavigator(): Navigator? { + return items.lastOrNull { it is ScreenType.ListHost } + ?.let { screenNavigatorMap[it] } +} + @Composable fun Navigator.currentScreen(): State { return remember(this) { From a31904aba9b4ca0aefea60e1f99e25d81baee210 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 19 Aug 2024 14:23:05 +0800 Subject: [PATCH 062/213] =?UTF-8?q?[refactor]=E5=8E=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E7=9A=84Local=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/navigation/NavigationContext.kt | 5 -- .../component/navigation/NestedNavigator.kt | 59 ++++++++----------- 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt index 9aab14f9c..0f28ec74e 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt @@ -3,7 +3,6 @@ package com.lalilu.component.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember @@ -11,10 +10,6 @@ import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator import com.lalilu.component.base.screen.ScreenType -val LocalNavigatorParent = compositionLocalOf { null } -val LocalNavigatorBaseScreen = compositionLocalOf { error("No base screen found") } -val LocalNavigatorKey = compositionLocalOf { "" } - private val screenNavigatorMap = mutableStateMapOf() fun Navigator.lastNestedNavigator(): Navigator? { diff --git a/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt index d73caba51..066ad7a65 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt @@ -2,11 +2,9 @@ package com.lalilu.component.navigation import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import cafe.adriel.voyager.core.annotation.InternalVoyagerApi import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.CurrentScreen -import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.NavigatorContent import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior @@ -25,44 +23,37 @@ fun Screen.NestedNavigator( key: String = compositionUniqueId(), content: NavigatorContent = { CurrentScreen() } ) { - val parentNavigator = LocalNavigator.current val enhanceSheetState = LocalEnhanceSheetState.current - CompositionLocalProvider( - LocalNavigatorParent provides parentNavigator, - LocalNavigatorKey provides key, - LocalNavigatorBaseScreen provides this - ) { - Navigator( - screen = startScreen, - disposeBehavior = disposeBehavior, - onBackPressed = onBackPressed, - key = key, - ) { navigator -> - RegisterNavigator( - screen = this, - navigator = navigator - ) + Navigator( + screen = startScreen, + disposeBehavior = disposeBehavior, + onBackPressed = onBackPressed, + key = key, + ) { navigator -> + RegisterNavigator( + screen = this, + navigator = navigator + ) - if (onBackPressed == null) { - BackHandler( - enabled = when (enhanceSheetState) { - is EnhanceBottomSheetState -> !enhanceSheetState.isVisible && navigator.canPop - is EnhanceModalSheetState -> enhanceSheetState.isVisible && navigator.canPop - else -> false - } - ) { - enhanceSheetState.apply { - when (this) { - is EnhanceBottomSheetState -> navigator.pop() - is EnhanceModalSheetState -> if (!navigator.pop()) hide() - else -> {} - } + if (onBackPressed == null) { + BackHandler( + enabled = when (enhanceSheetState) { + is EnhanceBottomSheetState -> !enhanceSheetState.isVisible && navigator.canPop + is EnhanceModalSheetState -> enhanceSheetState.isVisible && navigator.canPop + else -> false + } + ) { + enhanceSheetState.apply { + when (this) { + is EnhanceBottomSheetState -> navigator.pop() + is EnhanceModalSheetState -> if (!navigator.pop()) hide() + else -> {} } } } - - content(navigator) } + + content(navigator) } } \ No newline at end of file From da62d8bfe2ecd2af070b4b24292ef659e614940b Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 19 Aug 2024 16:16:56 +0800 Subject: [PATCH 063/213] =?UTF-8?q?[refactor]=E5=8E=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E4=BB=A3=E7=A0=81=E5=92=8C=E8=B5=84=E6=BA=90=EF=BC=8C?= =?UTF-8?q?=E5=AE=8C=E5=96=84SmartBar=E7=9A=84imePadding=E9=80=82=E9=85=8D?= =?UTF-8?q?=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 3 +- .../java/com/lalilu/lmusic/compose/App.kt | 4 +- ...youtWrapper.kt => LayoutWrapperContent.kt} | 58 ++++----- .../lmusic/compose/component/base/InputBar.kt | 115 +++++++++++------- .../compose/component/base/SearchInputBar.kt | 27 ++-- .../new_screen/detail/SongDetailContent.kt | 4 +- .../new_screen/{ => search}/SearchScreen.kt | 10 +- .../compose/screen/detail/ImageBgBox.kt | 49 -------- app/src/main/res/layout/fragment_inputer.xml | 28 ----- app/src/main/res/layout/item_playing.xml | 92 -------------- .../java/com/lalilu/component/LLazyColumn.kt | 4 +- .../component/LLazyVerticalStaggeredGrid.kt | 4 +- .../com/lalilu/component/base/CustomScreen.kt | 3 - .../com/lalilu/component/base/LocalObject.kt | 2 +- .../component/base/screen/ScreenBarFactory.kt | 2 - .../base/screen/ScreenExtraBarFactory.kt | 65 ---------- .../navigation/NavigationSmartBar.kt | 26 ++-- .../screen/PlaylistCreateOrEditScreen.kt | 2 - 18 files changed, 136 insertions(+), 362 deletions(-) rename app/src/main/java/com/lalilu/lmusic/compose/{LayoutWrapper.kt => LayoutWrapperContent.kt} (84%) rename app/src/main/java/com/lalilu/lmusic/compose/new_screen/{ => search}/SearchScreen.kt (97%) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt delete mode 100644 app/src/main/res/layout/fragment_inputer.xml delete mode 100644 app/src/main/res/layout/item_playing.xml delete mode 100644 component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4f321e956..e2a8ea6bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -66,7 +66,8 @@ android:name=".lmusic.MainActivity" android:configChanges="orientation|screenSize" android:exported="true" - android:launchMode="singleTop"> + android:launchMode="singleTop" + android:windowSoftInputMode="adjustNothing"> diff --git a/app/src/main/java/com/lalilu/lmusic/compose/App.kt b/app/src/main/java/com/lalilu/lmusic/compose/App.kt index 6c0ce479e..3653bccda 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/App.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/App.kt @@ -9,8 +9,8 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier -import com.lalilu.lmusic.LMusicTheme import com.lalilu.component.base.LocalWindowSize +import com.lalilu.lmusic.LMusicTheme @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) object App { @@ -19,7 +19,7 @@ object App { fun Content(activity: Activity) { Environment(activity = activity) { Box(modifier = Modifier.fillMaxSize()) { - with(LayoutWrapper) { Content() } + LayoutWrapperContent() } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt similarity index 84% rename from app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt rename to app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt index 4368db1d7..d4bc02434 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -33,25 +34,26 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import com.lalilu.component.base.BottomSheetLayout import com.lalilu.component.base.BottomSheetLayout2 +import com.lalilu.component.base.LocalSmartBarPadding import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.extension.DialogWrapper import com.lalilu.component.extension.DynamicTipsHost +import com.lalilu.component.navigation.CustomTransition import com.lalilu.component.navigation.HostNavigator import com.lalilu.component.navigation.NavigationSmartBar -import com.lalilu.component.navigation.CustomTransition -import com.lalilu.lmusic.compose.new_screen.HomeScreen +import com.lalilu.lmusic.compose.new_screen.home.HomeScreen import com.lalilu.lmusic.compose.screen.playing.PlayingLayout import com.lalilu.lmusic.compose.screen.playing.PlayingLayoutExpended import com.lalilu.lmusic.compose.screen.playing.PlayingSmartCard -object LayoutWrapper { - - @Composable - fun BoxScope.Content() { - val windowClass = LocalWindowSize.current +@Composable +fun BoxScope.LayoutWrapperContent() { + val windowClass = LocalWindowSize.current + val padding = remember { mutableStateOf(PaddingValues(bottom = 56.dp)) } - val navHostContent = remember { - movableContentOf<(Navigator) -> Unit> { content -> + val navHostContent = remember { + movableContentOf<(Navigator) -> Unit> { content -> + CompositionLocalProvider(value = LocalSmartBarPadding provides padding) { HostNavigator(HomeScreen) { navigator -> content(navigator) @@ -62,33 +64,33 @@ object LayoutWrapper { } } } + } - val navigationSmartBar = remember { - movableContentOf { modifier -> - NavigationSmartBar(modifier = modifier) - } + val navigationSmartBar = remember { + movableContentOf { modifier -> + NavigationSmartBar(modifier = modifier) } + } - if (windowClass.widthSizeClass == WindowWidthSizeClass.Expanded) { - LayoutForPad( - navHostContent = navHostContent, - navigationSmartBar = navigationSmartBar - ) - } else { - LayoutForMobile( - navHostContent = navHostContent, - navigationSmartBar = navigationSmartBar - ) - } + if (windowClass.widthSizeClass == WindowWidthSizeClass.Expanded) { + LayoutForPad( + navHostContent = navHostContent, + navigationSmartBar = navigationSmartBar + ) + } else { + LayoutForMobile( + navHostContent = navHostContent, + navigationSmartBar = navigationSmartBar + ) + } - DialogWrapper.Content() + DialogWrapper.Content() - with(DynamicTipsHost) { Content() } - } + with(DynamicTipsHost) { Content() } } @Composable -fun LayoutForPad( +private fun LayoutForPad( modifier: Modifier = Modifier, navigatorBarHeight: Dp = 56.dp, navHostContent: @Composable ((Navigator) -> Unit) -> Unit, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt index 39ffa0daf..b79124c11 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt @@ -1,15 +1,33 @@ package com.lalilu.lmusic.compose.component.base +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidViewBinding -import androidx.core.widget.addTextChangedListener -import com.blankj.utilcode.util.KeyboardUtils -import com.lalilu.databinding.FragmentInputerBinding -import com.lalilu.lmusic.utils.extension.getActivity +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.extension.dayNightTextColor @Composable fun InputBar( @@ -19,48 +37,59 @@ fun InputBar( value: MutableState = remember { mutableStateOf(defaultValue) }, onValueChange: (String) -> Unit = { }, onSubmit: (String) -> Unit = {}, - onFocusChange: (Boolean) -> Boolean = { false }, ) { - AndroidViewBinding( - modifier = modifier, - factory = { inflater, parent, attachToParent -> - FragmentInputerBinding.inflate(inflater, parent, attachToParent).apply { - val activity = parent.context.getActivity()!! - inputer.setText(value.value) + val focusRequest = remember { FocusRequester() } + val focused = remember { mutableStateOf(false) } + val color = MaterialTheme.colors.onBackground.copy(alpha = 0.3f) + val borderColor = animateColorAsState( + targetValue = if (focused.value) Color(0xFF135CB6) else color, + label = "TextField border color with focus" + ) - hint.takeIf { it.isNotEmpty() }?.let { - inputer.hint = it - } - - inputer.addTextChangedListener { - onValueChange(it.toString()) - value.value = it.toString() - } - - inputer.setOnEditorActionListener { textView, _, _ -> - onValueChange(textView.text.toString()) - value.value = textView.text.toString() - onSubmit(value.value) - textView.clearFocus() - KeyboardUtils.hideSoftInput(textView) - return@setOnEditorActionListener true - } - - KeyboardUtils.registerSoftInputChangedListener(activity) { - if (inputer.isFocused && it > 0) { - return@registerSoftInputChangedListener - } - - inputer.clearFocus() - if (inputer.isFocused && onFocusChange(inputer.isFocused)) { - inputer.onEditorAction(0) - } - } + BasicTextField( + modifier = modifier + .focusRequester(focusRequest) + .onFocusChanged { + focused.value = it.hasFocus && it.isFocused } + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp), + keyboardActions = KeyboardActions(onSearch = { + onSubmit(value.value) + }), + minLines = 1, + textStyle = TextStyle.Default.copy( + color = dayNightTextColor(), + fontSize = 18.sp + ), + cursorBrush = SolidColor(dayNightTextColor()), + value = value.value, + onValueChange = { + value.value = it + onValueChange(it) } - ) { - if (inputer.text.toString() != value.value) { - inputer.setText(value.value) + ) { innerTextField -> + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Surface( + border = BorderStroke(2.dp, borderColor.value), + shape = RoundedCornerShape(4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + innerTextField() + } + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt index ddb4e417c..9085c0ae5 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt @@ -1,14 +1,9 @@ package com.lalilu.lmusic.compose.component.base -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp @Composable fun SearchInputBar( @@ -18,19 +13,11 @@ fun SearchInputBar( onValueChange: (String) -> Unit = {}, onSubmit: (String) -> Unit = {}, ) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 5.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { - InputBar( - modifier = Modifier.weight(1f), - hint = hint, - value = value, - onValueChange = onValueChange, - onSubmit = onSubmit - ) - } + InputBar( + modifier = modifier.fillMaxWidth(), + hint = hint, + value = value, + onValueChange = onValueChange, + onSubmit = onSubmit + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt index 533d2aa10..ca385adc2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt @@ -36,7 +36,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import com.lalilu.common.base.SourceType -import com.lalilu.component.base.LocalPaddingValue +import com.lalilu.component.base.LocalSmartBarPadding import com.lalilu.lmedia.entity.FileInfo import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.entity.Metadata @@ -216,7 +216,7 @@ fun SongDetailContent( Column( modifier = Modifier .layoutId("content") - .padding(bottom = LocalPaddingValue.current.value.calculateBottomPadding() + 16.dp), + .padding(bottom = LocalSmartBarPadding.current.value.calculateBottomPadding() + 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { SongArtistsRow( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt similarity index 97% rename from app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt rename to app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt index eef8ab146..06a61902e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.new_screen +package com.lalilu.lmusic.compose.new_screen.search import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -29,7 +29,7 @@ import com.blankj.utilcode.util.KeyboardUtils import com.lalilu.R import com.lalilu.component.Songs import com.lalilu.component.base.TabScreen -import com.lalilu.component.base.screen.ScreenExtraBarFactory +import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.extension.singleViewModel import com.lalilu.component.navigation.AppRouter @@ -48,7 +48,8 @@ import com.lalilu.lmusic.viewmodel.SearchViewModel import com.zhangke.krouter.annotation.Destination @Destination("/pages/search") -object SearchScreen : Screen, TabScreen, ScreenExtraBarFactory { +object SearchScreen : Screen, TabScreen, ScreenBarFactory { + private fun readResolve(): Any = SearchScreen @Composable override fun provideScreenInfo(): ScreenInfo = remember { @@ -60,7 +61,7 @@ object SearchScreen : Screen, TabScreen, ScreenExtraBarFactory { @Composable override fun Content() { - RegisterExtraContent( + RegisterContent( isVisible = remember { mutableStateOf(true) }, onBackPressed = null, content = { SearchBar() } @@ -75,6 +76,7 @@ fun SearchBar( searchVM: SearchViewModel = singleViewModel(), ) { SearchInputBar( + modifier = Modifier, value = searchVM.keyword, onValueChange = { searchVM.searchFor(it) }, onSubmit = { searchVM.searchFor(it) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt deleted file mode 100644 index fc64d733d..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/ImageBgBox.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.lalilu.lmusic.compose.screen.detail - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade -import coil3.transition.CrossfadeDrawable - - -@Composable -fun ImageBgBox( - modifier: Modifier = Modifier, - contentAlignment: Alignment = Alignment.TopCenter, - imageData: Any? = null, - imageModifier: Modifier = Modifier, - imageCrossFadeDuration: Int = CrossfadeDrawable.DEFAULT_DURATION, - content: @Composable BoxScope.() -> Unit = {}, -) { - val context = LocalContext.current - val model = remember(imageData) { - imageData?.let { - ImageRequest.Builder(context) - .data(it) - .crossfade(imageCrossFadeDuration) - .build() - } - } - - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = contentAlignment - ) { - AsyncImage( - modifier = imageModifier, - model = model, - contentScale = ContentScale.Crop, - contentDescription = "" - ) - content() - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_inputer.xml b/app/src/main/res/layout/fragment_inputer.xml deleted file mode 100644 index 5b7722fbc..000000000 --- a/app/src/main/res/layout/fragment_inputer.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_playing.xml b/app/src/main/res/layout/item_playing.xml deleted file mode 100644 index b50e84473..000000000 --- a/app/src/main/res/layout/item_playing.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/component/src/main/java/com/lalilu/component/LLazyColumn.kt b/component/src/main/java/com/lalilu/component/LLazyColumn.kt index df8a2ca87..66826f6bc 100644 --- a/component/src/main/java/com/lalilu/component/LLazyColumn.kt +++ b/component/src/main/java/com/lalilu/component/LLazyColumn.kt @@ -16,7 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.lalilu.component.base.LocalPaddingValue +import com.lalilu.component.base.LocalSmartBarPadding @Composable fun LLazyColumn( @@ -31,7 +31,7 @@ fun LLazyColumn( userScrollEnabled: Boolean = true, content: LazyListScope.() -> Unit ) { - val padding by LocalPaddingValue.current + val padding by LocalSmartBarPadding.current LazyColumn( modifier = modifier, diff --git a/component/src/main/java/com/lalilu/component/LLazyVerticalStaggeredGrid.kt b/component/src/main/java/com/lalilu/component/LLazyVerticalStaggeredGrid.kt index 7bef6b835..0d864c7e5 100644 --- a/component/src/main/java/com/lalilu/component/LLazyVerticalStaggeredGrid.kt +++ b/component/src/main/java/com/lalilu/component/LLazyVerticalStaggeredGrid.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.lalilu.component.base.LocalPaddingValue +import com.lalilu.component.base.LocalSmartBarPadding @Composable fun LLazyVerticalStaggeredGrid( @@ -33,7 +33,7 @@ fun LLazyVerticalStaggeredGrid( userScrollEnabled: Boolean = true, content: LazyStaggeredGridScope.() -> Unit ) { - val padding by LocalPaddingValue.current + val padding by LocalSmartBarPadding.current LazyVerticalStaggeredGrid( columns = columns, diff --git a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt index 230d0b71e..483123401 100644 --- a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt +++ b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt @@ -28,7 +28,6 @@ sealed interface ScreenAction { @DrawableRes val icon: Int? = null, @StringRes val info: Int? = null, val color: Color = Color.White, - val fitImePadding: Boolean = false, val isLongClickAction: Boolean = false, val onAction: () -> Unit ) : ScreenAction @@ -40,8 +39,6 @@ sealed interface ScreenAction { data class ScreenBarComponent( val state: MutableState, - val showMask: Boolean, - val showBackground: Boolean, val key: String = state.hashCode().toString(), val content: @Composable () -> Unit ) { diff --git a/component/src/main/java/com/lalilu/component/base/LocalObject.kt b/component/src/main/java/com/lalilu/component/base/LocalObject.kt index f1c4a8aa9..c5fda35ae 100644 --- a/component/src/main/java/com/lalilu/component/base/LocalObject.kt +++ b/component/src/main/java/com/lalilu/component/base/LocalObject.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.unit.dp -val LocalPaddingValue = compositionLocalOf { mutableStateOf(PaddingValues(0.dp)) } +val LocalSmartBarPadding = compositionLocalOf { mutableStateOf(PaddingValues(0.dp)) } val LocalWindowSize = compositionLocalOf { error("WindowSizeClass hasn't been initialized") diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt index ef53548fc..889ad87fa 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt @@ -42,8 +42,6 @@ interface ScreenBarFactory { if (isVisible.value) { stack.stack += ScreenBarComponent( state = isVisible, - showMask = false, - showBackground = false, content = { content.invoke() diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt deleted file mode 100644 index 2b8a9f9c0..000000000 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenExtraBarFactory.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.lalilu.component.base.screen - -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.lalilu.component.base.ScreenBarComponent - - -private class ExtraComponentStack { - var stack: List by mutableStateOf(emptyList()) - - companion object { - private val instanceMap = mutableStateMapOf() - - fun getInstance(attach: ScreenExtraBarFactory): ExtraComponentStack { - return instanceMap.getOrPut(attach) { ExtraComponentStack() } - } - } -} - -@Deprecated("移除") -interface ScreenExtraBarFactory { - private val stack: ExtraComponentStack - get() = ExtraComponentStack.getInstance(this) - - @Composable - fun content(): ScreenBarComponent? { - return stack.stack.lastOrNull() - } - - @Composable - fun RegisterExtraContent( - isVisible: MutableState, - onBackPressed: (() -> Unit)?, - content: @Composable () -> Unit - ) { - LaunchedEffect(isVisible.value) { - if (isVisible.value) { - stack.stack += ScreenBarComponent( - state = isVisible, - showMask = false, - showBackground = false, - content = { - content.invoke() - - if (onBackPressed != null) { - BackHandler { - isVisible.value = false - onBackPressed() - } - } - } - ) - } else { - val key = isVisible.hashCode().toString() - stack.stack = stack.stack.filter { it.key != key } - } - } - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt index 923c4a658..d0483a12d 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -13,7 +13,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,14 +37,13 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.FixedScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -105,7 +104,9 @@ fun NavigationSmartBar( } AnimatedContent( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .imePadding(), transitionSpec = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Up, @@ -119,6 +120,7 @@ fun NavigationSmartBar( Box( modifier = Modifier .fillMaxWidth() + .pointerInput(Unit) { detectTapGestures() } .background(MaterialTheme.colors.background.copy(0.95f)) .navigationBarsPadding() .height(56.dp) @@ -130,6 +132,7 @@ fun NavigationSmartBar( is NavigationBarType.TabBar -> { NavigateTabBar( + modifier = Modifier.fillMaxHeight(), currentScreen = { currentScreen }, tabScreens = { tabScreens }, onSelectTab = { AppRouter.intent(NavIntent.Jump(it)) } @@ -138,7 +141,7 @@ fun NavigationSmartBar( is NavigationBarType.CommonBar -> { NavigateCommonBar( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxHeight(), previousTitle = previousTitle, currentScreen = currentScreen ) @@ -157,7 +160,6 @@ fun NavigateTabBar( ) { Row( modifier = modifier - .clickable(enabled = false) {} .height(52.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -184,15 +186,13 @@ fun NavigateCommonBar( previousTitle: String, currentScreen: Screen? ) { - val itemFitImePadding = remember { mutableStateOf(false) } val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val screenActions = (currentScreen as? ScreenActionFactory)?.provideScreenActions() + // TODO 待实现当screenActions溢出时转换成下拉菜单的逻辑,下拉菜单可直接用Dialog替代 Row( modifier = modifier - .fillMaxWidth() - .clickable(enabled = false) {} - .run { if (itemFitImePadding.value) this.imePadding() else this }, + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { TextButton( @@ -236,12 +236,6 @@ fun NavigateCommonBar( } if (it is ScreenAction.StaticAction) { - if (it.fitImePadding) { - LaunchedEffect(Unit) { - itemFitImePadding.value = true - } - } - TextButton( modifier = Modifier.fillMaxHeight(), shape = RectangleShape, diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt index 3b663590f..37007eb72 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt @@ -79,7 +79,6 @@ class PlaylistCreateOrEditScreenModel( title = R.string.playlist_action_create_playlist, icon = componentR.drawable.ic_check_line, isLongClickAction = true, - fitImePadding = true, color = Color(0xFF008521) ) { val title = title.value @@ -106,7 +105,6 @@ class PlaylistCreateOrEditScreenModel( title = R.string.playlist_action_update_playlist, icon = componentR.drawable.ic_check_line, isLongClickAction = true, - fitImePadding = true, color = Color(0xFF008521) ) { val playlistId = playlistId.value From fe1837a01b13a276d37a989e00eebf79ed2bf8bc Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 19 Aug 2024 16:18:13 +0800 Subject: [PATCH 064/213] =?UTF-8?q?[refactor]=E6=97=A7=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E9=80=82=E9=85=8D=E6=96=B0=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=BB=84=E5=90=88=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/new_screen/SearchLyricScreen.kt | 16 +++++------ .../compose/new_screen/SettingsScreen.kt | 23 ++++++++++----- .../new_screen/{ => home}/HomeScreen.kt | 5 ++-- .../lalilu/lalbum/screen/AlbumDetailScreen.kt | 21 +++++++++----- .../com/lalilu/lalbum/screen/AlbumsScreen.kt | 28 +++++++++++++------ 5 files changed, 60 insertions(+), 33 deletions(-) rename app/src/main/java/com/lalilu/lmusic/compose/new_screen/{ => home}/HomeScreen.kt (95%) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt index 726e5662c..f83ec341a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt @@ -39,24 +39,24 @@ import coil3.request.crossfade import coil3.request.error import coil3.request.placeholder import com.lalilu.R -import com.lalilu.lmusic.api.lrcshare.SongResult -import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.LLazyColumn -import com.lalilu.lmusic.compose.component.card.SearchInputBar import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.screen.ScreenExtraBarFactory +import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.extension.dayNightTextColor +import com.lalilu.component.extension.singleViewModel +import com.lalilu.lmusic.api.lrcshare.SongResult +import com.lalilu.lmusic.compose.component.card.SearchInputBar import com.lalilu.lmusic.compose.presenter.SearchLyricAction import com.lalilu.lmusic.compose.presenter.SearchLyricPresenter import com.lalilu.lmusic.compose.presenter.SearchLyricState -import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.viewmodel.SearchLyricViewModel data class SearchLyricScreen( private val mediaId: String, private val keywords: String? = null -) : Screen, ScreenInfoFactory, ScreenExtraBarFactory { +) : Screen, ScreenInfoFactory, ScreenBarFactory { @Composable override fun provideScreenInfo(): ScreenInfo = remember { @@ -78,7 +78,7 @@ data class SearchLyricScreen( state.onAction(SearchLyricAction.SearchFor(keywords)) } - RegisterExtraContent( + RegisterContent( isVisible = remember { mutableStateOf(true) }, onBackPressed = null ) { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt index 1ff586f02..9f210b2f5 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,6 +23,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ActivityUtils import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.RomUtils @@ -32,9 +34,9 @@ import com.lalilu.R import com.lalilu.common.CustomRomUtils import com.lalilu.component.IconTextButton import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.CustomScreen import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.extension.rememberFixedStatusBarHeightDp import com.lalilu.component.settings.SettingCategory import com.lalilu.component.settings.SettingFilePicker @@ -47,14 +49,21 @@ import com.lalilu.lmusic.GuidingActivity import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.utils.EQHelper import com.lalilu.lmusic.utils.extension.getActivity +import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.launch import org.koin.compose.koinInject -object SettingsScreen : CustomScreen { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_settings, - icon = R.drawable.ic_settings_4_line - ) +@Destination("/pages/settings") +object SettingsScreen : Screen, ScreenInfoFactory { + private fun readResolve(): Any = SettingsScreen + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = R.string.screen_title_settings, + icon = R.drawable.ic_settings_4_line + ) + } @Composable override fun Content() { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/home/HomeScreen.kt similarity index 95% rename from app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt rename to app/src/main/java/com/lalilu/lmusic/compose/new_screen/home/HomeScreen.kt index 18668af79..427a3b5d4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/home/HomeScreen.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.new_screen +package com.lalilu.lmusic.compose.new_screen.home import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -12,8 +12,8 @@ import androidx.compose.ui.Modifier import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.TabScreen +import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.extension.EntryPanel import com.lalilu.lmusic.extension.dailyRecommend @@ -26,6 +26,7 @@ import com.zhangke.krouter.annotation.Destination @Destination("/pages/home") object HomeScreen : TabScreen, Screen { + private fun readResolve(): Any = HomeScreen @Composable override fun provideScreenInfo(): ScreenInfo = remember { diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt index 73429db12..5f135ffc1 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt @@ -7,34 +7,41 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.koin.getScreenModel import com.lalilu.component.Songs -import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.LoadingScaffold import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType import com.lalilu.lalbum.R import com.lalilu.lalbum.component.AlbumCoverCard import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LAlbum +import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch +@Destination("/pages/albums/detail") data class AlbumDetailScreen( private val albumId: String -) : DynamicScreen(), ScreenType.List { +) : Screen, ScreenInfoFactory, ScreenType.List { + override val key: ScreenKey = "${super.key}:$albumId" - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.album_screen_title, - ) + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo(title = R.string.album_screen_title) + } @Composable override fun Content() { @@ -59,7 +66,7 @@ class AlbumDetailScreenModel : ScreenModel { } @Composable -private fun DynamicScreen.AlbumDetail( +private fun Screen.AlbumDetail( albumDetailSM: AlbumDetailScreenModel ) { val albumLoadingState = albumDetailSM.album.collectAsLoadingState() diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index 79ea4cef5..05c58b56e 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -12,19 +12,23 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Surface +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel import com.lalilu.component.LLazyVerticalStaggeredGrid -import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.LoadingScaffold +import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent import com.lalilu.component.viewmodel.IPlayingViewModel @@ -35,6 +39,7 @@ import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LAlbum import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.Sortable +import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest @@ -42,13 +47,17 @@ import kotlinx.coroutines.launch import org.koin.compose.koinInject import com.lalilu.component.R as ComponentR +@Destination("/pages/albums") data class AlbumsScreen( val albumsId: List = emptyList() -) : DynamicScreen() { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.album_screen_title, - icon = ComponentR.drawable.ic_album_fill - ) +) : Screen, ScreenInfoFactory { + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = R.string.album_screen_title, + icon = ComponentR.drawable.ic_album_fill + ) + } @Composable override fun Content() { @@ -81,12 +90,13 @@ class AlbumsScreenModel( } @Composable -private fun DynamicScreen.AlbumsScreen( +private fun AlbumsScreen( title: String = "全部专辑", albumsSM: AlbumsScreenModel, playingVM: IPlayingViewModel = koinInject(), sortFor: String = Sortable.SORT_FOR_ALBUMS, ) { + val isPad = LocalWindowSize.current.widthSizeClass == WindowWidthSizeClass.Expanded val albumsState = albumsSM.albums.collectAsLoadingState() val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() @@ -94,7 +104,7 @@ private fun DynamicScreen.AlbumsScreen( targetState = albumsState ) { albums -> LLazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(2), + columns = StaggeredGridCells.Fixed(if (isPad) 3 else 2), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalItemSpacing = 10.dp, contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = statusBarPadding) From 0bf9de84f0e1922f4621263cc79d014f15083456 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 19 Aug 2024 16:53:36 +0800 Subject: [PATCH 065/213] =?UTF-8?q?[refactor]=E8=A7=A3=E5=86=B3=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E5=AD=90=E8=B7=AF=E7=94=B1=E5=9C=A8=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E6=97=B6=E9=A1=B5=E9=9D=A2=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/navigation/HostNavigator.kt | 14 ++++++----- .../navigation/ListDetailContainer.kt | 18 ++++++------- .../component/navigation/NestedNavigator.kt | 25 ------------------- 3 files changed, 16 insertions(+), 41 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt index f6e35ec7d..28bd75446 100644 --- a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt +++ b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt @@ -42,12 +42,14 @@ fun HostNavigator( else -> false } ) { - enhanceSheetState.apply { - when (this) { - is EnhanceBottomSheetState -> navigator.pop() - is EnhanceModalSheetState -> if (!navigator.pop()) hide() - else -> {} - } + val actualNavigator = navigator.lastNestedNavigator() + ?.takeIf { it.canPop } + ?: navigator + + when (enhanceSheetState) { + is EnhanceBottomSheetState -> actualNavigator.pop() + is EnhanceModalSheetState -> if (!actualNavigator.pop()) enhanceSheetState.hide() + else -> {} } } diff --git a/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt b/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt index bd961bc92..23efe5dd1 100644 --- a/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt +++ b/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt @@ -31,25 +31,23 @@ class ListDetailContainer( val isPad = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded val listContent = remember(navigator) { - movableContentOf { - navigator.items - .firstOrNull { it is ScreenType.List } - ?.let { - navigator.saveableState(key = "list", screen = it) { - it.Content() - } + movableContentOf { screen -> + (screen ?: navigator.items.firstOrNull { it is ScreenType.List })?.let { + navigator.saveableState(key = "list", screen = it) { + it.Content() } + } } } - val detailContent = remember { + val detailContent = remember(navigator) { movableContentOf { isPad -> CustomTransition( navigator = navigator, content = { when { isPad && it is ScreenType.List -> EmptyScreen.Content() - !isPad && it is ScreenType.List -> listContent() + !isPad && it is ScreenType.List -> listContent(it) else -> navigator.saveableState( screen = it, key = "transition", @@ -71,7 +69,7 @@ class ListDetailContainer( modifier = Modifier .fillMaxHeight() .width(360.dp), - content = { listContent() } + content = { listContent(null) } ) Box( diff --git a/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt index 066ad7a65..fbe8309d4 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt @@ -1,6 +1,5 @@ package com.lalilu.component.navigation -import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import cafe.adriel.voyager.core.annotation.InternalVoyagerApi import cafe.adriel.voyager.core.screen.Screen @@ -10,9 +9,6 @@ import cafe.adriel.voyager.navigator.NavigatorContent import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior import cafe.adriel.voyager.navigator.OnBackPressed import cafe.adriel.voyager.navigator.compositionUniqueId -import com.lalilu.component.base.EnhanceBottomSheetState -import com.lalilu.component.base.EnhanceModalSheetState -import com.lalilu.component.base.LocalEnhanceSheetState @OptIn(InternalVoyagerApi::class) @Composable @@ -23,8 +19,6 @@ fun Screen.NestedNavigator( key: String = compositionUniqueId(), content: NavigatorContent = { CurrentScreen() } ) { - val enhanceSheetState = LocalEnhanceSheetState.current - Navigator( screen = startScreen, disposeBehavior = disposeBehavior, @@ -35,25 +29,6 @@ fun Screen.NestedNavigator( screen = this, navigator = navigator ) - - if (onBackPressed == null) { - BackHandler( - enabled = when (enhanceSheetState) { - is EnhanceBottomSheetState -> !enhanceSheetState.isVisible && navigator.canPop - is EnhanceModalSheetState -> enhanceSheetState.isVisible && navigator.canPop - else -> false - } - ) { - enhanceSheetState.apply { - when (this) { - is EnhanceBottomSheetState -> navigator.pop() - is EnhanceModalSheetState -> if (!navigator.pop()) hide() - else -> {} - } - } - } - } - content(navigator) } } \ No newline at end of file From 189cc25139587a81093f8615d2af279e4da0f05c Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 19 Aug 2024 17:01:53 +0800 Subject: [PATCH 066/213] =?UTF-8?q?[refactor]=E4=BF=AE=E6=AD=A3=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E5=B5=8C=E5=A5=97=E5=AD=90=E8=B7=AF=E7=94=B1=E5=B9=B6?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E8=B7=AF=E7=94=B1=E8=B7=B3=E8=BD=AC=E7=9A=84?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E9=9C=80=E8=A6=81=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E5=AD=90=E8=B7=AF=E7=94=B1=E5=A4=84=E4=BA=8E?= =?UTF-8?q?=E7=88=B6=E8=B7=AF=E7=94=B1=E7=9A=84=E5=AF=BC=E8=88=AA=E6=A0=88?= =?UTF-8?q?=E6=9C=AB=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/component/navigation/AppRouter.kt | 2 +- .../java/com/lalilu/component/navigation/HostNavigator.kt | 2 +- .../com/lalilu/component/navigation/NavigationContext.kt | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt index d49b4088a..9893ac444 100644 --- a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -74,7 +74,7 @@ val DefaultHandler = NavHandler { navigator, intent -> } val actualNavigator = if (screen is ScreenType.Detail) { - navigator.lastNestedNavigator() ?: navigator + navigator.nestedNavigatorInLastScreen() ?: navigator } else { navigator } diff --git a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt index 28bd75446..e588d5571 100644 --- a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt +++ b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt @@ -42,7 +42,7 @@ fun HostNavigator( else -> false } ) { - val actualNavigator = navigator.lastNestedNavigator() + val actualNavigator = navigator.nestedNavigatorInLastScreen() ?.takeIf { it.canPop } ?: navigator diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt index 0f28ec74e..3e494d08c 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt @@ -12,8 +12,9 @@ import com.lalilu.component.base.screen.ScreenType private val screenNavigatorMap = mutableStateMapOf() -fun Navigator.lastNestedNavigator(): Navigator? { - return items.lastOrNull { it is ScreenType.ListHost } +fun Navigator.nestedNavigatorInLastScreen(): Navigator? { + return items.lastOrNull() + ?.takeIf { it is ScreenType.ListHost } ?.let { screenNavigatorMap[it] } } From 842f2c9cb4730acb3fb5ca184d5c1473224bd38b Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 19 Aug 2024 19:05:25 +0800 Subject: [PATCH 067/213] =?UTF-8?q?[refactor]=E5=B0=9D=E8=AF=95=E9=87=8D?= =?UTF-8?q?=E6=9E=84Songs=E5=88=97=E8=A1=A8=E7=9B=B8=E5=85=B3=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=92=8C=E9=80=BB=E8=BE=91=EF=BC=8C=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?AppRouter=E8=B0=83=E7=94=A8=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../new_screen/detail/SongDetailScreen.kt | 2 +- .../compose/screen/playing/PlaylistLayout.kt | 10 +- .../compose/screen/songs/SongsScreen.kt | 209 ++++++++---------- .../lalilu/lmusic/extension/DailyRecommend.kt | 41 +++- .../lalilu/lmusic/extension/HistoryPanel.kt | 11 +- .../lalilu/lmusic/extension/LatestPanel.kt | 10 +- .../java/com/lalilu/component/LLazyColumn.kt | 1 + .../main/java/com/lalilu/component/Songs.kt | 4 +- .../com/lalilu/component/base/LocalObject.kt | 25 +++ 9 files changed, 163 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index c3af03a52..02b10234d 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -43,7 +43,7 @@ import com.lalilu.lplayer.extensions.QueueAction import com.zhangke.krouter.annotation.Destination import com.zhangke.krouter.annotation.Param -@Destination("/song/detail") +@Destination("/pages/songs/detail") data class SongDetailScreen( @Param val mediaId: String ) : Screen, ScreenActionFactory, ScreenInfoFactory, ScreenType.Detail { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 18fd1b11c..1a71674be 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -36,14 +36,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback -import cafe.adriel.voyager.core.screen.Screen import coil3.compose.AsyncImage import com.lalilu.common.base.Playable import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lplayer.LPlayer -import com.zhangke.krouter.KRouter import org.koin.compose.koinInject @@ -162,10 +159,9 @@ fun PlaylistLayout( onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - AppRouter.intent { - KRouter.route("/song/detail?mediaId=${item.data.mediaId}") - ?.let(NavIntent::Push) - } + AppRouter.route("/pages/songs/detail") + .with("mediaId", item.data.mediaId) + .jump() } ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index b2a9deff5..0ab4b5fa0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -1,49 +1,47 @@ package com.lalilu.lmusic.compose.screen.songs -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R -import com.lalilu.component.Songs -import com.lalilu.component.SongsScreenModel -import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType -import com.lalilu.component.extension.LazyListScrollToHelper -import com.lalilu.component.extension.SelectAction -import com.lalilu.component.extension.rememberLazyListScrollToHelper -import com.lalilu.component.extension.singleViewModel -import com.lalilu.lhistory.SortRuleLastPlayTime -import com.lalilu.lhistory.SortRulePlayCount -import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lmusic.viewmodel.HistoryViewModel -import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.lalilu.lplaylist.PlaylistActions -import com.zhangke.krouter.KRouter +import com.lalilu.component.base.smartBarPadding +import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.toState +import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lplayer.extensions.PlayerAction import com.zhangke.krouter.annotation.Destination -import com.zhangke.krouter.annotation.Param +import kotlinx.coroutines.flow.Flow @Destination("/pages/songs") data class SongsScreen( - @Param(required = true, name = KRouter.PRESET_ROUTER) - private val router: String = "/pages/songs", private val title: String? = null, private val mediaIds: List = emptyList() -) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenType.List { +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory, ScreenType.List { @Composable override fun provideScreenInfo(): ScreenInfo = remember { @@ -53,105 +51,88 @@ data class SongsScreen( ) } - @Transient - private var scrollHelper: LazyListScrollToHelper? = null + @Composable + override fun provideScreenActions(): List = remember { + listOf( + ScreenAction.StaticAction( + title = R.string.screen_action_sort, + icon = R.drawable.ic_sort_desc, + color = Color(0xFF1793FF), + onAction = { } + ), + ScreenAction.StaticAction( + title = R.string.screen_action_locate_playing_item, + icon = R.drawable.ic_focus_3_line, + color = Color(0xFF9317FF), + onAction = { } + ), + ) + } @Transient - private var songsSM: SongsScreenModel? = null - + private var songsSM: SongsSM? = null @Composable - override fun provideScreenActions(): List { - val playingVM: PlayingViewModel = singleViewModel() + override fun Content() { + val sm = rememberScreenModel { SongsSM(mediaIds) } + .also { songsSM = it } - return remember { - listOf( - ScreenAction.StaticAction( - title = R.string.screen_action_sort, - icon = R.drawable.ic_sort_desc, - color = Color(0xFF1793FF), - onAction = { songsSM?.showSortPanel?.value = true } - ), - ScreenAction.StaticAction( - title = R.string.screen_action_locate_playing_item, - icon = R.drawable.ic_focus_3_line, - color = Color(0xFF9317FF), - onAction = { - val playingId = playingVM.playing.value?.mediaId ?: return@StaticAction - scrollHelper?.scrollToItem(playingId) - } - ), - ) - } + SongsScreenContent(songsSM = sm) } +} - @Composable - override fun Content() { - val listState: LazyListState = rememberLazyListState() - val songsSM = rememberScreenModel { SongsScreenModel() } - .also { this.songsSM = it } - val scrollHelper = rememberLazyListScrollToHelper(listState = listState) - .also { this.scrollHelper = it } - val historyVM: HistoryViewModel = singleViewModel() +private class SongsSM( + private val mediaIds: List +) : ScreenModel { + private fun flow(): Flow> { + return if (mediaIds.isEmpty()) LMedia.getFlow() + else LMedia.flowMapBy(mediaIds) + } + + val songs = flow().toState(emptyList(), screenModelScope) +} + +@Composable +private fun SongsScreenContent( + songsSM: SongsSM +) { + val hapticFeedback = LocalHapticFeedback.current + val listState: LazyListState = rememberLazyListState() + val songs by songsSM.songs - Songs( - modifier = Modifier, - showAll = true, - mediaIds = emptyList(), - listState = listState, - songsSM = songsSM, - scrollToHelper = scrollHelper, - selectActions = { getAll -> - listOf( - SelectAction.StaticAction.SelectAll(getAll = getAll), - SelectAction.StaticAction.ClearAll, - PlaylistActions.addToPlaylistAction, - PlaylistActions.addToFavorite, - ) - }, - supportListAction = { - listOf( - SortStaticAction.Normal, - SortStaticAction.Title, - SortStaticAction.AddTime, - SortStaticAction.Duration, - SortRulePlayCount, - SortRuleLastPlayTime, - SortStaticAction.Shuffle - ) - }, - showPrefixContent = { it.value == SortRulePlayCount::class.java.name }, - headerContent = { - item { - NavigatorHeader( - title = title ?: "全部歌曲", - subTitle = "共 ${it.value.values.flatten().size} 首歌曲" - ) - } - }, - prefixContent = { item, sortRuleStr -> - var icon = -1 - var text = "" - when (sortRuleStr.value) { - SortRulePlayCount::class.java.name -> { - icon = R.drawable.headphone_fill - text = historyVM.requiteHistoryCountById(item.mediaId).toString() - } - } - if (icon != -1) { - Icon( - modifier = Modifier.size(10.dp), - painter = painterResource(id = icon), - contentDescription = "" - ) - } - if (text.isNotEmpty()) { - Text( - text = text, - fontSize = 10.sp - ) - } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + ) { + Text(text = "全部歌曲") + Text(text = "${songs.size}首歌曲") } - ) + } + + items( + items = songs, + key = { it.mediaId }, + contentType = { it::class.java } + ) { + SongCard( + song = { it }, + onClick = { PlayerAction.PlayById(it.mediaId).action() }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.mediaId) + .jump() + }, + ) + } + + smartBarPadding() } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index 390c91f1f..2c11b3b34 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent @@ -32,7 +31,6 @@ import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.compose.screen.songs.SongsScreen import com.lalilu.lmusic.viewmodel.LibraryViewModel -import com.zhangke.krouter.KRouter @OptIn(ExperimentalMaterialApi::class) fun LazyListScope.dailyRecommend( @@ -71,10 +69,9 @@ fun LazyListScope.dailyRecommend( item = { it }, modifier = Modifier.size(width = 250.dp, height = 250.dp), onClick = { - AppRouter.intent { - KRouter.route("/song/detail?mediaId=${it.id}") - ?.let(NavIntent::Push) - } + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() } ) } @@ -101,7 +98,11 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { modifier = Modifier .fillMaxHeight() .weight(1f), - onClick = { AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() + } ) } @@ -111,7 +112,11 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { modifier = Modifier .width(150.dp) .fillMaxHeight(), - onClick = { AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() + } ) } @@ -121,7 +126,11 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { modifier = Modifier .width(150.dp) .fillMaxHeight(), - onClick = { AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() + } ) } } @@ -145,7 +154,11 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { modifier = Modifier .fillMaxHeight() .weight(1f), - onClick = { AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } } + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() + } ) } @@ -163,7 +176,9 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { .fillMaxWidth() .weight(1f), onClick = { - AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() } ) } @@ -175,7 +190,9 @@ fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { .fillMaxWidth() .weight(1f), onClick = { - AppRouter.intent { KRouter.route("/song/detail?mediaId=${it.id}") } + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() } ) } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt index 0a060f4e0..80396406c 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt @@ -12,16 +12,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen import com.lalilu.common.base.Playable import com.lalilu.component.card.SongCard import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent import com.lalilu.lmedia.entity.LSong import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.HistoryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.zhangke.krouter.KRouter @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) fun LazyListScope.historyPanel( @@ -70,10 +67,10 @@ fun LazyListScope.historyPanel( }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - AppRouter.intent { - KRouter.route("/song/detail?mediaId=${item.mediaId}") - ?.let(NavIntent::Push) - } + + AppRouter.route("/pages/songs/detail") + .with("mediaId", item.mediaId) + .jump() } ) } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt index 08ac9d86c..f05abc7d3 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt @@ -8,16 +8,13 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen import com.lalilu.common.base.Playable import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent import com.lalilu.lmusic.compose.component.card.RecommendCard import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.LibraryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.zhangke.krouter.KRouter @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @@ -49,10 +46,9 @@ fun LazyListScope.latestPanel( height = { 100.dp }, modifier = Modifier.animateItem(), onClick = { - AppRouter.intent { - KRouter.route("/song/detail?mediaId=${it.mediaId}") - ?.let(NavIntent::Push) - } + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.mediaId) + .jump() }, isPlaying = { playingVM.isItemPlaying(it.id, Playable::mediaId) }, onClickButton = { diff --git a/component/src/main/java/com/lalilu/component/LLazyColumn.kt b/component/src/main/java/com/lalilu/component/LLazyColumn.kt index 66826f6bc..4bf09201e 100644 --- a/component/src/main/java/com/lalilu/component/LLazyColumn.kt +++ b/component/src/main/java/com/lalilu/component/LLazyColumn.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lalilu.component.base.LocalSmartBarPadding +@Deprecated("弃用") @Composable fun LLazyColumn( modifier: Modifier = Modifier, diff --git a/component/src/main/java/com/lalilu/component/Songs.kt b/component/src/main/java/com/lalilu/component/Songs.kt index 50a9f9442..e1062b7da 100644 --- a/component/src/main/java/com/lalilu/component/Songs.kt +++ b/component/src/main/java/com/lalilu/component/Songs.kt @@ -171,7 +171,7 @@ fun Screen.Songs( emptyContent = emptyContent, prefixContent = { prefixContent(it, sortRuleStr) }, onLongClickItem = { - AppRouter.route(baseUrl = "/song/detail") + AppRouter.route("/pages/songs/detail") .with("mediaId", it.mediaId) .push() }, @@ -208,7 +208,7 @@ fun Screen.Songs( emptyContent = emptyContent, prefixContent = { prefixContent(it, sortRuleStr) }, onLongClickItem = { - AppRouter.route(baseUrl = "/song/detail") + AppRouter.route("/pages/songs/detail") .with("mediaId", it.mediaId) .push() }, diff --git a/component/src/main/java/com/lalilu/component/base/LocalObject.kt b/component/src/main/java/com/lalilu/component/base/LocalObject.kt index c5fda35ae..e62332c0f 100644 --- a/component/src/main/java/com/lalilu/component/base/LocalObject.kt +++ b/component/src/main/java/com/lalilu/component/base/LocalObject.kt @@ -1,9 +1,17 @@ package com.lalilu.component.base import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp val LocalSmartBarPadding = compositionLocalOf { mutableStateOf(PaddingValues(0.dp)) } @@ -11,3 +19,20 @@ val LocalSmartBarPadding = compositionLocalOf { mutableStateOf(PaddingValues(0.d val LocalWindowSize = compositionLocalOf { error("WindowSizeClass hasn't been initialized") } + +fun LazyListScope.smartBarPadding() { + item( + key = "smartBarPadding", + contentType = "smartBarPadding" + ) { + val bottomHeight = LocalSmartBarPadding.current.value.calculateBottomPadding() + + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + 16.dp + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(bottomHeight) + ) + } +} \ No newline at end of file From f1cdf7d836f2467552167b17cd2b773e12bfab4d Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 20 Aug 2024 19:26:31 +0800 Subject: [PATCH 068/213] =?UTF-8?q?[refactor]=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=9B=BA=E5=AE=9A=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E7=9A=84=E9=A1=B5=E9=9D=A2=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/screen/songs/SongsSM.kt | 117 ++++++++++++++++++ .../compose/screen/songs/SongsScreen.kt | 85 +------------ .../screen/songs/SongsScreenContent.kt | 89 +++++++++++++ .../screen/songs/SongsSortPanelDialog.kt | 23 ++++ 4 files changed, 233 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt new file mode 100644 index 000000000..7548609fc --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -0,0 +1,117 @@ +package com.lalilu.lmusic.compose.screen.songs + +import androidx.compose.runtime.mutableStateOf +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.BaseMatchable +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.Sortable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +internal sealed interface SongsScreenAction { + data object ToggleSortPanel : SongsScreenAction + data object LocaleToPlayingItem : SongsScreenAction +} + +internal sealed interface SongsScreenEvent { + data class ScrollToItem(val key: String) : SongsScreenEvent +} + +internal class SongsSM( + private val mediaIds: List +) : ScreenModel { + // 持久化元素的状态 + val showSortPanel = mutableStateOf(false) + + // 事件流 + private val eventFlow = MutableSharedFlow() + fun event(): SharedFlow = eventFlow + fun action(action: SongsScreenAction) = screenModelScope.launch { + when (action) { + SongsScreenAction.LocaleToPlayingItem -> { + eventFlow.emit(SongsScreenEvent.ScrollToItem("")) + } + + SongsScreenAction.ToggleSortPanel -> { + showSortPanel.value = !showSortPanel.value + } + + else -> {} + } + } + + // 数据流 + private fun flow(): Flow> { + return if (mediaIds.isEmpty()) LMedia.getFlow() + else LMedia.flowMapBy(mediaIds) + } + + val searcher = ItemSearcher(flow()) + + // val sorter = ItemSorter(searcher.output) + val grouper = ItemGrouper(searcher.output) + val songs = grouper.output.toState(emptyMap(), screenModelScope) +} + +internal class ItemSearcher( + sourceFlow: Flow> +) { + private val keywordStr = MutableStateFlow("") + private val keywordFlow = keywordStr.map { + when { + it.isBlank() -> emptyList() + it.contains(' ') -> it.split(' ') + else -> listOf(it) + } + } + + val output: Flow> = sourceFlow.combine(keywordFlow) { source, keywords -> + source.filter { item -> keywords.all { item.matchStr.contains(it) } } + } + + fun search(keyword: String) { + keywordStr.value = keyword + } + + fun clear() { + keywordStr.value = "" + } +} + +internal class ItemGrouper( + sourceFlow: Flow>, +) { + private val groupByFunc = MutableStateFlow<((T) -> Any)?>(null) + + val output: Flow>> = sourceFlow.combine(groupByFunc) { source, func -> + if (func == null) return@combine mapOf("" to source) + source.groupBy { func(it) } + } + + fun setGroupByFunc(func: (T) -> Any) { + groupByFunc.value = func + } +} + +internal class ItemSorter( + sourceFlow: Flow>, +) { + private val sortByFunc = MutableStateFlow<((T) -> Comparable<*>)?>(null) + + val output: Flow> = sourceFlow.combine(sortByFunc) { source, func -> + if (func == null) return@combine source + source + } + + fun setSortByFunc(func: (T) -> Comparable<*>) { + sortByFunc.value = func + } +} diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 0ab4b5fa0..6df6df785 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -1,24 +1,9 @@ package com.lalilu.lmusic.compose.screen.songs -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel -import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R import com.lalilu.component.base.ScreenAction @@ -27,15 +12,7 @@ import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType -import com.lalilu.component.base.smartBarPadding -import com.lalilu.component.card.SongCard -import com.lalilu.component.extension.toState -import com.lalilu.component.navigation.AppRouter -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lplayer.extensions.PlayerAction import com.zhangke.krouter.annotation.Destination -import kotlinx.coroutines.flow.Flow @Destination("/pages/songs") data class SongsScreen( @@ -58,13 +35,13 @@ data class SongsScreen( title = R.string.screen_action_sort, icon = R.drawable.ic_sort_desc, color = Color(0xFF1793FF), - onAction = { } + onAction = { songsSM?.action(SongsScreenAction.ToggleSortPanel) } ), ScreenAction.StaticAction( title = R.string.screen_action_locate_playing_item, icon = R.drawable.ic_focus_3_line, color = Color(0xFF9317FF), - onAction = { } + onAction = { songsSM?.action(SongsScreenAction.LocaleToPlayingItem) } ), ) } @@ -77,62 +54,8 @@ data class SongsScreen( val sm = rememberScreenModel { SongsSM(mediaIds) } .also { songsSM = it } - SongsScreenContent(songsSM = sm) - } -} + SongsSortPanelDialog(songsSM = sm) -private class SongsSM( - private val mediaIds: List -) : ScreenModel { - private fun flow(): Flow> { - return if (mediaIds.isEmpty()) LMedia.getFlow() - else LMedia.flowMapBy(mediaIds) + SongsScreenContent(songsSM = sm) } - - val songs = flow().toState(emptyList(), screenModelScope) } - -@Composable -private fun SongsScreenContent( - songsSM: SongsSM -) { - val hapticFeedback = LocalHapticFeedback.current - val listState: LazyListState = rememberLazyListState() - val songs by songsSM.songs - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - ) { - item { - Column( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - ) { - Text(text = "全部歌曲") - Text(text = "${songs.size}首歌曲") - } - } - - items( - items = songs, - key = { it.mediaId }, - contentType = { it::class.java } - ) { - SongCard( - song = { it }, - onClick = { PlayerAction.PlayById(it.mediaId).action() }, - onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) - .jump() - }, - ) - } - - smartBarPadding() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt new file mode 100644 index 000000000..a5d21356a --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -0,0 +1,89 @@ +package com.lalilu.lmusic.compose.screen.songs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import com.lalilu.component.base.smartBarPadding +import com.lalilu.component.card.SongCard +import com.lalilu.component.navigation.AppRouter +import com.lalilu.lplayer.extensions.PlayerAction +import kotlinx.coroutines.flow.collectLatest + +@Composable +internal fun SongsScreenContent( + songsSM: SongsSM +) { + val hapticFeedback = LocalHapticFeedback.current + val listState: LazyListState = rememberLazyListState() + val songs by songsSM.songs + + LaunchedEffect(Unit) { + songsSM.event().collectLatest { + when (it) { + is SongsScreenEvent.ScrollToItem -> { + listState.scrollToItem(0) + } + } + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + ) { + Text(text = "全部歌曲") + + val count = remember(songs) { songs.values.flatten().size } + Text(text = "$count 首歌曲") + } + } + + songs.forEach { (group, list) -> + item( + key = group, + contentType = "group" + ) { + Text(text = "$group") + } + + items( + items = list, + key = { it.mediaId }, + contentType = { it::class.java } + ) { + SongCard( + song = { it }, + onClick = { PlayerAction.PlayById(it.mediaId).action() }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.mediaId) + .jump() + }, + ) + } + } + + smartBarPadding() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt new file mode 100644 index 000000000..7b78daa6f --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt @@ -0,0 +1,23 @@ +package com.lalilu.lmusic.compose.screen.songs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.lalilu.component.extension.DialogItem +import com.lalilu.component.extension.DialogWrapper + +@Composable +internal fun SongsSortPanelDialog( + songsSM: SongsSM +) { + val dialog = remember { + DialogItem.Dynamic(backgroundColor = Color.Transparent) { + + } + } + + DialogWrapper.register( + isVisible = songsSM.showSortPanel, + dialogItem = dialog + ) +} \ No newline at end of file From b1b693b77c6c7d7950eb8f0990ece77871b979fc Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 21 Aug 2024 09:14:25 +0800 Subject: [PATCH 069/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E9=9D=A2=E6=9D=BF=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/screen/songs/SongsSM.kt | 85 +++++++++++----- .../compose/screen/songs/SongsScreen.kt | 6 +- .../screen/songs/SongsSortPanelDialog.kt | 97 ++++++++++++++++++- .../lalilu/component/extension/ComposeExt.kt | 2 + 4 files changed, 161 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt index 7548609fc..58174c2fc 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -3,22 +3,37 @@ package com.lalilu.lmusic.compose.screen.songs import androidx.compose.runtime.mutableStateOf import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import com.lalilu.common.base.BaseSp import com.lalilu.component.extension.toState +import com.lalilu.component.viewmodel.SongsSp +import com.lalilu.component.viewmodel.findInstance +import com.lalilu.lhistory.SortRuleLastPlayTime +import com.lalilu.lhistory.SortRulePlayCount import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.BaseMatchable import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortDynamicAction +import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmedia.extension.Sortable +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import org.koin.java.KoinJavaComponent.inject internal sealed interface SongsScreenAction { data object ToggleSortPanel : SongsScreenAction data object LocaleToPlayingItem : SongsScreenAction + data class SearchFor(val keyword: String) : SongsScreenAction + data class SelectSortAction(val action: ListAction) : SongsScreenAction } internal sealed interface SongsScreenEvent { @@ -26,10 +41,22 @@ internal sealed interface SongsScreenEvent { } internal class SongsSM( - private val mediaIds: List + private val mediaIds: List, ) : ScreenModel { // 持久化元素的状态 val showSortPanel = mutableStateOf(false) + val supportSortActions = setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.AddTime, + SortStaticAction.Shuffle, + SortStaticAction.Duration, + SortRulePlayCount, + SortRuleLastPlayTime, +// KoinJavaComponent.getOrNull(ListAction::class.java, named("SortRulePlayCount")), +// KoinJavaComponent.getOrNull(ListAction::class.java, named("SortRuleLastPlayTime")) + ).filterNotNull() + .toSet() // 事件流 private val eventFlow = MutableSharedFlow() @@ -44,6 +71,14 @@ internal class SongsSM( showSortPanel.value = !showSortPanel.value } + is SongsScreenAction.SearchFor -> { + searcher.search(action.keyword) + } + + is SongsScreenAction.SelectSortAction -> { + sorter.selectSortAction(action.action) + } + else -> {} } } @@ -55,10 +90,8 @@ internal class SongsSM( } val searcher = ItemSearcher(flow()) - - // val sorter = ItemSorter(searcher.output) - val grouper = ItemGrouper(searcher.output) - val songs = grouper.output.toState(emptyMap(), screenModelScope) + val sorter = ItemSorter(searcher.output, supportSortActions) + val songs = sorter.output.toState(emptyMap(), screenModelScope) } internal class ItemSearcher( @@ -86,32 +119,34 @@ internal class ItemSearcher( } } -internal class ItemGrouper( +@OptIn(ExperimentalCoroutinesApi::class) +internal class ItemSorter( sourceFlow: Flow>, + private val supportSortActions: Set, ) { - private val groupByFunc = MutableStateFlow<((T) -> Any)?>(null) - - val output: Flow>> = sourceFlow.combine(groupByFunc) { source, func -> - if (func == null) return@combine mapOf("" to source) - source.groupBy { func(it) } - } + private val baseSp: BaseSp by inject(SongsSp::class.java) + private val sortActionKey = baseSp.obtain("SONGS_SORT_RULE_KEY", "") + + private val sortActionFlow = sortActionKey + .flow(true) + .mapLatest { key -> + supportSortActions.findInstance { it::class.java.name == key } + ?: SortStaticAction.Normal + } - fun setGroupByFunc(func: (T) -> Any) { - groupByFunc.value = func + val output = sortActionFlow.flatMapLatest { action -> + when (action) { + is SortStaticAction -> sourceFlow.mapLatest { action.doSort(it, false) } + is SortDynamicAction -> action.doSort(sourceFlow, false) + else -> flowOf(emptyMap()) + } } -} - -internal class ItemSorter( - sourceFlow: Flow>, -) { - private val sortByFunc = MutableStateFlow<((T) -> Comparable<*>)?>(null) - val output: Flow> = sourceFlow.combine(sortByFunc) { source, func -> - if (func == null) return@combine source - source + fun selectSortAction(action: ListAction) { + sortActionKey.value = action::class.java.name } - fun setSortByFunc(func: (T) -> Comparable<*>) { - sortByFunc.value = func + fun isSortActionSelected(action: ListAction): Boolean { + return sortActionKey.value == action::class.java.name } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 6df6df785..6aed44242 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -54,7 +54,11 @@ data class SongsScreen( val sm = rememberScreenModel { SongsSM(mediaIds) } .also { songsSM = it } - SongsSortPanelDialog(songsSM = sm) + SongsSortPanelDialog( + isVisible = sm.showSortPanel, + supportSortActions = sm.supportSortActions, + onSelectSortAction = { sm.action(SongsScreenAction.SelectSortAction(it)) } + ) SongsScreenContent(songsSM = sm) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt index 7b78daa6f..156d1bcf7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt @@ -1,23 +1,114 @@ package com.lalilu.lmusic.compose.screen.songs +import android.content.res.Configuration +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.lalilu.component.extension.DialogItem import com.lalilu.component.extension.DialogWrapper +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lmusic.LMusicTheme + @Composable internal fun SongsSortPanelDialog( - songsSM: SongsSM + isVisible: MutableState, + supportSortActions: Set, + onSelectSortAction: (ListAction) -> Unit ) { val dialog = remember { DialogItem.Dynamic(backgroundColor = Color.Transparent) { - + SongsSortPanelDialogContent( + supportSortActions = supportSortActions, + onSelectSortAction = onSelectSortAction + ) } } DialogWrapper.register( - isVisible = songsSM.showSortPanel, + isVisible = isVisible, dialogItem = dialog ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun SongsSortPanelDialogContent( + modifier: Modifier = Modifier, + supportSortActions: Set, + onSelectSortAction: (ListAction) -> Unit = {} +) { + Surface( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(8.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.onBackground.copy(0.1f)), + shape = RoundedCornerShape(18.dp), + elevation = 10.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + supportSortActions.forEach { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + backgroundColor = Color(0xFF6176F8), + onClick = { onSelectSortAction(it) } + ) { + Box(modifier = Modifier.padding(8.dp)) { + Text(text = stringResource(id = it.titleRes)) + } + } + } + } + } +} + +@Preview( + showSystemUi = false, + showBackground = true, +) +@Composable +private fun SongsSortPanelDialogPVDay() { + LMusicTheme { + SongsSortPanelDialogContent( + supportSortActions = setOf(SortStaticAction.Normal) + ) + } +} + +@Preview( + showSystemUi = false, + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, +) +@Composable +private fun SongsSortPanelDialogPV() { + LMusicTheme { + SongsSortPanelDialogContent( + supportSortActions = setOf(SortStaticAction.Normal) + ) + } } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt index 5e69bbe1a..fde8ced53 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt @@ -78,12 +78,14 @@ fun WindowSizeClass.rememberIsPad(): State { } } +@Deprecated("弃用") @Composable fun dayNightTextColor(alpha: Float = 1f): Color { val color = contentColorFor(backgroundColor = MaterialTheme.colors.background) return remember(color) { color.copy(alpha = alpha) } } +@Deprecated("弃用") @Composable fun dayNightTextColorFilter(alpha: Float = 1f): ColorFilter { val color = contentColorFor(backgroundColor = MaterialTheme.colors.background) From 879e8c4411455e00997be1d0143db6cc12549c66 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Wed, 21 Aug 2024 14:32:10 +0800 Subject: [PATCH 070/213] =?UTF-8?q?[refactor]=E5=BC=95=E5=85=A5Koin-Annota?= =?UTF-8?q?tions=EF=BC=8C=E8=B0=83=E6=95=B4=E4=BE=9D=E8=B5=96=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E7=9A=84=E5=AE=9A=E4=B9=89=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 3 +- .../lmusic/compose/screen/songs/SongsSM.kt | 12 +++---- common/build.gradle.kts | 4 +-- .../java/com/lalilu/common/ext/KoinExt.kt | 10 ++++++ gradle/libs.versions.toml | 8 +++++ lhistory/build.gradle.kts | 1 + .../com/lalilu/lhistory/ExtendSortRule.kt | 11 ++++-- .../java/com/lalilu/lhistory/HistoryModule.kt | 35 +++++++++++-------- .../repository/HistoryRepositoryImpl.kt | 2 ++ .../lalilu/lhistory/screen/HistoryScreen.kt | 2 ++ 10 files changed, 61 insertions(+), 27 deletions(-) create mode 100644 common/src/main/java/com/lalilu/common/ext/KoinExt.kt diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 7c9af51d8..d05a43be0 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -23,6 +23,7 @@ import com.zhangke.krouter.generated.KRouterInjectMap import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import org.koin.ksp.generated.module import java.io.File class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, ViewModelStoreOwner { @@ -59,7 +60,7 @@ class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, V FilterModule, PlaylistModule, ComponentModule, - HistoryModule, + HistoryModule.module, ArtistModule, AlbumModule, DictionaryModule, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt index 58174c2fc..01fc62b15 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -4,11 +4,10 @@ import androidx.compose.runtime.mutableStateOf import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.lalilu.common.base.BaseSp +import com.lalilu.common.ext.requestFor import com.lalilu.component.extension.toState import com.lalilu.component.viewmodel.SongsSp import com.lalilu.component.viewmodel.findInstance -import com.lalilu.lhistory.SortRuleLastPlayTime -import com.lalilu.lhistory.SortRulePlayCount import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.BaseMatchable import com.lalilu.lmedia.entity.LSong @@ -27,6 +26,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import org.koin.core.qualifier.named import org.koin.java.KoinJavaComponent.inject internal sealed interface SongsScreenAction { @@ -45,16 +45,14 @@ internal class SongsSM( ) : ScreenModel { // 持久化元素的状态 val showSortPanel = mutableStateOf(false) - val supportSortActions = setOf( + val supportSortActions = setOf( SortStaticAction.Normal, SortStaticAction.Title, SortStaticAction.AddTime, SortStaticAction.Shuffle, SortStaticAction.Duration, - SortRulePlayCount, - SortRuleLastPlayTime, -// KoinJavaComponent.getOrNull(ListAction::class.java, named("SortRulePlayCount")), -// KoinJavaComponent.getOrNull(ListAction::class.java, named("SortRuleLastPlayTime")) + requestFor(named("sort_rule_play_count")), + requestFor(named("sort_rule_last_play_time")), ).filterNotNull() .toSet() diff --git a/common/build.gradle.kts b/common/build.gradle.kts index be1d405b1..a7c2cbc81 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { api("io.github.billywei01:fastkv:2.4.2") api("io.github.billywei01:packable:1.1.0") - api(libs.koin.android) - api(libs.koin.compose) - + api(libs.bundles.koin) api(libs.krouter.core) } \ No newline at end of file diff --git a/common/src/main/java/com/lalilu/common/ext/KoinExt.kt b/common/src/main/java/com/lalilu/common/ext/KoinExt.kt new file mode 100644 index 000000000..c8f1fc04f --- /dev/null +++ b/common/src/main/java/com/lalilu/common/ext/KoinExt.kt @@ -0,0 +1,10 @@ +package com.lalilu.common.ext + +import org.koin.core.parameter.ParametersDefinition +import org.koin.core.qualifier.Qualifier +import org.koin.java.KoinJavaComponent + +inline fun requestFor( + qualifier: Qualifier? = null, + noinline parameters: ParametersDefinition? = null, +): T? = KoinJavaComponent.getOrNull(T::class.java, qualifier, parameters) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 783407ab1..1cdbbf9c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ ksp_version = "2.0.0-1.0.22" #serialization_json_version = "1.6.0" koin_version = "3.5.6" +koin_ksp_version = "1.3.1" compose_bom_alpha_version = "2024.08.00-alpha01" compose_bom_version = "2024.06.00" accompanist_version = "0.32.0" @@ -76,6 +77,8 @@ accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist- # koin koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin_version" } koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin_version" } +koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin_ksp_version" } +koin-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin_ksp_version" } # coil # https://github.com/coil-kt/coil @@ -158,4 +161,9 @@ coil3 = [ "coil3-android", "coil3-compose", "coil3-okhttp" +] +koin = [ + "koin-android", + "koin-compose", + "koin-annotations" ] \ No newline at end of file diff --git a/lhistory/build.gradle.kts b/lhistory/build.gradle.kts index 04f03d267..7c25227e1 100644 --- a/lhistory/build.gradle.kts +++ b/lhistory/build.gradle.kts @@ -45,4 +45,5 @@ dependencies { ksp(libs.room.compiler) implementation(project(":component")) + ksp(libs.koin.compiler) } \ No newline at end of file diff --git a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt b/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt index 3ef2ff235..12bc7d421 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt @@ -2,13 +2,18 @@ package com.lalilu.lhistory import com.lalilu.lhistory.repository.HistoryRepository import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortDynamicAction import com.lalilu.lmedia.extension.Sortable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single import org.koin.java.KoinJavaComponent -object SortRulePlayCount : +@Named("sort_rule_play_count") +@Single(binds = [ListAction::class]) +class SortRulePlayCount : SortDynamicAction(titleRes = R.string.sort_preset_by_played_times) { private val historyRepo: HistoryRepository by KoinJavaComponent.inject(HistoryRepository::class.java) @@ -27,7 +32,9 @@ object SortRulePlayCount : } } -object SortRuleLastPlayTime : +@Named("sort_rule_last_play_time") +@Single(binds = [ListAction::class]) +class SortRuleLastPlayTime : SortDynamicAction(titleRes = R.string.sort_preset_by_last_play_time) { private val historyRepo: HistoryRepository by KoinJavaComponent.inject(HistoryRepository::class.java) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/HistoryModule.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryModule.kt index 111a7db0b..b00df923b 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/HistoryModule.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryModule.kt @@ -1,20 +1,27 @@ package com.lalilu.lhistory +import android.app.Application import androidx.room.Room -import com.lalilu.lhistory.repository.HistoryRepository -import com.lalilu.lhistory.repository.HistoryRepositoryImpl +import com.lalilu.lhistory.repository.HistoryDao import com.lalilu.lhistory.repository.LDatabase -import com.lalilu.lhistory.screen.HistoryScreenModel -import org.koin.android.ext.koin.androidApplication -import org.koin.core.module.dsl.factoryOf -import org.koin.dsl.module +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single -val HistoryModule = module { - single { - Room.databaseBuilder(androidApplication(), LDatabase::class.java, "lmedia.db") - .fallbackToDestructiveMigration() - .build() - } - single { HistoryRepositoryImpl(get().historyDao()) } - factoryOf(::HistoryScreenModel) +@Module +@ComponentScan("com.lalilu.lhistory") +object HistoryModule + +@Single +fun provideRoom( + application: Application +): LDatabase { + return Room.databaseBuilder(application, LDatabase::class.java, "lmedia.db") + .fallbackToDestructiveMigration() + .build() +} + +@Single +fun provideHistoryDao(database: LDatabase): HistoryDao { + return database.historyDao() } \ No newline at end of file diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt index 3e26b0c18..c00471891 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt @@ -8,8 +8,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch +import org.koin.core.annotation.Single import kotlin.coroutines.CoroutineContext +@Single(binds = [HistoryRepository::class]) class HistoryRepositoryImpl( private val historyDao: HistoryDao ) : HistoryRepository, CoroutineScope { diff --git a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt index 7d2d1b484..305af36a9 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt @@ -16,6 +16,7 @@ import com.lalilu.lhistory.R import com.lalilu.lhistory.repository.HistoryRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.mapLatest +import org.koin.core.annotation.Factory import com.lalilu.component.R as ComponentR data object HistoryScreen : DynamicScreen() { @@ -32,6 +33,7 @@ data object HistoryScreen : DynamicScreen() { } } +@Factory class HistoryScreenModel( historyRepo: HistoryRepository ) : ScreenModel { From 040962ce63722d27827344372ed20650ebc81553 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 22 Aug 2024 20:30:03 +0800 Subject: [PATCH 071/213] =?UTF-8?q?[refactor]=E8=B0=83=E6=95=B4=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E8=8F=9C=E5=8D=95=E5=BC=B9=E7=AA=97=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/songs/SongsScreen.kt | 1 + .../screen/songs/SongsSortPanelDialog.kt | 106 +++++++++++++++--- component/build.gradle.kts | 1 + .../com/lalilu/lhistory/ExtendSortRule.kt | 15 +-- 4 files changed, 99 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 6aed44242..033bd5ceb 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -57,6 +57,7 @@ data class SongsScreen( SongsSortPanelDialog( isVisible = sm.showSortPanel, supportSortActions = sm.supportSortActions, + isSortActionSelected = { sm.sorter.isSortActionSelected(it) }, onSelectSortAction = { sm.action(SongsScreenAction.SelectSortAction(it)) } ) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt index 156d1bcf7..1a5fb8a35 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt @@ -3,25 +3,37 @@ package com.lalilu.lmusic.compose.screen.songs import android.content.res.Configuration import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card +import androidx.compose.material.ChipDefaults import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FilterChip +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cheonjaeung.compose.grid.SimpleGridCells +import com.cheonjaeung.compose.grid.VerticalGrid import com.lalilu.component.extension.DialogItem import com.lalilu.component.extension.DialogWrapper import com.lalilu.lmedia.extension.ListAction @@ -33,13 +45,16 @@ import com.lalilu.lmusic.LMusicTheme internal fun SongsSortPanelDialog( isVisible: MutableState, supportSortActions: Set, + isSortActionSelected: (ListAction) -> Boolean = { false }, onSelectSortAction: (ListAction) -> Unit ) { val dialog = remember { DialogItem.Dynamic(backgroundColor = Color.Transparent) { SongsSortPanelDialogContent( supportSortActions = supportSortActions, - onSelectSortAction = onSelectSortAction + isSortActionSelected = isSortActionSelected, + onSelectSortAction = onSelectSortAction, + onDismiss = { dismiss() } ) } } @@ -55,30 +70,91 @@ internal fun SongsSortPanelDialog( private fun SongsSortPanelDialogContent( modifier: Modifier = Modifier, supportSortActions: Set, - onSelectSortAction: (ListAction) -> Unit = {} + isSortActionSelected: (ListAction) -> Boolean = { false }, + onSelectSortAction: (ListAction) -> Unit = {}, + onDismiss: () -> Unit = {} ) { + val colors = ChipDefaults.filterChipColors( + selectedBackgroundColor = Color(0xFF029DF3), + selectedContentColor = Color.White, + backgroundColor = MaterialTheme.colors.onSurface + .compositeOver(MaterialTheme.colors.surface) + .copy(alpha = 0.05f) + ) + Surface( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .padding(8.dp), + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .navigationBarsPadding(), border = BorderStroke(1.dp, MaterialTheme.colors.onBackground.copy(0.1f)), shape = RoundedCornerShape(18.dp), elevation = 10.dp ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + VerticalGrid( + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 20.dp + ), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + columns = SimpleGridCells.Fixed(2) ) { + Row( + modifier = Modifier + .span(2) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier + .weight(1f), + text = "常用排序逻辑", + fontSize = 14.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Bold, + ) + + IconButton(onClick = { onDismiss() }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null + ) + } + } + supportSortActions.forEach { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - backgroundColor = Color(0xFF6176F8), - onClick = { onSelectSortAction(it) } + FilterChip( + modifier = Modifier + .fillMaxWidth(), + colors = colors, + shape = RoundedCornerShape(5.dp), + selected = isSortActionSelected(it), + onClick = { onSelectSortAction(it) }, ) { - Box(modifier = Modifier.padding(8.dp)) { - Text(text = stringResource(id = it.titleRes)) + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + fontSize = 12.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.Bold, + text = stringResource(id = it.titleRes) + ) + + Text( + modifier = Modifier.fillMaxWidth(), + text = "test", + fontSize = 10.sp, + lineHeight = 10.sp, + ) } } } diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 81e4ba13c..99704e5c7 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { api("me.rosuh:AndroidFilePicker:1.0.1") api("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha13") api("com.github.cy745.KRouter:core:fcf40f4b15") + api("com.cheonjaeung.compose.grid:grid:2.0.0") // compose // api(platform(libs.compose.bom)) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt b/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt index 12bc7d421..07528d0f9 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt @@ -9,14 +9,12 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.koin.java.KoinJavaComponent @Named("sort_rule_play_count") @Single(binds = [ListAction::class]) -class SortRulePlayCount : - SortDynamicAction(titleRes = R.string.sort_preset_by_played_times) { - - private val historyRepo: HistoryRepository by KoinJavaComponent.inject(HistoryRepository::class.java) +class SortRulePlayCount( + private val historyRepo: HistoryRepository +) : SortDynamicAction(titleRes = R.string.sort_preset_by_played_times) { override fun doSort( items: Flow>, @@ -34,10 +32,9 @@ class SortRulePlayCount : @Named("sort_rule_last_play_time") @Single(binds = [ListAction::class]) -class SortRuleLastPlayTime : - SortDynamicAction(titleRes = R.string.sort_preset_by_last_play_time) { - - private val historyRepo: HistoryRepository by KoinJavaComponent.inject(HistoryRepository::class.java) +class SortRuleLastPlayTime( + private val historyRepo: HistoryRepository +) : SortDynamicAction(titleRes = R.string.sort_preset_by_last_play_time) { override fun doSort( items: Flow>, From 0c87f4afd5311eda9954d0bf5f82fd2d2220f45f Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 26 Aug 2024 09:02:49 +0800 Subject: [PATCH 072/213] =?UTF-8?q?[refactor]=E9=87=8D=E6=9E=84SmartBar?= =?UTF-8?q?=E7=9A=84ScreenAction=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 - .../compose/component/base/IconCheckButton.kt | 85 ----- .../new_screen/detail/SongDetailScreen.kt | 91 ++---- .../presenter/DetailScreenPresenter.kt | 13 +- .../compose/screen/detail/SongLikeAction.kt | 96 ++++++ .../compose/screen/detail/SongPlayAction.kt | 79 +++++ .../lmusic/compose/screen/songs/SongsSM.kt | 10 +- .../compose/screen/songs/SongsScreen.kt | 26 +- .../screen/songs/SongsSortPanelDialog.kt | 86 +++-- component/build.gradle.kts | 1 + .../com/lalilu/component/base/CustomScreen.kt | 5 +- .../base/screen/ScreenActionFactory.kt | 25 +- .../lalilu/component/extension/DialogHost.kt | 38 +-- .../component/navigation/NavigateCommonBar.kt | 300 ++++++++++++++++++ .../component/navigation/NavigateTabBar.kt | 125 ++++++++ .../navigation/NavigationSmartBar.kt | 212 ------------- 16 files changed, 755 insertions(+), 438 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/component/base/IconCheckButton.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongLikeAction.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt create mode 100644 component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt create mode 100644 component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21327909d..9e0eb33cb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,7 +59,6 @@ android { buildFeatures { compose = true - viewBinding = true buildConfig = true } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/base/IconCheckButton.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/base/IconCheckButton.kt deleted file mode 100644 index 03a1c392d..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/base/IconCheckButton.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.lalilu.lmusic.compose.component.base - -import androidx.annotation.DrawableRes -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.minimumInteractiveComponentSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.Role -import com.lalilu.component.extension.dayNightTextColor - -@Composable -fun IconCheckButton( - modifier: Modifier = Modifier, - shape: Shape = CircleShape, - checkedColor: Color = MaterialTheme.colors.primary, - @DrawableRes checkedIconRes: Int, - @DrawableRes normalIconRes: Int, - getIsChecked: () -> Boolean, - onCheckedChange: (Boolean) -> Unit = {}, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } -) { - val isChecked = getIsChecked() - val haptic = LocalHapticFeedback.current - val pressedState = interactionSource.collectIsPressedAsState() - val iconColor by animateColorAsState( - targetValue = if (isChecked) checkedColor else dayNightTextColor(0.3f), - label = "" - ) - val scaleValue by animateFloatAsState( - animationSpec = SpringSpec(dampingRatio = Spring.DampingRatioMediumBouncy), - targetValue = if (pressedState.value) 1.2f else 1f, - label = "" - ) - - Surface( - modifier = modifier, - shape = shape, - color = iconColor.copy(0.15f) - ) { - Box( - modifier = modifier - .minimumInteractiveComponentSize() - .toggleable( - value = isChecked, - onValueChange = { - onCheckedChange(it) - if (it) { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - } - }, - role = Role.Checkbox, - interactionSource = interactionSource, - indication = null - ), - contentAlignment = Alignment.Center - ) { - Icon( - modifier = Modifier.scale(scaleValue), - painter = painterResource(id = if (isChecked) checkedIconRes else normalIconRes), - tint = iconColor, - contentDescription = "A Checkable Button" - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index 02b10234d..4a386d088 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -1,16 +1,9 @@ package com.lalilu.lmusic.compose.new_screen.detail -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -18,15 +11,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import com.lalilu.R -import com.lalilu.component.IconButton import com.lalilu.component.base.LocalEnhanceSheetState -import com.lalilu.component.base.ScreenAction +import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory @@ -35,10 +25,8 @@ import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.override.ModalBottomSheetValue import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.compose.component.base.IconCheckButton -import com.lalilu.lmusic.compose.presenter.DetailScreenAction -import com.lalilu.lmusic.compose.presenter.DetailScreenIsPlayingPresenter -import com.lalilu.lmusic.compose.presenter.DetailScreenLikeBtnPresenter +import com.lalilu.lmusic.compose.screen.detail.provideSongLikeAction +import com.lalilu.lmusic.compose.screen.detail.provideSongPlayAction import com.lalilu.lplayer.extensions.QueueAction import com.zhangke.krouter.annotation.Destination import com.zhangke.krouter.annotation.Param @@ -55,62 +43,25 @@ data class SongDetailScreen( } @Composable - override fun provideScreenActions(): List { - return remember(this) { - listOf( - ScreenAction.StaticAction( - title = R.string.button_set_song_to_next, - color = Color(0xFF00AC84), - onAction = { - val song = LMedia.get(id = mediaId) ?: return@StaticAction - QueueAction.AddToNext(song.mediaId).action() - DynamicTipsItem.Static( - title = song.title, - subTitle = "下一首播放", - imageData = song.imageSource - ).show() - } - ), - ScreenAction.ComposeAction { - val state = DetailScreenLikeBtnPresenter(mediaId) - - IconCheckButton( - modifier = Modifier - .fillMaxHeight() - .aspectRatio(4f / 3f), - shape = RectangleShape, - getIsChecked = { state.isLiked }, - onCheckedChange = { state.onAction(if (it) DetailScreenAction.Like else DetailScreenAction.UnLike) }, - checkedColor = MaterialTheme.colors.primary, - checkedIconRes = R.drawable.ic_heart_3_fill, - normalIconRes = R.drawable.ic_heart_3_line - ) - }, - ScreenAction.ComposeAction { - val state = DetailScreenIsPlayingPresenter(mediaId) + override fun provideScreenActions(): List = remember(this) { + listOf( + provideSongLikeAction(mediaId), + provideSongPlayAction(mediaId), + ScreenAction.Static( + title = { stringResource(id = R.string.button_set_song_to_next) }, + color = { Color(0xFF00AC84) }, + onAction = { + val song = LMedia.get(id = mediaId) ?: return@Static - AnimatedContent( - modifier = Modifier - .fillMaxHeight() - .aspectRatio(3f / 2f), - targetState = state.isPlaying, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "" - ) { isPlaying -> - val icon = - if (isPlaying) R.drawable.ic_pause_line else R.drawable.ic_play_line - IconButton( - modifier = Modifier.fillMaxSize(), - color = Color(0xFF006E7C), - shape = RectangleShape, - text = stringResource(id = R.string.text_button_play), - icon = painterResource(id = icon), - onClick = { state.onAction(DetailScreenAction.PlayPause) } - ) - } - }, - ) - } + QueueAction.AddToNext(song.mediaId).action() + DynamicTipsItem.Static( + title = song.title, + subTitle = "下一首播放", + imageData = song.imageSource + ).show() + } + ), + ) } @Composable diff --git a/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt b/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt index c46b3e261..eec683336 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt @@ -2,8 +2,11 @@ package com.lalilu.lmusic.compose.presenter import android.annotation.SuppressLint import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.lalilu.common.base.Playable import com.lalilu.component.base.UiAction @@ -58,9 +61,15 @@ fun DetailScreenIsPlayingPresenter( mediaId: String, playingVM: PlayingViewModel = koinInject() ): DetailScreenIsPlayingState { - val isPlaying = playingVM.isItemPlaying(mediaId, Playable::mediaId) + val isPlaying = remember { + derivedStateOf { playingVM.isItemPlaying(mediaId, Playable::mediaId) } + } + + SideEffect { + println("isPlaying: ${isPlaying}") + } - return DetailScreenIsPlayingState(isPlaying = isPlaying) { + return DetailScreenIsPlayingState(isPlaying = isPlaying.value) { when (it) { DetailScreenAction.PlayPause -> playingVM.play( mediaId = mediaId, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongLikeAction.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongLikeAction.kt new file mode 100644 index 000000000..f7d3fd70a --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongLikeAction.kt @@ -0,0 +1,96 @@ +package com.lalilu.lmusic.compose.screen.detail + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.lmusic.compose.presenter.DetailScreenAction +import com.lalilu.lmusic.compose.presenter.DetailScreenLikeBtnPresenter +import com.lalilu.remixicon.HealthAndMedical +import com.lalilu.remixicon.healthandmedical.heart3Fill +import com.lalilu.remixicon.healthandmedical.heart3Line + +fun provideSongLikeAction(mediaId: String): ScreenAction.Dynamic { + return ScreenAction.Dynamic { actionContext -> + val state = DetailScreenLikeBtnPresenter(mediaId) + + val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + val haptic = LocalHapticFeedback.current + val pressedState = interactionSource.collectIsPressedAsState() + val iconColor by animateColorAsState( + targetValue = if (state.isLiked) MaterialTheme.colors.primary + else MaterialTheme.colors.onBackground.copy(0.3f), + label = "" + ) + val scaleValue by animateFloatAsState( + animationSpec = SpringSpec(dampingRatio = Spring.DampingRatioMediumBouncy), + targetValue = if (pressedState.value) 1.2f else 1f, + label = "" + ) + + Surface( + modifier = Modifier, + color = iconColor.copy(0.15f) + ) { + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .toggleable( + value = state.isLiked, + onValueChange = { + state.onAction(if (it) DetailScreenAction.Like else DetailScreenAction.UnLike) + if (it) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + role = Role.Checkbox, + interactionSource = interactionSource, + indication = null + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier + .size(24.dp) + .scale(scaleValue), + imageVector = if (state.isLiked) RemixIcon.HealthAndMedical.heart3Fill else RemixIcon.HealthAndMedical.heart3Line, + tint = iconColor, + contentDescription = "A Checkable Button" + ) + + if (actionContext.isFullyExpanded) { + Text( + text = "收藏", + fontSize = 14.sp, + lineHeight = 14.sp, + color = iconColor, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt new file mode 100644 index 000000000..1d5adf79d --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt @@ -0,0 +1,79 @@ +package com.lalilu.lmusic.compose.screen.detail + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.R +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.lmusic.compose.presenter.DetailScreenAction +import com.lalilu.lmusic.compose.presenter.DetailScreenIsPlayingPresenter +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.media.pauseLine +import com.lalilu.remixicon.media.playLine + + +@OptIn(ExperimentalMaterialApi::class) +fun provideSongPlayAction(mediaId: String): ScreenAction.Dynamic { + return ScreenAction.Dynamic { actionContext -> + val state = DetailScreenIsPlayingPresenter(mediaId) + val color = Color(0xFF006E7C) + + Surface( + color = color.copy(0.2f), + onClick = { state.onAction(DetailScreenAction.PlayPause) } + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AnimatedContent( + modifier = Modifier + .fillMaxHeight(), + targetState = state.isPlaying, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { isPlaying -> + val icon = if (isPlaying) RemixIcon.Media.pauseLine + else RemixIcon.Media.playLine + + Image( + modifier = Modifier.size(24.dp), + imageVector = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(color = color) + ) + } + + if (actionContext.isFullyExpanded) { + Text( + text = stringResource(id = R.string.text_button_play), + fontSize = 14.sp, + lineHeight = 14.sp, + color = color, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt index 01fc62b15..4dab36030 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -33,7 +33,6 @@ internal sealed interface SongsScreenAction { data object ToggleSortPanel : SongsScreenAction data object LocaleToPlayingItem : SongsScreenAction data class SearchFor(val keyword: String) : SongsScreenAction - data class SelectSortAction(val action: ListAction) : SongsScreenAction } internal sealed interface SongsScreenEvent { @@ -73,10 +72,6 @@ internal class SongsSM( searcher.search(action.keyword) } - is SongsScreenAction.SelectSortAction -> { - sorter.selectSortAction(action.action) - } - else -> {} } } @@ -145,6 +140,11 @@ internal class ItemSorter( } fun isSortActionSelected(action: ListAction): Boolean { + // 初次启动时若key值为空,则默认为Normal + if (sortActionKey.value.isBlank()) { + return action::class.java == SortStaticAction.Normal::class.java + } + return sortActionKey.value == action::class.java.name } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 033bd5ceb..770cb3d1d 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -3,15 +3,21 @@ package com.lalilu.lmusic.compose.screen.songs import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R -import com.lalilu.component.base.ScreenAction +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType +import com.lalilu.remixicon.Design +import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.design.focus3Line +import com.lalilu.remixicon.editor.sortDesc import com.zhangke.krouter.annotation.Destination @Destination("/pages/songs") @@ -31,16 +37,16 @@ data class SongsScreen( @Composable override fun provideScreenActions(): List = remember { listOf( - ScreenAction.StaticAction( - title = R.string.screen_action_sort, - icon = R.drawable.ic_sort_desc, - color = Color(0xFF1793FF), + ScreenAction.Static( + title = { stringResource(id = R.string.screen_action_sort) }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, onAction = { songsSM?.action(SongsScreenAction.ToggleSortPanel) } ), - ScreenAction.StaticAction( - title = R.string.screen_action_locate_playing_item, - icon = R.drawable.ic_focus_3_line, - color = Color(0xFF9317FF), + ScreenAction.Static( + title = { stringResource(id = R.string.screen_action_locate_playing_item) }, + icon = { RemixIcon.Design.focus3Line }, + color = { Color(0xFF9317FF) }, onAction = { songsSM?.action(SongsScreenAction.LocaleToPlayingItem) } ), ) @@ -58,7 +64,7 @@ data class SongsScreen( isVisible = sm.showSortPanel, supportSortActions = sm.supportSortActions, isSortActionSelected = { sm.sorter.isSortActionSelected(it) }, - onSelectSortAction = { sm.action(SongsScreenAction.SelectSortAction(it)) } + onSelectSortAction = { sm.sorter.selectSortAction(it) } ) SongsScreenContent(songsSM = sm) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt index 1a5fb8a35..ae4038bba 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt @@ -16,6 +16,7 @@ import androidx.compose.material.FilterChip import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.SelectableChipColors import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -94,10 +95,9 @@ private fun SongsSortPanelDialogContent( elevation = 10.dp ) { VerticalGrid( - modifier = Modifier.padding( - horizontal = 16.dp, - vertical = 20.dp - ), + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), columns = SimpleGridCells.Fixed(2) @@ -127,41 +127,61 @@ private fun SongsSortPanelDialogContent( } supportSortActions.forEach { - FilterChip( - modifier = Modifier - .fillMaxWidth(), + SortItem( + modifier = Modifier.fillMaxWidth(), + title = stringResource(id = it.titleRes), + subTitle = "test", colors = colors, - shape = RoundedCornerShape(5.dp), - selected = isSortActionSelected(it), - onClick = { onSelectSortAction(it) }, - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - modifier = Modifier.fillMaxWidth(), - fontSize = 12.sp, - lineHeight = 12.sp, - fontWeight = FontWeight.Bold, - text = stringResource(id = it.titleRes) - ) - - Text( - modifier = Modifier.fillMaxWidth(), - text = "test", - fontSize = 10.sp, - lineHeight = 10.sp, - ) - } - } + selected = { isSortActionSelected(it) }, + onClick = { onSelectSortAction(it) } + ) } } } } +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun SortItem( + modifier: Modifier = Modifier, + title: String, + subTitle: String = "", + colors: SelectableChipColors = ChipDefaults.filterChipColors(), + selected: () -> Boolean, + onClick: () -> Unit = {} +) { + FilterChip( + modifier = modifier + .fillMaxWidth(), + colors = colors, + shape = RoundedCornerShape(5.dp), + selected = selected(), + onClick = onClick, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + fontSize = 12.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.Bold, + text = title + ) + + Text( + modifier = Modifier.fillMaxWidth(), + text = subTitle, + fontSize = 10.sp, + lineHeight = 10.sp, + ) + } + } +} + @Preview( showSystemUi = false, showBackground = true, diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 99704e5c7..5274bb77c 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { api("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha13") api("com.github.cy745.KRouter:core:fcf40f4b15") api("com.cheonjaeung.compose.grid:grid:2.0.0") + api("com.github.cy745.RemixIcon-Kmp:core:1a3c554a35") // compose // api(platform(libs.compose.bom)) diff --git a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt index 483123401..800dd0008 100644 --- a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt +++ b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt @@ -5,6 +5,7 @@ import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.base.screen.ScreenInfoFactory @@ -13,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope /** * 定义一个页面的信息 */ +@Deprecated("弃用", replaceWith = ReplaceWith("com.lalilu.component.base.screen.ScreenInfo")) data class ScreenInfo( @StringRes val title: Int, @DrawableRes val icon: Int? = null, @@ -22,6 +24,7 @@ data class ScreenInfo( /** * 定义某个页面可执行的动作 */ +@Deprecated("弃用") sealed interface ScreenAction { data class StaticAction( @StringRes val title: Int, @@ -33,7 +36,7 @@ sealed interface ScreenAction { ) : ScreenAction data class ComposeAction( - val content: @Composable () -> Unit + val content: @Composable (Modifier) -> Unit ) : ScreenAction } diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt index ccfd329b4..10743fbc8 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt @@ -1,8 +1,31 @@ package com.lalilu.component.base.screen import androidx.compose.runtime.Composable -import com.lalilu.component.base.ScreenAction +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +@Stable +@Immutable +data class ActionContext( + val isFullyExpanded: Boolean = false +) + +sealed class ScreenAction { + @Stable + data class Static( + val title: @Composable () -> String, + val color: @Composable () -> Color = { Color.White }, + val icon: @Composable () -> ImageVector? = { null }, + val onAction: () -> Unit = {} + ) : ScreenAction() + + @Stable + data class Dynamic( + val content: @Composable (ActionContext) -> Unit + ) : ScreenAction() +} interface ScreenActionFactory { diff --git a/component/src/main/java/com/lalilu/component/extension/DialogHost.kt b/component/src/main/java/com/lalilu/component/extension/DialogHost.kt index 2a431d967..28e447a8c 100644 --- a/component/src/main/java/com/lalilu/component/extension/DialogHost.kt +++ b/component/src/main/java/com/lalilu/component/extension/DialogHost.kt @@ -1,5 +1,6 @@ package com.lalilu.component.extension +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -110,7 +111,7 @@ object DialogWrapper : DialogHost, DialogContext { @Composable override fun Content() { if (dialogItem == null) return - + val isActiveClose by remember { mutableStateOf(false) .also { dismissFunc = { it.value = true } } @@ -151,24 +152,25 @@ object DialogWrapper : DialogHost, DialogContext { dialogItem = null }, content = { - dialogItem?.let { - when (it) { - is DialogItem.Static -> { - StaticDialogCard( - title = it.title, - message = it.message, - onConfirm = { - dismiss() - it.onConfirm() - }, - onCancel = { - dismiss() - it.onCancel() - } - ) - } + AnimatedContent( + targetState = dialogItem, + label = "" + ) { dialog -> + dialog?.apply { + when (this) { + is DialogItem.Static -> { + StaticDialogCard( + title = title, + message = message, + onConfirm = { dismiss(); onConfirm() }, + onCancel = { dismiss(); onCancel() } + ) + } - is DialogItem.Dynamic -> it.content.invoke(this@DialogWrapper) + is DialogItem.Dynamic -> { + content.invoke(this@DialogWrapper) + } + } } } } diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt new file mode 100644 index 000000000..2cef8eae5 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt @@ -0,0 +1,300 @@ +package com.lalilu.component.navigation + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachReversed +import cafe.adriel.voyager.core.screen.Screen +import com.lalilu.RemixIcon +import com.lalilu.component.R +import com.lalilu.component.base.screen.ActionContext +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.extension.DialogItem +import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.extension.toColorFilter +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.more2Fill + + +@Composable +fun NavigateCommonBar( + modifier: Modifier = Modifier, + previousTitle: String, + currentScreen: Screen? +) { + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val screenActions = (currentScreen as? ScreenActionFactory)?.provideScreenActions() + val moreActionPanelDialogVisible = remember { mutableStateOf(false) } + val actionContext = ActionContext(isFullyExpanded = false) + + MoreActionPanelDialog( + isVisible = moreActionPanelDialogVisible, + actions = screenActions ?: emptyList() + ) + + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(start = 16.dp, end = 24.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colors.onBackground + ), + onClick = { onBackPressedDispatcher?.onBackPressed() } + ) { + Image( + painter = painterResource(id = R.drawable.ic_arrow_left_s_line), + contentDescription = "backButtonIcon", + colorFilter = MaterialTheme.colors.onBackground.toColorFilter() + ) + AnimatedContent(targetState = previousTitle, label = "") { + Text( + text = it, + fontSize = 14.sp + ) + } + } + + AnimatedContent( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + transitionSpec = { fadeIn() togetherWith fadeOut() }, + targetState = screenActions, + label = "ExtraActions" + ) { actions -> + SubcomposeLayout( + modifier = Modifier.fillMaxSize() + ) { constraints -> + // 若actions为空,则不显示 + if (actions == null) return@SubcomposeLayout layout(0, 0) {} + + val moreBtnMeasurable = subcompose("moreBtn") { + MoreActionBtn(onClick = { moreActionPanelDialogVisible.value = true }) + }[0] + val moreBtnPlaceable = moreBtnMeasurable.measure( + constraints.copy( + maxWidth = moreBtnMeasurable.maxIntrinsicWidth(constraints.maxWidth), + minWidth = 0 + ) + ) + + var widthSum = 0f + val targets = mutableListOf() + for (action in actions) { + val measurable = subcompose(action) { + ActionItem( + action = action, + actionContext = actionContext + ) + }[0] + val placeable = measurable.measure( + constraints.copy( + maxWidth = measurable.maxIntrinsicWidth(constraints.maxWidth), + minWidth = 0 + ) + ) + + // 若宽度超出,则显示下拉菜单按钮 + if (placeable.width + moreBtnPlaceable.width + widthSum > constraints.maxWidth) { + targets.add(moreBtnPlaceable) + break + } + + targets.add(placeable) + widthSum += placeable.width + } + + layout(width = constraints.maxWidth, height = constraints.maxHeight) { + var startX = constraints.maxWidth + + targets.fastForEachReversed { + it.place(x = startX - it.width, y = 0) + startX -= it.width + } + } + } + } + } +} + +@Composable +private fun MoreActionBtn( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colors.onBackground, + onClick: () -> Unit = {} +) { + TextButton( + modifier = modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = 10.dp), + colors = ButtonDefaults.textButtonColors( + backgroundColor = color.copy(alpha = 0.15f), + contentColor = color + ), + onClick = onClick + ) { + Image( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.System.more2Fill, + contentDescription = "More Actions", + colorFilter = ColorFilter.tint(color = color) + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun ActionItem( + modifier: Modifier = Modifier, + actionContext: ActionContext, + action: ScreenAction +) { + when (action) { + is ScreenAction.Dynamic -> { + action.content(actionContext) + } + + is ScreenAction.Static -> { + val color = action.color() + val title = action.title() + val icon = action.icon() + + Surface( + modifier = modifier, + color = color.copy(0.2f), + onClick = { action.onAction() } + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon?.let { + Image( + modifier = Modifier.size(20.dp), + imageVector = icon, + contentDescription = title, + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text( + text = title, + fontSize = 14.sp, + lineHeight = 14.sp, + color = color, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +@Composable +private fun MoreActionPanelDialog( + isVisible: MutableState, + actions: List, +) { + val actualActions = rememberUpdatedState(newValue = actions) + + val dialog = remember { + DialogItem.Dynamic(backgroundColor = Color.Transparent) { + MoreActionPanelDialogContent( + actions = actualActions.value, + onDismiss = { dismiss() } + ) + } + } + + DialogWrapper.register( + isVisible = isVisible, + dialogItem = dialog + ) +} + +@Composable +private fun MoreActionPanelDialogContent( + modifier: Modifier = Modifier, + actions: List, + onDismiss: () -> Unit +) { + Surface( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .navigationBarsPadding(), + border = BorderStroke(1.dp, MaterialTheme.colors.onBackground.copy(0.1f)), + shape = RoundedCornerShape(18.dp), + elevation = 10.dp + ) { + Column( + modifier = Modifier + .padding(16.dp) + .clip(RoundedCornerShape(12.dp)), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + actions.forEach { action -> + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + ) { + ActionItem( + action = action, + actionContext = ActionContext(isFullyExpanded = true) + ) + } + } + } + } +} diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt new file mode 100644 index 000000000..17e7199ba --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt @@ -0,0 +1,125 @@ +package com.lalilu.component.navigation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.FixedScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import com.lalilu.component.R +import com.lalilu.component.base.TabScreen +import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.extension.dayNightTextColor + + +@Composable +fun NavigateTabBar( + modifier: Modifier = Modifier, + currentScreen: () -> Screen?, + tabScreens: () -> List, + onSelectTab: (TabScreen) -> Unit = {} +) { + Row( + modifier = modifier + .height(52.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + tabScreens().forEach { + val screenInfo = (it as? ScreenInfoFactory) + ?.provideScreenInfo() + + NavigateItem( + modifier = Modifier.weight(1f), + titleRes = { screenInfo?.title ?: R.string.empty_screen_no_items }, + iconRes = { screenInfo?.icon ?: R.drawable.ic_close_line }, + isSelected = { currentScreen() === it }, + onClick = { onSelectTab(it) } + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun NavigateItem( + modifier: Modifier = Modifier, + titleRes: () -> Int, + iconRes: () -> Int, + isSelected: () -> Boolean = { false }, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + baseColor: Color = MaterialTheme.colors.primary, + unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f) +) { + val titleValue = stringResource(id = titleRes()) + val iconTintColor = animateColorAsState( + targetValue = if (isSelected()) baseColor else unSelectedColor, + label = "" + ) +// val backgroundColor by animateColorAsState( +// targetValue = if (isSelected()) baseColor.copy(alpha = 0.12f) else Color.Transparent, +// label = "" +// ) + + Surface( + color = Color.Transparent, + onClick = onClick, + shape = RectangleShape, + modifier = modifier + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = iconRes()), + contentDescription = titleValue, + colorFilter = ColorFilter.tint(iconTintColor.value), + contentScale = FixedScale(if (isSelected()) 1.1f else 1f) + ) + AnimatedVisibility(visible = isSelected()) { + Text( + text = titleValue, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + letterSpacing = 0.1.sp, + color = dayNightTextColor() + ) + } + } + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt index d0483a12d..0f957b53c 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -1,67 +1,31 @@ package com.lalilu.component.navigation -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.FixedScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator -import com.lalilu.component.R -import com.lalilu.component.base.ScreenAction import com.lalilu.component.base.ScreenBarComponent import com.lalilu.component.base.TabScreen -import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.toColorFilter sealed interface NavigationBarType { @@ -150,179 +114,3 @@ fun NavigationSmartBar( } } } - -@Composable -fun NavigateTabBar( - modifier: Modifier = Modifier, - currentScreen: () -> Screen?, - tabScreens: () -> List, - onSelectTab: (TabScreen) -> Unit = {} -) { - Row( - modifier = modifier - .height(52.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - tabScreens().forEach { - val screenInfo = (it as? ScreenInfoFactory) - ?.provideScreenInfo() - - NavigateItem( - modifier = Modifier.weight(1f), - titleRes = { screenInfo?.title ?: R.string.empty_screen_no_items }, - iconRes = { screenInfo?.icon ?: R.drawable.ic_close_line }, - isSelected = { currentScreen() === it }, - onClick = { onSelectTab(it) } - ) - } - } -} - -@Composable -fun NavigateCommonBar( - modifier: Modifier = Modifier, - previousTitle: String, - currentScreen: Screen? -) { - val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - val screenActions = (currentScreen as? ScreenActionFactory)?.provideScreenActions() - - // TODO 待实现当screenActions溢出时转换成下拉菜单的逻辑,下拉菜单可直接用Dialog替代 - Row( - modifier = modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(start = 16.dp, end = 24.dp), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colors.onBackground - ), - onClick = { onBackPressedDispatcher?.onBackPressed() } - ) { - Image( - painter = painterResource(id = R.drawable.ic_arrow_left_s_line), - contentDescription = "backButtonIcon", - colorFilter = MaterialTheme.colors.onBackground.toColorFilter() - ) - AnimatedContent(targetState = previousTitle, label = "") { - Text( - text = it, - fontSize = 14.sp - ) - } - } - - AnimatedContent( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - transitionSpec = { fadeIn() togetherWith fadeOut() }, - targetState = screenActions, - label = "ExtraActions" - ) { actions -> - LazyRow( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.End - ) { - items(items = actions ?: emptyList()) { - if (it is ScreenAction.ComposeAction) { - it.content.invoke() - return@items - } - - if (it is ScreenAction.StaticAction) { - TextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = 20.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = it.color.copy(alpha = 0.15f), - contentColor = it.color - ), - onClick = it.onAction - ) { - it.icon?.let { icon -> - Image( - modifier = Modifier.size(20.dp), - painter = painterResource(id = icon), - contentDescription = stringResource(id = it.title), - colorFilter = ColorFilter.tint(color = it.color) - ) - Spacer(modifier = Modifier.width(6.dp)) - } - Text( - text = stringResource(id = it.title), - fontSize = 14.sp - ) - } - } - } - } - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun NavigateItem( - modifier: Modifier = Modifier, - titleRes: () -> Int, - iconRes: () -> Int, - isSelected: () -> Boolean = { false }, - onClick: () -> Unit = {}, - onLongClick: () -> Unit = {}, - baseColor: Color = MaterialTheme.colors.primary, - unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f) -) { - val titleValue = stringResource(id = titleRes()) - val iconTintColor = animateColorAsState( - targetValue = if (isSelected()) baseColor else unSelectedColor, - label = "" - ) -// val backgroundColor by animateColorAsState( -// targetValue = if (isSelected()) baseColor.copy(alpha = 0.12f) else Color.Transparent, -// label = "" -// ) - - Surface( - color = Color.Transparent, - onClick = onClick, - shape = RectangleShape, - modifier = modifier - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(id = iconRes()), - contentDescription = titleValue, - colorFilter = ColorFilter.tint(iconTintColor.value), - contentScale = FixedScale(if (isSelected()) 1.1f else 1f) - ) - AnimatedVisibility(visible = isSelected()) { - Text( - text = titleValue, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - letterSpacing = 0.1.sp, - color = dayNightTextColor() - ) - } - } - } - } -} \ No newline at end of file From 318b941e7951c6d8954c1af18980a233513e3706 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 27 Aug 2024 19:34:55 +0800 Subject: [PATCH 073/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E5=88=97=E8=A1=A8=E9=A1=B5=E4=B8=AD=E7=9A=84Action?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E9=87=8D=E6=9E=84Playlist=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E7=9A=84Action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 2 + .../compose/screen/detail/SongPlayAction.kt | 20 ++-- .../lmusic/compose/screen/songs/SongsSM.kt | 3 + .../compose/screen/songs/SongsScreen.kt | 44 ++++++- .../screen/songs/SongsScreenContent.kt | 26 +++- .../screen/songs/SongsSelectorPanel.kt | 48 ++++++++ .../component/extension/ItemSelector.kt | 43 +++++++ .../lalilu/component/navigation/AppRouter.kt | 6 +- .../component/navigation/NavigateCommonBar.kt | 56 +++++++-- lplaylist/build.gradle.kts | 1 + .../com/lalilu/lplaylist/PlaylistActions.kt | 112 +++++++----------- .../com/lalilu/lplaylist/PlaylistModule.kt | 10 +- 12 files changed, 272 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSelectorPanel.kt create mode 100644 component/src/main/java/com/lalilu/component/extension/ItemSelector.kt diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index d05a43be0..97e525217 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -18,6 +18,7 @@ import com.lalilu.lmedia.indexer.FilterProvider import com.lalilu.lmusic.utils.extension.ignoreSSLVerification import com.lalilu.lplayer.LPlayer import com.lalilu.lplaylist.PlaylistModule +import com.lalilu.lplaylist.PlaylistModule2 import com.zhangke.krouter.KRouter import com.zhangke.krouter.generated.KRouterInjectMap import org.koin.android.ext.android.inject @@ -61,6 +62,7 @@ class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, V PlaylistModule, ComponentModule, HistoryModule.module, + PlaylistModule2.module, ArtistModule, AlbumModule, DictionaryModule, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt index 1d5adf79d..da5bebc93 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt @@ -23,23 +23,29 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.R import com.lalilu.RemixIcon +import com.lalilu.common.base.Playable import com.lalilu.component.base.screen.ScreenAction -import com.lalilu.lmusic.compose.presenter.DetailScreenAction -import com.lalilu.lmusic.compose.presenter.DetailScreenIsPlayingPresenter +import com.lalilu.component.extension.singleViewModel +import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.remixicon.Media import com.lalilu.remixicon.media.pauseLine import com.lalilu.remixicon.media.playLine - @OptIn(ExperimentalMaterialApi::class) fun provideSongPlayAction(mediaId: String): ScreenAction.Dynamic { return ScreenAction.Dynamic { actionContext -> - val state = DetailScreenIsPlayingPresenter(mediaId) - val color = Color(0xFF006E7C) + val playingVM: PlayingViewModel = singleViewModel() + val color = Color(0xFF008394) Surface( color = color.copy(0.2f), - onClick = { state.onAction(DetailScreenAction.PlayPause) } + onClick = { + playingVM.play( + mediaId = mediaId, + addToNext = true, + playOrPause = true + ) + } ) { Row( modifier = Modifier.padding(horizontal = 20.dp), @@ -49,7 +55,7 @@ fun provideSongPlayAction(mediaId: String): ScreenAction.Dynamic { AnimatedContent( modifier = Modifier .fillMaxHeight(), - targetState = state.isPlaying, + targetState = playingVM.isItemPlaying(mediaId, Playable::mediaId), transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "" ) { isPlaying -> diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt index 4dab36030..cadca7c90 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -4,7 +4,9 @@ import androidx.compose.runtime.mutableStateOf import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.lalilu.common.base.BaseSp +import com.lalilu.common.base.Playable import com.lalilu.common.ext.requestFor +import com.lalilu.component.extension.ItemSelector import com.lalilu.component.extension.toState import com.lalilu.component.viewmodel.SongsSp import com.lalilu.component.viewmodel.findInstance @@ -85,6 +87,7 @@ internal class SongsSM( val searcher = ItemSearcher(flow()) val sorter = ItemSorter(searcher.output, supportSortActions) val songs = sorter.output.toState(emptyMap(), screenModelScope) + val selector = ItemSelector() } internal class ItemSearcher( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 770cb3d1d..51604351f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -8,6 +8,7 @@ import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R import com.lalilu.RemixIcon +import com.lalilu.common.ext.requestFor import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenBarFactory @@ -16,9 +17,12 @@ import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType import com.lalilu.remixicon.Design import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.design.editBoxLine import com.lalilu.remixicon.design.focus3Line import com.lalilu.remixicon.editor.sortDesc import com.zhangke.krouter.annotation.Destination +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named @Destination("/pages/songs") data class SongsScreen( @@ -43,10 +47,16 @@ data class SongsScreen( color = { Color(0xFF1793FF) }, onAction = { songsSM?.action(SongsScreenAction.ToggleSortPanel) } ), + ScreenAction.Static( + title = { "选择" }, + icon = { RemixIcon.Design.editBoxLine }, + color = { Color(0xFF009673) }, + onAction = { songsSM?.selector?.isSelecting?.value = true } + ), ScreenAction.Static( title = { stringResource(id = R.string.screen_action_locate_playing_item) }, icon = { RemixIcon.Design.focus3Line }, - color = { Color(0xFF9317FF) }, + color = { Color(0xFF8700FF) }, onAction = { songsSM?.action(SongsScreenAction.LocaleToPlayingItem) } ), ) @@ -67,6 +77,36 @@ data class SongsScreen( onSelectSortAction = { sm.sorter.selectSortAction(it) } ) - SongsScreenContent(songsSM = sm) + SongsSelectorPanel( + isVisible = sm.selector.isSelecting, + screenActions = listOfNotNull( + ScreenAction.Static( + title = { "全选" }, + onAction = { + val songs = sm.songs.value.values.flatten() + sm.selector.selectAll(songs) + } + ), + ScreenAction.Static( + title = { "取消全选" }, + onAction = { sm.selector.clear() } + ), + requestFor( + qualifier = named("add_to_favourite_action"), + parameters = { parametersOf(sm.selector::selected) } + ), + requestFor( + qualifier = named("add_to_playlist_action"), + parameters = { parametersOf(sm.selector::selected) } + ) + ) + ) + + SongsScreenContent( + songsSM = sm, + isSelecting = { sm.selector.isSelecting.value }, + isSelected = { sm.selector.isSelected(it) }, + onSelect = { sm.selector.onSelect(it) } + ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index a5d21356a..24e8be958 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import com.lalilu.common.base.Playable import com.lalilu.component.base.smartBarPadding import com.lalilu.component.card.SongCard import com.lalilu.component.navigation.AppRouter @@ -24,7 +25,10 @@ import kotlinx.coroutines.flow.collectLatest @Composable internal fun SongsScreenContent( - songsSM: SongsSM + songsSM: SongsSM, + isSelecting: () -> Boolean = { false }, + isSelected: (Playable) -> Boolean = { false }, + onSelect: (Playable) -> Unit = {} ) { val hapticFeedback = LocalHapticFeedback.current val listState: LazyListState = rememberLazyListState() @@ -72,14 +76,26 @@ internal fun SongsScreenContent( ) { SongCard( song = { it }, - onClick = { PlayerAction.PlayById(it.mediaId).action() }, + isSelected = { isSelected(it) }, + onClick = { + if (isSelecting()) { + onSelect(it) + } else { + PlayerAction.PlayById(it.mediaId).action() + } + }, onLongClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) - .jump() + if (isSelecting()) { + onSelect(it) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.mediaId) + .jump() + } }, + onEnterSelect = { onSelect(it) } ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSelectorPanel.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSelectorPanel.kt new file mode 100644 index 000000000..62db08960 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSelectorPanel.kt @@ -0,0 +1,48 @@ +package com.lalilu.lmusic.compose.screen.songs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ActionContext +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.component.navigation.NavigateCommonBarContent +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.closeLine + + +@Composable +internal fun ScreenBarFactory.SongsSelectorPanel( + isVisible: MutableState, + screenActions: List? = null, +) { + RegisterContent(isVisible = isVisible, onBackPressed = { }) { + SongsSelectorPanelContent( + modifier = Modifier, + screenActions = screenActions, + onBackPress = { isVisible.value = false } + ) + } +} + +@Composable +private fun SongsSelectorPanelContent( + modifier: Modifier = Modifier, + screenActions: List?, + onBackPress: (() -> Unit)? = null +) { + val dialogVisible = remember { mutableStateOf(false) } + + NavigateCommonBarContent( + modifier = modifier, + previousTitle = "取消", + previousIcon = RemixIcon.System.closeLine, + dialogVisible = dialogVisible, + screenActions = screenActions, + actionContext = ActionContext(false), + onBackPress = onBackPress + ) +} diff --git a/component/src/main/java/com/lalilu/component/extension/ItemSelector.kt b/component/src/main/java/com/lalilu/component/extension/ItemSelector.kt new file mode 100644 index 000000000..952a7c547 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/extension/ItemSelector.kt @@ -0,0 +1,43 @@ +package com.lalilu.component.extension + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember + +@Stable +class ItemSelector { + private val items = mutableStateOf(emptySet()) + private val _isSelecting = mutableStateOf(false) + val isSelecting: MutableState = object : MutableState { + override var value: Boolean + get() = _isSelecting.value + set(value) = run { if (!value) clear(); _isSelecting.value = value } + + override fun component1(): Boolean = value + override fun component2(): (Boolean) -> Unit = { value = it } + } + + fun isSelected(item: T) = items.value.contains(item) + fun selected() = items.value + + fun onSelect(item: T) { + if (!isSelecting.value) isSelecting.value = true + + if (items.value.contains(item)) items.value -= item + else items.value += item + } + + fun selectAll(list: List) { + if (!isSelecting.value) isSelecting.value = true + items.value = list.toSet() + } + + fun clear() = run { items.value = emptySet() } +} + +@Composable +fun rememberSelector(): ItemSelector { + return remember { ItemSelector() } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt index 9893ac444..41ab1801c 100644 --- a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -1,5 +1,6 @@ package com.lalilu.component.navigation +import android.util.Log import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator import com.lalilu.component.base.TabScreen @@ -130,6 +131,9 @@ object AppRouter : CoroutineScope { private fun requestResult(): Screen? = runCatching { KRouter.route(baseUrl, params) } - .getOrNull() + .getOrElse { + Log.e("AppRouter", "route request for [$baseUrl] Failed", it) + null + } } } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt index 2cef8eae5..f3071fcb6 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text @@ -39,23 +40,23 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEachReversed import cafe.adriel.voyager.core.screen.Screen import com.lalilu.RemixIcon -import com.lalilu.component.R import com.lalilu.component.base.screen.ActionContext import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.extension.DialogItem import com.lalilu.component.extension.DialogWrapper -import com.lalilu.component.extension.toColorFilter +import com.lalilu.remixicon.Arrows import com.lalilu.remixicon.System +import com.lalilu.remixicon.arrows.arrowLeftSLine import com.lalilu.remixicon.system.more2Fill @@ -65,13 +66,34 @@ fun NavigateCommonBar( previousTitle: String, currentScreen: Screen? ) { - val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val screenActions = (currentScreen as? ScreenActionFactory)?.provideScreenActions() - val moreActionPanelDialogVisible = remember { mutableStateOf(false) } val actionContext = ActionContext(isFullyExpanded = false) + val isDialogVisible = remember { mutableStateOf(false) } + + NavigateCommonBarContent( + modifier = modifier, + previousTitle = previousTitle, + dialogVisible = isDialogVisible, + screenActions = screenActions, + actionContext = actionContext + ) +} + +@Composable +fun NavigateCommonBarContent( + modifier: Modifier = Modifier, + previousTitle: String, + previousIcon: ImageVector = RemixIcon.Arrows.arrowLeftSLine, + dialogVisible: MutableState, + screenActions: List?, + actionContext: ActionContext, + onBackPress: (() -> Unit)? = null +) { + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current + ?.onBackPressedDispatcher MoreActionPanelDialog( - isVisible = moreActionPanelDialogVisible, + isVisible = dialogVisible, actions = screenActions ?: emptyList() ) @@ -87,14 +109,22 @@ fun NavigateCommonBar( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colors.onBackground ), - onClick = { onBackPressedDispatcher?.onBackPressed() } + onClick = { + if (onBackPress != null) { + onBackPress() + } else { + onBackPressedDispatcher?.onBackPressed() + } + } ) { - Image( - painter = painterResource(id = R.drawable.ic_arrow_left_s_line), - contentDescription = "backButtonIcon", - colorFilter = MaterialTheme.colors.onBackground.toColorFilter() + Icon( + imageVector = previousIcon, + tint = MaterialTheme.colors.onBackground, + contentDescription = null ) - AnimatedContent(targetState = previousTitle, label = "") { + AnimatedContent( + targetState = previousTitle, label = "" + ) { Text( text = it, fontSize = 14.sp @@ -117,7 +147,7 @@ fun NavigateCommonBar( if (actions == null) return@SubcomposeLayout layout(0, 0) {} val moreBtnMeasurable = subcompose("moreBtn") { - MoreActionBtn(onClick = { moreActionPanelDialogVisible.value = true }) + MoreActionBtn(onClick = { dialogVisible.value = true }) }[0] val moreBtnPlaceable = moreBtnMeasurable.measure( constraints.copy( diff --git a/lplaylist/build.gradle.kts b/lplaylist/build.gradle.kts index 75e19351f..c9411068a 100644 --- a/lplaylist/build.gradle.kts +++ b/lplaylist/build.gradle.kts @@ -36,4 +36,5 @@ composeCompiler { dependencies { implementation(project(":component")) + ksp(libs.koin.compiler) } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt index aa06cdef5..d5ffebdeb 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt @@ -1,79 +1,53 @@ package com.lalilu.lplaylist +import androidx.compose.material.MaterialTheme import androidx.compose.ui.graphics.Color import com.blankj.utilcode.util.ToastUtils +import com.lalilu.RemixIcon import com.lalilu.common.base.Playable -import com.lalilu.component.extension.SelectAction +import com.lalilu.common.ext.requestFor +import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository -import com.lalilu.lplaylist.screen.PlaylistAddToScreen -import org.koin.java.KoinJavaComponent -import com.lalilu.component.R as componentR - -object PlaylistActions { - private val playlistRepo: PlaylistRepository by KoinJavaComponent.inject(PlaylistRepository::class.java) - - /** - * 将指定歌曲添加至播放列表 - */ - val addToPlaylistAction = SelectAction.StaticAction.Custom( - title = R.string.playlist_action_add_to_playlist, - icon = componentR.drawable.ic_play_list_add_line, - color = Color(0xFF04B931), - ) { selector -> - val mediaIds = selector.selected.value - .mapNotNull { (it as? Playable)?.mediaId } - - AppRouter.intent( - NavIntent.Push( - PlaylistAddToScreen( - ids = mediaIds, - callback = { - selector.clear() - - AppRouter.intent(NavIntent.Pop) - } - ) - ) - ) +import com.lalilu.remixicon.HealthAndMedical +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.healthandmedical.heart3Line +import com.lalilu.remixicon.media.playListAddLine +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named + +@Factory(binds = [ScreenAction::class]) +@Named("add_to_playlist_action") +fun provideAddToPlaylistAction( + selectedItems: () -> Collection +): ScreenAction.Static = ScreenAction.Static( + title = { "添加到歌单" }, + icon = { RemixIcon.Media.playListAddLine }, + color = { Color(0xFF24A800) }, + onAction = { + val items = selectedItems() + + AppRouter.route("/playlist/add") + .with("mediaIds", items.map { it.mediaId }) + .jump() } - - /** - * 将指定歌曲添加至播放列表 - */ - val addToFavorite = SelectAction.StaticAction.Custom( - title = R.string.playlist_action_add_to_favorites, - icon = componentR.drawable.ic_heart_3_fill, - color = Color(0xFFE91E63), - ) { selector -> - val mediaIds = selector.selected.value - .mapNotNull { (it as? Playable)?.mediaId } - - playlistRepo.addMediaIdsToFavourite(mediaIds) - ToastUtils.showShort("已添加${mediaIds.size}首歌曲至我喜欢") - } - - /** - * 删除指定的播放列表 - */ - internal val removePlaylists = SelectAction.StaticAction.Custom( - title = R.string.playlist_action_remove_playlist, - forLongClick = true, - icon = componentR.drawable.ic_delete_bin_6_line, - color = Color(0xFFE91E1E), - ) { selector -> - val selectedPlaylist = selector.selected.value.filterIsInstance() - val playlistIds = selectedPlaylist.map { it.id } - - runCatching { - playlistRepo.removeByIds(ids = playlistIds) - ToastUtils.showShort("已删除${playlistIds.size}个歌单") - selector.remove(selectedPlaylist) - }.getOrElse { - it.printStackTrace() - ToastUtils.showShort("删除失败") +) + +@Factory(binds = [ScreenAction::class]) +@Named("add_to_favourite_action") +fun provideAddToFavouriteAction( + selectedItems: () -> Collection +): ScreenAction.Static = ScreenAction.Static( + title = { "添加到我喜欢" }, + icon = { RemixIcon.HealthAndMedical.heart3Line }, + color = { MaterialTheme.colors.primary }, + onAction = { + val items = selectedItems().map { it.mediaId } + val playlistRepo = requestFor() + + playlistRepo?.let { + it.addMediaIdsToFavourite(items) + ToastUtils.showShort("已添加${items.size}首歌曲至我喜欢") } } -} \ No newline at end of file +) \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt index c3e1f126a..895fa74b1 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt @@ -3,12 +3,18 @@ package com.lalilu.lplaylist import com.lalilu.lplaylist.repository.PlaylistKV import com.lalilu.lplaylist.repository.PlaylistRepository import com.lalilu.lplaylist.repository.PlaylistRepositoryImpl -import com.lalilu.lplaylist.screen.PlaylistCreateOrEditScreenModel -import com.lalilu.lplaylist.screen.PlaylistDetailScreenModel +import com.lalilu.lplaylist.screen.create.PlaylistCreateOrEditScreenModel +import com.lalilu.lplaylist.screen.detail.PlaylistDetailScreenModel +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.dsl.module +@Module +@ComponentScan("com.lalilu.lplaylist") +object PlaylistModule2 + val PlaylistModule = module { singleOf(::PlaylistKV) From 3c91388a5636d30e1eb2306104a0999c35f442be Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 27 Aug 2024 19:35:12 +0800 Subject: [PATCH 074/213] =?UTF-8?q?[refactor]=E5=BC=80=E5=A7=8B=E9=87=8D?= =?UTF-8?q?=E6=9E=84Playlist=E7=9B=B8=E5=85=B3=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lplaylist/screen/PlaylistAddToScreen.kt | 137 ------------ .../lplaylist/screen/PlaylistDetailScreen.kt | 206 ------------------ .../lalilu/lplaylist/screen/PlaylistScreen.kt | 5 +- .../screen/add/PlaylistAddToScreen.kt | 79 +++++++ .../screen/add/PlaylistAddToScreenContent.kt | 71 ++++++ .../PlaylistCreateOrEditScreen.kt | 2 +- .../screen/detail/PlaylistDetailScreen.kt | 112 ++++++++++ .../detail/PlaylistDetailScreenContent.kt | 11 + 8 files changed, 277 insertions(+), 346 deletions(-) delete mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt delete mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreenContent.kt rename lplaylist/src/main/java/com/lalilu/lplaylist/screen/{ => create}/PlaylistCreateOrEditScreen.kt (99%) create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt deleted file mode 100644 index b6f3d402b..000000000 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.lalilu.lplaylist.screen - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.ScreenKey -import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.DialogScreen -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenAction -import com.lalilu.component.base.ScreenInfo -import com.lalilu.component.extension.ItemSelectHelper -import com.lalilu.component.extension.rememberItemSelectHelper -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.lplaylist.R -import com.lalilu.lplaylist.component.PlaylistCard -import com.lalilu.lplaylist.entity.LPlaylist -import com.lalilu.lplaylist.repository.PlaylistRepository -import org.koin.compose.koinInject -import com.lalilu.component.R as componentR - -data class PlaylistAddToScreen( - private val ids: List, - @Transient private val callback: () -> Unit = {} // callback内若有其他对象的引用会影响到Voyager的序列化 -) : DynamicScreen(), DialogScreen { - override val key: ScreenKey = "${super.key}:${ids.hashCode()}" - - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.playlist_action_add_to_playlist - ) - - @Transient - private var selector: ItemSelectHelper? = null - - @Composable - override fun registerActions(): List { - val playlistRepo: PlaylistRepository = koinInject() - - return remember { - listOf( - ScreenAction.StaticAction( - title = R.string.playlist_action_add_to_playlist, - icon = componentR.drawable.ic_check_line, - color = Color(0xFF008521) - ) { - val playlistIds = selector?.selected?.value - ?.filterIsInstance(LPlaylist::class.java) - ?.map { it.id } - ?: emptyList() - - playlistRepo.addMediaIdsToPlaylists( - mediaIds = ids, - playlistIds = playlistIds - ) - - callback.invoke() - } - ) - } - } - - @Composable - override fun Content() { - val playlistRepo: PlaylistRepository = koinInject() - val playlists = remember { derivedStateOf { playlistRepo.getPlaylists() } } - val selector = rememberItemSelectHelper().also { this.selector = it } - - PlaylistAddToScreen( - mediaIds = ids, - selector = selector, - playlists = { playlists.value } - ) - } -} - -@Composable -private fun DynamicScreen.PlaylistAddToScreen( - mediaIds: List, - selector: ItemSelectHelper, - playlists: () -> List, -) { - LLazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - item { - NavigatorHeader( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding(), - title = stringResource(id = R.string.playlist_action_add_to_playlist), - subTitle = "[S: ${mediaIds.size}] -> [P: ${selector.selected.value.size}]" - ) { - IconButton( - onClick = { - AppRouter.intent( - NavIntent.Push( - PlaylistCreateOrEditScreen() - ) - ) - } - ) { - Icon( - painter = painterResource(componentR.drawable.ic_add_line), - contentDescription = null - ) - } - } - } - - items( - items = playlists(), - key = { it.id }, - contentType = { LPlaylist::class.java } - ) { playlist -> - PlaylistCard( - playlist = playlist, - isSelected = { selector.isSelected(playlist) }, - onClick = { selector.onSelect(playlist) } - ) - } - } -} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt deleted file mode 100644 index 291b39cdd..000000000 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt +++ /dev/null @@ -1,206 +0,0 @@ -package com.lalilu.lplaylist.screen - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.koin.getScreenModel -import com.blankj.utilcode.util.ToastUtils -import com.lalilu.common.base.Playable -import com.lalilu.common.toCachedFlow -import com.lalilu.component.Songs -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.LoadingScaffold -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenAction -import com.lalilu.component.base.ScreenInfo -import com.lalilu.component.base.collectAsLoadingState -import com.lalilu.component.extension.SelectAction -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lplaylist.PlaylistActions -import com.lalilu.lplaylist.R -import com.lalilu.lplaylist.repository.PlaylistRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import com.lalilu.component.R as componentR - -data class PlaylistDetailScreen( - val playlistId: String -) : DynamicScreen() { - - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.playlist_screen_detail - ) - - @Composable - override fun registerActions(): List { - val playlistDetailSM = getScreenModel() - - return remember { - listOf( - playlistDetailSM.playAllRandomlyAction, - playlistDetailSM.playAllAction - ) - } - } - - @Composable - override fun Content() { - val playlistDetailSM = getScreenModel() - - LaunchedEffect(Unit) { - playlistDetailSM.updatePlaylistId(playlistId) - } - - PlaylistDetailScreen( - playlistId = playlistId, - playlistDetailSM = playlistDetailSM - ) - } -} - -class PlaylistDetailScreenModel( - private val playingVM: IPlayingViewModel, - private val playlistRepo: PlaylistRepository -) : ScreenModel { - private val playlistId = MutableStateFlow("") - - val playlist = playlistId - .combine(playlistRepo.getPlaylistsFlow()) { id, playlists -> - playlists.firstOrNull { it.id == id } - }.toCachedFlow() - - val deleteAction = SelectAction.StaticAction.Custom( - title = R.string.playlist_action_remove_from_playlist, - forLongClick = true, - icon = componentR.drawable.ic_delete_bin_6_line, - color = Color.Red - ) { selector -> - val mediaIds = selector.selected.value.filterIsInstance(Playable::class.java) - .map { it.mediaId } - - playlistRepo.removeMediaIdsFromPlaylist(mediaIds, playlistId.value) - ToastUtils.showShort("Removed from playlist") - } - - val playAllAction = ScreenAction.StaticAction( - title = R.string.playlist_action_play_all, - icon = componentR.drawable.ic_play_list_2_fill, - color = Color(0xFF008521) - ) { - val mediaIds = playlist.get()?.mediaIds ?: emptyList() - - if (mediaIds.isEmpty()) { - ToastUtils.showShort("No item to play") - } else { - playingVM.play( - mediaIds = mediaIds, - mediaId = mediaIds.first() - ) - } - } - - val playAllRandomlyAction = ScreenAction.StaticAction( - title = R.string.playlist_action_play_randomly, - icon = componentR.drawable.ic_dice_line, - color = Color(0xFF8D01B4) - ) { - val mediaIds = playlist.get()?.mediaIds ?: emptyList() - - if (mediaIds.isEmpty()) { - ToastUtils.showShort("No item to play") - } else { - playingVM.play( - mediaIds = mediaIds.shuffled(), - mediaId = mediaIds.random() - ) - } - } - - fun updatePlaylistId(playlistId: String) { - this.playlistId.tryEmit(playlistId) - } - - fun onDragMoveEnd(items: List) { - val mediaId = items.map { it.mediaId } - playlistRepo.updateMediaIdsToPlaylist(mediaId, playlistId.value) - } -} - -@Composable -private fun DynamicScreen.PlaylistDetailScreen( - playlistId: String, - playlistDetailSM: PlaylistDetailScreenModel, -) { - val playlistState = playlistDetailSM.playlist.collectAsLoadingState() - - LoadingScaffold(targetState = playlistState) { playlist -> - Songs( - mediaIds = playlist.mediaIds, - onDragMoveEnd = playlistDetailSM::onDragMoveEnd, - selectActions = { getAll -> - listOf( - SelectAction.StaticAction.SelectAll(getAll), - playlistDetailSM.deleteAction, - PlaylistActions.addToPlaylistAction, - ) - }, - sortFor = "PlaylistDetail", - supportListAction = { emptyList() }, - emptyContent = { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = "There is no songs.", - style = MaterialTheme.typography.subtitle1 - ) - Text( - text = "Add songs from library.", - style = MaterialTheme.typography.subtitle2 - ) - } - }, - headerContent = { - item { - NavigatorHeader( - title = playlist.title, - subTitle = playlist.subTitle - ) { - IconButton( - onClick = { - AppRouter.intent( - NavIntent.Push(PlaylistCreateOrEditScreen(targetPlaylistId = playlistId)) - ) - } - ) { - Icon( - painter = painterResource(componentR.drawable.ic_edit_line), - contentDescription = null - ) - } - } - } - } - ) - } -} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index 2aa40ae3c..ed389d350 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -52,11 +52,12 @@ import com.lalilu.component.extension.SelectAction import com.lalilu.component.extension.rememberItemSelectHelper import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent -import com.lalilu.lplaylist.PlaylistActions import com.lalilu.lplaylist.R import com.lalilu.lplaylist.component.PlaylistCard import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository +import com.lalilu.lplaylist.screen.create.PlaylistCreateOrEditScreen +import com.lalilu.lplaylist.screen.detail.PlaylistDetailScreen import com.zhangke.krouter.annotation.Destination import org.koin.compose.koinInject import sh.calvin.reorderable.ReorderableItem @@ -116,7 +117,7 @@ private fun Screen.PlaylistScreen( selected = playlistSM.selectedItems ) val selectActions = remember { - listOf(PlaylistActions.removePlaylists) + listOf() } if (this is ScreenBarFactory) { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt new file mode 100644 index 000000000..61b78da98 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt @@ -0,0 +1,79 @@ +package com.lalilu.lplaylist.screen.add + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.rememberSelector +import com.lalilu.lplaylist.R +import com.lalilu.lplaylist.entity.LPlaylist +import com.lalilu.lplaylist.repository.PlaylistRepository +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.checkLine +import com.zhangke.krouter.annotation.Destination +import org.koin.compose.koinInject + +@Destination("/playlist/add") +data class PlaylistAddToScreen( + private val mediaIds: List, +) : Screen, ScreenInfoFactory, ScreenActionFactory { + override val key: ScreenKey = "${super.key}:${mediaIds.hashCode()}" + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember(this) { + ScreenInfo(title = R.string.playlist_action_add_to_playlist) + } + + @Composable + override fun provideScreenActions(): List { + val playlistRepo: PlaylistRepository = koinInject() + + return remember { + listOf( + ScreenAction.Static( + title = { stringResource(id = R.string.playlist_action_add_to_playlist) }, + icon = { RemixIcon.System.checkLine }, + color = { Color(0xFF008521) }, + onAction = { + val playlistIds = selector?.selected() + ?.map { it.id } + ?: emptyList() + + playlistRepo.addMediaIdsToPlaylists( + mediaIds = mediaIds, + playlistIds = playlistIds + ) + + selector?.clear() + } + ) + ) + } + } + + @Transient + private var selector: ItemSelector? = null + + @Composable + override fun Content() { + val playlistRepo: PlaylistRepository = koinInject() + val playlists = remember { derivedStateOf { playlistRepo.getPlaylists() } } + val selector = rememberSelector() + .also { this.selector = it } + + PlaylistAddToScreenContent( + mediaIds = mediaIds, + selector = selector, + playlists = { playlists.value } + ) + } +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreenContent.kt new file mode 100644 index 000000000..8160ab63f --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreenContent.kt @@ -0,0 +1,71 @@ +package com.lalilu.lplaylist.screen.add + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.lalilu.component.base.NavigatorHeader +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent +import com.lalilu.lplaylist.R +import com.lalilu.lplaylist.component.PlaylistCard +import com.lalilu.lplaylist.entity.LPlaylist +import com.lalilu.lplaylist.screen.create.PlaylistCreateOrEditScreen + + +@Composable +internal fun PlaylistAddToScreenContent( + mediaIds: List, + selector: ItemSelector, + playlists: () -> List, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + item { + NavigatorHeader( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding(), + title = stringResource(id = R.string.playlist_action_add_to_playlist), + subTitle = "[S: ${mediaIds.size}] -> [P: ${selector.selected().size}]" + ) { + IconButton( + onClick = { + AppRouter.intent( + NavIntent.Push(PlaylistCreateOrEditScreen()) + ) + } + ) { + Icon( + painter = painterResource(com.lalilu.component.R.drawable.ic_add_line), + contentDescription = null + ) + } + } + } + + items( + items = playlists(), + key = { it.id }, + contentType = { LPlaylist::class.java } + ) { playlist -> + PlaylistCard( + playlist = playlist, + isSelected = { selector.isSelected(playlist) }, + onClick = { selector.onSelect(playlist) } + ) + } + } +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt similarity index 99% rename from lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt rename to lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt index 37007eb72..9b85959bf 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt @@ -1,4 +1,4 @@ -package com.lalilu.lplaylist.screen +package com.lalilu.lplaylist.screen.create import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.BorderStroke diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt new file mode 100644 index 000000000..943a1c524 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -0,0 +1,112 @@ +package com.lalilu.lplaylist.screen.detail + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.screen.Screen +import com.blankj.utilcode.util.ToastUtils +import com.lalilu.common.base.Playable +import com.lalilu.common.toCachedFlow +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.extension.SelectAction +import com.lalilu.component.viewmodel.IPlayingViewModel +import com.lalilu.lplaylist.R +import com.lalilu.lplaylist.repository.PlaylistRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import com.lalilu.component.R as componentR + +data class PlaylistDetailScreen( + val playlistId: String +) : Screen, ScreenInfoFactory, ScreenActionFactory { + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo(title = R.string.playlist_screen_detail) + } + + @Composable + override fun provideScreenActions(): List { + return remember { listOf() } + } + + @Composable + override fun Content() { + + PlaylistDetailScreen( + playlistId = playlistId, + ) + } +} + +class PlaylistDetailScreenModel( + private val playingVM: IPlayingViewModel, + private val playlistRepo: PlaylistRepository, +) : ScreenModel { + private val playlistId = MutableStateFlow("") + + val playlist = playlistId + .combine(playlistRepo.getPlaylistsFlow()) { id, playlists -> + playlists.firstOrNull { it.id == id } + }.toCachedFlow() + + val deleteAction = SelectAction.StaticAction.Custom( + title = R.string.playlist_action_remove_from_playlist, + forLongClick = true, + icon = componentR.drawable.ic_delete_bin_6_line, + color = Color.Red + ) { selector -> + val mediaIds = selector.selected.value.filterIsInstance(Playable::class.java) + .map { it.mediaId } + + playlistRepo.removeMediaIdsFromPlaylist(mediaIds, playlistId.value) + ToastUtils.showShort("Removed from playlist") + } + +// val playAllAction = ScreenAction.StaticAction( +// title = R.string.playlist_action_play_all, +// icon = componentR.drawable.ic_play_list_2_fill, +// color = Color(0xFF008521) +// ) { +// val mediaIds = playlist.get()?.mediaIds ?: emptyList() +// +// if (mediaIds.isEmpty()) { +// ToastUtils.showShort("No item to play") +// } else { +// playingVM.play( +// mediaIds = mediaIds, +// mediaId = mediaIds.first() +// ) +// } +// } +// +// val playAllRandomlyAction = ScreenAction.StaticAction( +// title = R.string.playlist_action_play_randomly, +// icon = componentR.drawable.ic_dice_line, +// color = Color(0xFF8D01B4) +// ) { +// val mediaIds = playlist.get()?.mediaIds ?: emptyList() +// +// if (mediaIds.isEmpty()) { +// ToastUtils.showShort("No item to play") +// } else { +// playingVM.play( +// mediaIds = mediaIds.shuffled(), +// mediaId = mediaIds.random() +// ) +// } +// } +// +// fun updatePlaylistId(playlistId: String) { +// this.playlistId.tryEmit(playlistId) +// } +// +// fun onDragMoveEnd(items: List) { +// val mediaId = items.map { it.mediaId } +// playlistRepo.updateMediaIdsToPlaylist(mediaId, playlistId.value) +// } +} diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt new file mode 100644 index 000000000..4e7e7c611 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt @@ -0,0 +1,11 @@ +package com.lalilu.lplaylist.screen.detail + +import androidx.compose.runtime.Composable + +@Composable +private fun PlaylistDetailScreenContent( + playlistId: String, + playlistDetailSM: PlaylistDetailScreenModel, +) { + +} \ No newline at end of file From 73c4b1ed564501bd3457c94ec77beacc4e9fc966 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Wed, 28 Aug 2024 18:18:43 +0800 Subject: [PATCH 075/213] =?UTF-8?q?[refactor]=E6=B7=BB=E5=8A=A0=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=AD=8C=E6=9B=B2=E9=A1=B5=E4=B8=AD=E7=9A=84=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E9=80=BB=E8=BE=91=EF=BC=8C=E5=88=9D=E6=AD=A5=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0SmartBar=E7=9A=84=E5=B0=8F=E7=BA=A2=E7=82=B9=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/screen/songs/SongsSM.kt | 32 +-- .../compose/screen/songs/SongsScreen.kt | 37 ++++ .../screen/songs/SongsSearcherPanel.kt | 200 ++++++++++++++++++ .../base/screen/ScreenActionFactory.kt | 2 + .../com/lalilu/component/extension/FlowExt.kt | 10 +- .../component/navigation/NavigateCommonBar.kt | 167 ++++++++++++--- 6 files changed, 407 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSearcherPanel.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt index cadca7c90..93b532ed6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -1,6 +1,11 @@ package com.lalilu.lmusic.compose.screen.songs +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.lalilu.common.base.BaseSp @@ -17,10 +22,11 @@ import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortDynamicAction import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmedia.extension.Sortable +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -46,6 +52,7 @@ internal class SongsSM( ) : ScreenModel { // 持久化元素的状态 val showSortPanel = mutableStateOf(false) + val showSearcherPanel = mutableStateOf(false) val supportSortActions = setOf( SortStaticAction.Normal, SortStaticAction.Title, @@ -71,7 +78,7 @@ internal class SongsSM( } is SongsScreenAction.SearchFor -> { - searcher.search(action.keyword) + searcher.keywordState.value = action.keyword } else -> {} @@ -86,15 +93,19 @@ internal class SongsSM( val searcher = ItemSearcher(flow()) val sorter = ItemSorter(searcher.output, supportSortActions) - val songs = sorter.output.toState(emptyMap(), screenModelScope) + val songs = sorter.output.toState( + defaultValue = emptyMap(), + scope = screenModelScope, + context = Dispatchers.IO + SupervisorJob() + ) val selector = ItemSelector() } internal class ItemSearcher( sourceFlow: Flow> ) { - private val keywordStr = MutableStateFlow("") - private val keywordFlow = keywordStr.map { + val keywordState = mutableStateOf("") + private val keywordFlow = snapshotFlow { keywordState.value }.map { when { it.isBlank() -> emptyList() it.contains(' ') -> it.split(' ') @@ -106,13 +117,10 @@ internal class ItemSearcher( source.filter { item -> keywords.all { item.matchStr.contains(it) } } } - fun search(keyword: String) { - keywordStr.value = keyword - } - - fun clear() { - keywordStr.value = "" - } + val isSearching: State + @Composable get() = remember { + derivedStateOf { keywordState.value.isNotBlank() } + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 51604351f..019bfeb2d 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -15,11 +15,16 @@ import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType +import com.lalilu.component.extension.DialogWrapper import com.lalilu.remixicon.Design import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.System import com.lalilu.remixicon.design.editBoxLine import com.lalilu.remixicon.design.focus3Line import com.lalilu.remixicon.editor.sortDesc +import com.lalilu.remixicon.system.checkboxMultipleBlankLine +import com.lalilu.remixicon.system.checkboxMultipleLine +import com.lalilu.remixicon.system.menuSearchLine import com.zhangke.krouter.annotation.Destination import org.koin.core.parameter.parametersOf import org.koin.core.qualifier.named @@ -53,8 +58,30 @@ data class SongsScreen( color = { Color(0xFF009673) }, onAction = { songsSM?.selector?.isSelecting?.value = true } ), + ScreenAction.Static( + title = { "搜索" }, + subTitle = { + val isSearching = songsSM?.searcher?.isSearching + + if (isSearching?.value == true) "搜索中: ${songsSM?.searcher?.keywordState?.value}" + else null + }, + icon = { RemixIcon.System.menuSearchLine }, + color = { Color(0xFF8BC34A) }, + dotColor = { + val isSearching = songsSM?.searcher?.isSearching + + if (isSearching?.value == true) Color.Red + else null + }, + onAction = { + songsSM?.showSearcherPanel?.value = true + DialogWrapper.dismiss() + } + ), ScreenAction.Static( title = { stringResource(id = R.string.screen_action_locate_playing_item) }, + dotColor = { Color.Blue }, icon = { RemixIcon.Design.focus3Line }, color = { Color(0xFF8700FF) }, onAction = { songsSM?.action(SongsScreenAction.LocaleToPlayingItem) } @@ -77,11 +104,19 @@ data class SongsScreen( onSelectSortAction = { sm.sorter.selectSortAction(it) } ) + SongsSearcherPanel( + isVisible = sm.showSearcherPanel, + keyword = { sm.searcher.keywordState.value }, + onUpdateKeyword = { sm.searcher.keywordState.value = it } + ) + SongsSelectorPanel( isVisible = sm.selector.isSelecting, screenActions = listOfNotNull( ScreenAction.Static( title = { "全选" }, + color = { Color(0xFF00ACF0) }, + icon = { RemixIcon.System.checkboxMultipleLine }, onAction = { val songs = sm.songs.value.values.flatten() sm.selector.selectAll(songs) @@ -89,6 +124,8 @@ data class SongsScreen( ), ScreenAction.Static( title = { "取消全选" }, + icon = { RemixIcon.System.checkboxMultipleBlankLine }, + color = { Color(0xFFFF5100) }, onAction = { sm.selector.clear() } ), requestFor( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSearcherPanel.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSearcherPanel.kt new file mode 100644 index 000000000..5de4322b5 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSearcherPanel.kt @@ -0,0 +1,200 @@ +package com.lalilu.lmusic.compose.screen.songs + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.remixicon.Arrows +import com.lalilu.remixicon.System +import com.lalilu.remixicon.arrows.arrowLeftSLine +import com.lalilu.remixicon.system.closeLine + +@Composable +internal fun ScreenBarFactory.SongsSearcherPanel( + isVisible: MutableState, + keyword: () -> String, + onUpdateKeyword: (String) -> Unit +) { + RegisterContent(isVisible = isVisible, onBackPressed = { }) { + SongsSearcherPanelContent( + modifier = Modifier, + keyword = keyword, + onUpdateKeyword = onUpdateKeyword, + onBackPress = { isVisible.value = false } + ) + } +} + +@Composable +private fun SongsSearcherPanelContent( + modifier: Modifier = Modifier, + keyword: () -> String, + onUpdateKeyword: (String) -> Unit, + onBackPress: (() -> Unit)? = null +) { + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current + ?.onBackPressedDispatcher + val keyboard = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Row( + modifier = modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(start = 12.dp, end = 20.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colors.onBackground + ), + onClick = { + keyboard?.hide() + + if (onBackPress != null) { + onBackPress() + } else { + onBackPressedDispatcher?.onBackPressed() + } + } + ) { + Icon( + imageVector = RemixIcon.Arrows.arrowLeftSLine, + tint = MaterialTheme.colors.onBackground, + contentDescription = null + ) + Text( + text = "关闭", + fontSize = 14.sp, + lineHeight = 14.sp, + color = MaterialTheme.colors.onBackground, + ) + } + + BasicTextField( + modifier = Modifier + .focusRequester(focusRequester) + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colors.onBackground.copy(0.05f)), + value = keyword(), + onValueChange = onUpdateKeyword, + singleLine = true, + maxLines = 1, + cursorBrush = SolidColor(MaterialTheme.colors.onBackground), + textStyle = TextStyle.Default.copy( + fontSize = 16.sp, + lineHeight = 16.sp, + letterSpacing = 1.sp, + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.Bold + ), + decorationBox = { content -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + this@Row.AnimatedVisibility( + enter = fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)), + exit = fadeOut(animationSpec = spring(stiffness = Spring.StiffnessMedium)), + visible = keyword().isEmpty() + ) { + Text( + modifier = Modifier.padding(start = 2.dp), + text = "输入关键词以匹配元素", + fontSize = 14.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground.copy(0.3f) + ) + } + + Row( + modifier = Modifier + .fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(1f), + contentAlignment = Alignment.CenterStart + ) { + content() + } + + AnimatedVisibility( + enter = fadeIn() + scaleIn( + animationSpec = spring( + stiffness = Spring.StiffnessMedium, + dampingRatio = Spring.DampingRatioMediumBouncy + ), + initialScale = 0f + ), + exit = fadeOut() + scaleOut( + animationSpec = spring(stiffness = Spring.StiffnessMedium), + targetScale = 0f + ), + visible = keyword().isNotEmpty() + ) { + IconButton( + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + onClick = { onUpdateKeyword("") } + ) { + Icon( + imageVector = RemixIcon.System.closeLine, + contentDescription = "clear" + ) + } + } + } + } + } + ) + } +} diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt index 10743fbc8..0ec411669 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt @@ -16,8 +16,10 @@ sealed class ScreenAction { @Stable data class Static( val title: @Composable () -> String, + val subTitle: @Composable () -> String? = { null }, val color: @Composable () -> Color = { Color.White }, val icon: @Composable () -> ImageVector? = { null }, + val dotColor: @Composable () -> Color? = { null }, val onAction: () -> Unit = {} ) : ScreenAction() diff --git a/component/src/main/java/com/lalilu/component/extension/FlowExt.kt b/component/src/main/java/com/lalilu/component/extension/FlowExt.kt index 58f20c868..7851e45d0 100644 --- a/component/src/main/java/com/lalilu/component/extension/FlowExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/FlowExt.kt @@ -8,10 +8,12 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext /** * 将Flow转换为State @@ -25,9 +27,13 @@ fun Flow.toState(scope: CoroutineScope): State { /** * 将Flow转换为State,附带初始值 */ -fun Flow.toState(defaultValue: T, scope: CoroutineScope): State { +fun Flow.toState( + defaultValue: T, + scope: CoroutineScope, + context: CoroutineContext = Dispatchers.Unconfined +): State { return mutableStateOf(defaultValue).also { state -> - this.onEach { state.value = it }.launchIn(scope) + scope.launch(context = context) { collect { state.value = it } } } } diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt index f3071fcb6..26deb61bc 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt @@ -2,12 +2,22 @@ package com.lalilu.component.navigation import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -21,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi @@ -30,6 +41,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -40,6 +52,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.SubcomposeLayout @@ -57,7 +70,11 @@ import com.lalilu.component.extension.DialogWrapper import com.lalilu.remixicon.Arrows import com.lalilu.remixicon.System import com.lalilu.remixicon.arrows.arrowLeftSLine -import com.lalilu.remixicon.system.more2Fill +import com.lalilu.remixicon.system.moreLine +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.random.Random +import kotlin.random.nextInt @Composable @@ -105,7 +122,7 @@ fun NavigateCommonBarContent( TextButton( modifier = Modifier.fillMaxHeight(), shape = RectangleShape, - contentPadding = PaddingValues(start = 16.dp, end = 24.dp), + contentPadding = PaddingValues(start = 12.dp, end = 20.dp), colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colors.onBackground ), @@ -147,7 +164,14 @@ fun NavigateCommonBarContent( if (actions == null) return@SubcomposeLayout layout(0, 0) {} val moreBtnMeasurable = subcompose("moreBtn") { - MoreActionBtn(onClick = { dialogVisible.value = true }) + val colors = screenActions?.filterIsInstance() + ?.mapNotNull { it.dotColor() } + ?: emptyList() + + MoreActionBtn( + dotColors = colors, + onClick = { dialogVisible.value = true }, + ) }[0] val moreBtnPlaceable = moreBtnMeasurable.measure( constraints.copy( @@ -199,24 +223,64 @@ fun NavigateCommonBarContent( private fun MoreActionBtn( modifier: Modifier = Modifier, color: Color = MaterialTheme.colors.onBackground, + dotColors: List = emptyList(), onClick: () -> Unit = {} ) { TextButton( modifier = modifier.fillMaxHeight(), shape = RectangleShape, - contentPadding = PaddingValues(horizontal = 10.dp), colors = ButtonDefaults.textButtonColors( backgroundColor = color.copy(alpha = 0.15f), contentColor = color ), onClick = onClick ) { - Image( - modifier = Modifier.size(20.dp), - imageVector = RemixIcon.System.more2Fill, - contentDescription = "More Actions", - colorFilter = ColorFilter.tint(color = color) - ) + val showingColor = remember { mutableStateOf(null) } + + LaunchedEffect(showingColor.value) { + if (showingColor.value == null) { + showingColor.value = dotColors.firstOrNull() + return@LaunchedEffect + } + + delay(3000) + if (!isActive) return@LaunchedEffect + + val currentIndex = dotColors.indexOf(showingColor.value) + val nextIndex = (currentIndex + 1) % dotColors.size + showingColor.value = dotColors.getOrNull(nextIndex) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.System.moreLine, + contentDescription = null, + tint = color + ) + + showingColor.value?.let { dotColor -> + AnimatedContent( + modifier = Modifier.align(Alignment.TopStart), + transitionSpec = { + fadeIn(spring(stiffness = Spring.StiffnessLow)) togetherWith + fadeOut(spring(stiffness = Spring.StiffnessLow)) + }, + targetState = dotColor, + label = "" + ) { + Spacer( + modifier = Modifier + .clip(CircleShape) + .background(color = it) + .size(8.dp) + ) + } + } + } } } @@ -235,33 +299,82 @@ private fun ActionItem( is ScreenAction.Static -> { val color = action.color() val title = action.title() + val subTitle = action.subTitle() val icon = action.icon() + val dotColor = action.dotColor() Surface( modifier = modifier, color = color.copy(0.2f), onClick = { action.onAction() } ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier, + contentAlignment = Alignment.CenterStart ) { - icon?.let { - Image( - modifier = Modifier.size(20.dp), - imageVector = icon, - contentDescription = title, - colorFilter = ColorFilter.tint(color = color) + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon?.let { + Image( + modifier = Modifier.size(20.dp), + imageVector = icon, + contentDescription = title, + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + Column( + modifier = Modifier, + verticalArrangement = Arrangement + .spacedBy(2.dp, Alignment.CenterVertically) + ) { + Text( + text = title, + fontSize = 14.sp, + lineHeight = 14.sp, + color = color, + fontWeight = FontWeight.Medium + ) + + if (actionContext.isFullyExpanded && subTitle != null) { + Text( + text = subTitle, + fontSize = 10.sp, + lineHeight = 10.sp, + color = color.copy(0.5f), + ) + } + } + } + + if (dotColor != null) { + val animation = rememberInfiniteTransition(label = "") + val scaleValue = animation.animateFloat( + initialValue = 0.1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset( + offsetMillis = remember { Random.nextInt(0..1000) } + ) + ), + label = "" + ) + + Spacer( + modifier = Modifier + .graphicsLayer { alpha = scaleValue.value } + .padding(8.dp) + .align(Alignment.TopStart) + .clip(CircleShape) + .background(color = dotColor) + .size(8.dp) ) - Spacer(modifier = Modifier.width(6.dp)) } - Text( - text = title, - fontSize = 14.sp, - lineHeight = 14.sp, - color = color, - fontWeight = FontWeight.Medium - ) } } } From 9873822fcad721d6a8780aad25add81b27c874be Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Wed, 28 Aug 2024 19:11:28 +0800 Subject: [PATCH 076/213] =?UTF-8?q?[refactor]=E5=88=9B=E5=BB=BAHeaderJumpe?= =?UTF-8?q?rDialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/songs/SongsHeaderJumperDialog.kt | 33 +++++++++++++++++++ .../lmusic/compose/screen/songs/SongsSM.kt | 1 + .../compose/screen/songs/SongsScreen.kt | 5 ++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt new file mode 100644 index 000000000..cb5d37edd --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt @@ -0,0 +1,33 @@ +package com.lalilu.lmusic.compose.screen.songs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.lalilu.component.extension.DialogItem +import com.lalilu.component.extension.DialogWrapper + + +@Composable +internal fun SongsHeaderJumperDialog( + isVisible: MutableState, +) { + val dialog = remember { + DialogItem.Dynamic(backgroundColor = Color.Transparent) { + SongsHeaderJumperDialogContent( + + ) + } + } + + DialogWrapper.register( + isVisible = isVisible, + dialogItem = dialog + ) +} + +@Composable +private fun SongsHeaderJumperDialogContent(modifier: Modifier = Modifier) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt index 93b532ed6..8f9b0ac10 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -52,6 +52,7 @@ internal class SongsSM( ) : ScreenModel { // 持久化元素的状态 val showSortPanel = mutableStateOf(false) + val showJumperDialog = mutableStateOf(false) val showSearcherPanel = mutableStateOf(false) val supportSortActions = setOf( SortStaticAction.Normal, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 019bfeb2d..122179989 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -81,7 +81,6 @@ data class SongsScreen( ), ScreenAction.Static( title = { stringResource(id = R.string.screen_action_locate_playing_item) }, - dotColor = { Color.Blue }, icon = { RemixIcon.Design.focus3Line }, color = { Color(0xFF8700FF) }, onAction = { songsSM?.action(SongsScreenAction.LocaleToPlayingItem) } @@ -104,6 +103,10 @@ data class SongsScreen( onSelectSortAction = { sm.sorter.selectSortAction(it) } ) + SongsHeaderJumperDialog( + isVisible = sm.showJumperDialog + ) + SongsSearcherPanel( isVisible = sm.showSearcherPanel, keyword = { sm.searcher.keywordState.value }, From 83ed465f99a3f65ae7eccbfe2b5a70a0f5517ef1 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 28 Aug 2024 23:46:29 +0800 Subject: [PATCH 077/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E8=AE=B0?= =?UTF-8?q?=E5=BD=95LazyList=E4=B8=AD=E5=85=83=E7=B4=A0Key=E5=80=BC?= =?UTF-8?q?=E4=BB=A5=E7=94=A8=E4=BA=8E=E6=BB=9A=E5=8A=A8=E5=88=B0=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E5=85=83=E7=B4=A0=E7=9A=84=E5=9F=BA=E7=A1=80=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/screen/songs/SongsSM.kt | 15 ++- .../screen/songs/SongsScreenContent.kt | 102 ++++++++++-------- .../com/lalilu/component/base/LocalObject.kt | 2 + .../component/extension/ItemRecorder.kt | 74 +++++++++++++ 4 files changed, 145 insertions(+), 48 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt index 8f9b0ac10..ce7fa82fa 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -11,6 +11,7 @@ import cafe.adriel.voyager.core.model.screenModelScope import com.lalilu.common.base.BaseSp import com.lalilu.common.base.Playable import com.lalilu.common.ext.requestFor +import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.ItemSelector import com.lalilu.component.extension.toState import com.lalilu.component.viewmodel.SongsSp @@ -22,6 +23,7 @@ import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortDynamicAction import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmedia.extension.Sortable +import com.lalilu.lplayer.LPlayer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob @@ -44,7 +46,7 @@ internal sealed interface SongsScreenAction { } internal sealed interface SongsScreenEvent { - data class ScrollToItem(val key: String) : SongsScreenEvent + data class ScrollToItem(val index: Int) : SongsScreenEvent } internal class SongsSM( @@ -71,7 +73,15 @@ internal class SongsSM( fun action(action: SongsScreenAction) = screenModelScope.launch { when (action) { SongsScreenAction.LocaleToPlayingItem -> { - eventFlow.emit(SongsScreenEvent.ScrollToItem("")) + val mediaId = LPlayer.runtime.info.playingIdFlow.value + ?: return@launch + + val index = recorder.list() + .indexOf(mediaId) + .takeIf { it >= 0 } + ?: return@launch + + eventFlow.emit(SongsScreenEvent.ScrollToItem(index)) } SongsScreenAction.ToggleSortPanel -> { @@ -100,6 +110,7 @@ internal class SongsSM( context = Dispatchers.IO + SupervisorJob() ) val selector = ItemSelector() + val recorder = ItemRecorder() } internal class ItemSearcher( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index 24e8be958..b9be50d61 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -1,12 +1,13 @@ package com.lalilu.lmusic.compose.screen.songs import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -15,10 +16,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import com.lalilu.common.base.Playable import com.lalilu.component.base.smartBarPadding import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.flow.collectLatest @@ -30,15 +33,20 @@ internal fun SongsScreenContent( isSelected: (Playable) -> Boolean = { false }, onSelect: (Playable) -> Unit = {} ) { + val density = LocalDensity.current val hapticFeedback = LocalHapticFeedback.current val listState: LazyListState = rememberLazyListState() + val statusBar = WindowInsets.statusBars val songs by songsSM.songs LaunchedEffect(Unit) { songsSM.event().collectLatest { when (it) { is SongsScreenEvent.ScrollToItem -> { - listState.scrollToItem(0) + listState.scrollToItem( + index = it.index, + scrollOffset = -statusBar.getTop(density) + ) } } } @@ -48,55 +56,57 @@ internal fun SongsScreenContent( state = listState, modifier = Modifier.fillMaxSize(), ) { - item { - Column( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - ) { - Text(text = "全部歌曲") + startRecord(songsSM.recorder) { + itemWithRecord(key = "全部歌曲") { + Column( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + ) { + Text(text = "全部歌曲") - val count = remember(songs) { songs.values.flatten().size } - Text(text = "$count 首歌曲") + val count = remember(songs) { songs.values.flatten().size } + Text(text = "$count 首歌曲") + } } - } - songs.forEach { (group, list) -> - item( - key = group, - contentType = "group" - ) { - Text(text = "$group") - } + songs.forEach { (group, list) -> + itemWithRecord( + key = group, + contentType = "group" + ) { + Text(text = "$group") + } - items( - items = list, - key = { it.mediaId }, - contentType = { it::class.java } - ) { - SongCard( - song = { it }, - isSelected = { isSelected(it) }, - onClick = { - if (isSelecting()) { - onSelect(it) - } else { - PlayerAction.PlayById(it.mediaId).action() - } - }, - onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + itemsWithRecord( + items = list, + key = { it.mediaId }, + contentType = { it::class.java } + ) { + SongCard( + song = { it }, + isSelected = { isSelected(it) }, + onClick = { + if (isSelecting()) { + onSelect(it) + } else { + PlayerAction.PlayById(it.mediaId).action() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - if (isSelecting()) { - onSelect(it) - } else { - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) - .jump() - } - }, - onEnterSelect = { onSelect(it) } - ) + if (isSelecting()) { + onSelect(it) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.mediaId) + .jump() + } + }, + onEnterSelect = { onSelect(it) } + ) + } } } diff --git a/component/src/main/java/com/lalilu/component/base/LocalObject.kt b/component/src/main/java/com/lalilu/component/base/LocalObject.kt index e62332c0f..af894de3b 100644 --- a/component/src/main/java/com/lalilu/component/base/LocalObject.kt +++ b/component/src/main/java/com/lalilu/component/base/LocalObject.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.windowsizeclass.WindowSizeClass @@ -27,6 +28,7 @@ fun LazyListScope.smartBarPadding() { ) { val bottomHeight = LocalSmartBarPadding.current.value.calculateBottomPadding() + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + WindowInsets.ime.asPaddingValues().calculateBottomPadding() + 16.dp Spacer( diff --git a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt new file mode 100644 index 000000000..267eae151 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt @@ -0,0 +1,74 @@ +package com.lalilu.component.extension + +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable + +class LazyListRecordScope internal constructor( + var recorder: ItemRecorder, +) { + var lazyListScope: LazyListScope? = null + internal set + + fun itemWithRecord( + key: Any? = null, + contentType: Any? = null, + content: @Composable LazyItemScope.() -> Unit + ) { + lazyListScope?.let { scope -> + recorder.record(key) + scope.item( + key = key, + contentType = contentType, + content = content + ) + } + } + + inline fun itemsWithRecord( + items: List, + noinline key: ((item: T) -> Any)? = null, + noinline contentType: (item: T) -> Any? = { null }, + crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit + ) { + lazyListScope?.let { scope -> + recorder.recordAll(items.map { key?.invoke(it) }) + scope.items( + items = items, + key = key, + contentType = contentType, + itemContent = itemContent + ) + } + } +} + +class ItemRecorder { + private val keys = mutableListOf() + private val scope = LazyListRecordScope(this) + + fun record(key: Any?) = this.keys.add(key) + fun recordAll(keys: List) = this.keys.addAll(keys) + fun clear() = keys.clear() + fun list() = keys + + internal fun startRecord( + lazyListScope: LazyListScope, + block: LazyListRecordScope.() -> Unit + ) { + clear() + scope.lazyListScope = lazyListScope + scope.block() + } +} + +fun LazyListScope.startRecord( + recorder: ItemRecorder, + block: LazyListRecordScope.() -> Unit +) { + recorder.startRecord( + lazyListScope = this, + block = block + ) +} \ No newline at end of file From ed993e1c30877d9a8caeab7775675d0ae0d9a328 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 29 Aug 2024 00:17:20 +0800 Subject: [PATCH 078/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=88=97=E8=A1=A8=E5=85=83=E7=B4=A0=E7=9A=84=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/songs/SongsHeaderJumperDialog.kt | 86 ++++++++++++++++++- .../lmusic/compose/screen/songs/SongsSM.kt | 11 +++ .../compose/screen/songs/SongsScreen.kt | 5 +- .../component/extension/ItemRecorder.kt | 3 +- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt index cb5d37edd..0b98e004e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt @@ -1,22 +1,47 @@ package com.lalilu.lmusic.compose.screen.songs +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.Chip +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.lalilu.component.extension.DialogItem import com.lalilu.component.extension.DialogWrapper - +import com.lalilu.lmedia.extension.GroupIdentity @Composable internal fun SongsHeaderJumperDialog( isVisible: MutableState, + items: () -> Collection, + onSelectItem: (item: GroupIdentity) -> Unit = {} ) { val dialog = remember { DialogItem.Dynamic(backgroundColor = Color.Transparent) { SongsHeaderJumperDialogContent( - + items = items, + onSelectItem = onSelectItem ) } } @@ -27,7 +52,62 @@ internal fun SongsHeaderJumperDialog( ) } +@OptIn(ExperimentalMaterialApi::class) @Composable -private fun SongsHeaderJumperDialogContent(modifier: Modifier = Modifier) { +private fun SongsHeaderJumperDialogContent( + modifier: Modifier = Modifier, + items: () -> Collection, + onSelectItem: (item: GroupIdentity) -> Unit = {} +) { + val navigationBarsPadding = WindowInsets.navigationBars.asPaddingValues() + val charMapping = remember { + items().filter { it.text.isNotBlank() } + .groupBy { it.text[0].category } + } + LazyVerticalGrid( + modifier = modifier + .fillMaxSize() + .statusBarsPadding(), + columns = GridCells.Adaptive(56.dp), + contentPadding = PaddingValues( + start = 20.dp, + end = 20.dp, + bottom = navigationBarsPadding.calculateBottomPadding() + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + charMapping.forEach { (key, value) -> + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 14.sp, + color = Color.White, + text = key.name + // TODO 需要为CharCategory设置i18n转换 + ) + } + + items(items = value) { + Chip( + modifier = Modifier, + onClick = { onSelectItem(it) } + ) { + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.h6, + text = it.text + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt index ce7fa82fa..304c1f59a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt @@ -19,6 +19,7 @@ import com.lalilu.component.viewmodel.findInstance import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.BaseMatchable import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortDynamicAction import com.lalilu.lmedia.extension.SortStaticAction @@ -42,6 +43,7 @@ import org.koin.java.KoinJavaComponent.inject internal sealed interface SongsScreenAction { data object ToggleSortPanel : SongsScreenAction data object LocaleToPlayingItem : SongsScreenAction + data class LocaleToGroupItem(val item: GroupIdentity) : SongsScreenAction data class SearchFor(val keyword: String) : SongsScreenAction } @@ -92,6 +94,15 @@ internal class SongsSM( searcher.keywordState.value = action.keyword } + is SongsScreenAction.LocaleToGroupItem -> { + val index = recorder.list() + .indexOf(action.item) + .takeIf { it >= 0 } + ?: return@launch + + eventFlow.emit(SongsScreenEvent.ScrollToItem(index)) + } + else -> {} } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 122179989..74d293223 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -16,6 +16,7 @@ import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType import com.lalilu.component.extension.DialogWrapper +import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.remixicon.Design import com.lalilu.remixicon.Editor import com.lalilu.remixicon.System @@ -104,7 +105,9 @@ data class SongsScreen( ) SongsHeaderJumperDialog( - isVisible = sm.showJumperDialog + isVisible = sm.showJumperDialog, + items = { sm.recorder.list().filterIsInstance() }, + onSelectItem = { sm.action(SongsScreenAction.LocaleToGroupItem(it)) } ) SongsSearcherPanel( diff --git a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt index 267eae151..b7fb19594 100644 --- a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt +++ b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf class LazyListRecordScope internal constructor( var recorder: ItemRecorder, @@ -45,7 +46,7 @@ class LazyListRecordScope internal constructor( } class ItemRecorder { - private val keys = mutableListOf() + private val keys = mutableStateListOf() private val scope = LazyListRecordScope(this) fun record(key: Any?) = this.keys.add(key) From 9e8d0bfc237d74f5a6a1f1f722aef7589ead905f Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 29 Aug 2024 19:58:26 +0800 Subject: [PATCH 079/213] =?UTF-8?q?[refactor]=E6=B3=A8=E9=87=8A=E5=92=8C?= =?UTF-8?q?=E5=8E=BB=E9=99=A4=E6=97=A0=E7=94=A8=E4=BB=A3=E7=A0=81=EF=BC=8C?= =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=AE=8C=E6=88=90StickyHeader=E7=9A=84?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/new_screen/search/SearchScreen.kt | 234 +++++++------- .../compose/screen/songs/SongsScreen.kt | 3 +- .../screen/songs/SongsScreenContent.kt | 53 ++- .../component/LLazyVerticalStaggeredGrid.kt | 58 ---- .../com/lalilu/component/SongListWrapper.kt | 302 ------------------ .../main/java/com/lalilu/component/Songs.kt | 209 ++++++------ .../java/com/lalilu/component/SortPanel.kt | 181 ----------- .../com/lalilu/component/TwoColumnWithPad.kt | 63 ---- .../component/extension/ItemRecorder.kt | 17 + .../component/extension/StickyHeaderHelper.kt | 116 ------- .../extension/StickyHeaderOffsetHelper.kt | 55 ++++ .../component/navigation/EmptyScreen.kt | 1 + .../lalilu/lalbum/screen/AlbumDetailScreen.kt | 83 +++-- .../com/lalilu/lalbum/screen/AlbumsScreen.kt | 24 +- .../lartist/screen/ArtistDetailScreen.kt | 115 +++---- .../lalilu/lhistory/screen/HistoryScreen.kt | 38 +-- 16 files changed, 462 insertions(+), 1090 deletions(-) delete mode 100644 component/src/main/java/com/lalilu/component/LLazyVerticalStaggeredGrid.kt delete mode 100644 component/src/main/java/com/lalilu/component/SongListWrapper.kt delete mode 100644 component/src/main/java/com/lalilu/component/SortPanel.kt delete mode 100644 component/src/main/java/com/lalilu/component/TwoColumnWithPad.kt delete mode 100644 component/src/main/java/com/lalilu/component/extension/StickyHeaderHelper.kt create mode 100644 component/src/main/java/com/lalilu/component/extension/StickyHeaderOffsetHelper.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt index 06a61902e..7d34a4424 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt @@ -1,6 +1,5 @@ package com.lalilu.lmusic.compose.new_screen.search -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -27,21 +26,12 @@ import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.KeyboardUtils import com.lalilu.R -import com.lalilu.component.Songs import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.extension.singleViewModel -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.lartist.component.ArtistCard -import com.lalilu.lmedia.entity.LArtist -import com.lalilu.lmedia.entity.LSong import com.lalilu.lmusic.compose.component.base.SearchInputBar -import com.lalilu.lmusic.compose.component.card.RecommendCardForAlbum -import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle -import com.lalilu.lmusic.compose.screen.songs.SongsScreen import com.lalilu.lmusic.utils.extension.getActivity import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchViewModel @@ -100,119 +90,119 @@ private fun Screen.SearchScreen( } } - Songs( - mediaIds = searchVM.songsResult.value.take(5).map { it.mediaId }, - sortFor = "SearchResult", - supportListAction = { emptyList() }, - headerContent = { - item(key = "Song_Header") { - RecommendTitle( - modifier = Modifier.height(64.dp), - title = "歌曲", - onClick = { - if (searchVM.songsResult.value.isNotEmpty()) { - AppRouter.intent(NavIntent.Push( - SongsScreen( - title = "[${searchVM.keyword.value}]\n歌曲搜索结果", - mediaIds = searchVM.songsResult.value.map { it.mediaId } - ) - )) - } - } - ) - } - }, - footerContent = { - val onAlbumHeaderClick = { - if (searchVM.albumsResult.value.isNotEmpty()) { -// navigator.navigate( -// AlbumsScreenDestination( -// title = "[${keyword.value}]\n专辑搜索结果", -// sortFor = "SearchResultForAlbum", -// albumIdsText = searchVM.albumsResult.value.map(LAlbum::id).json() -// ) +// Songs( +// mediaIds = searchVM.songsResult.value.take(5).map { it.mediaId }, +// sortFor = "SearchResult", +// supportListAction = { emptyList() }, +// headerContent = { +// item(key = "Song_Header") { +// RecommendTitle( +// modifier = Modifier.height(64.dp), +// title = "歌曲", +// onClick = { +// if (searchVM.songsResult.value.isNotEmpty()) { +// AppRouter.intent(NavIntent.Push( +// SongsScreen( +// title = "[${searchVM.keyword.value}]\n歌曲搜索结果", +// mediaIds = searchVM.songsResult.value.map { it.mediaId } +// ) +// )) +// } +// } // ) - } - } - - item(key = "AlbumHeader") { - RecommendTitle( - title = "专辑", - modifier = Modifier.height(64.dp), - onClick = onAlbumHeaderClick - ) { - AnimatedVisibility(visible = searchVM.albumsResult.value.isNotEmpty()) { - Chip( - onClick = onAlbumHeaderClick, - ) { - Text( - text = "${searchVM.albumsResult.value.size} 条结果", - style = MaterialTheme.typography.caption, - ) - } - } - } - } - item(key = "AlbumItems") { - AnimatedContent( - targetState = searchVM.albumsResult.value.isNotEmpty(), - label = "" - ) { show -> - if (show) { - RecommendRow( - items = { searchVM.albumsResult.value }, - getId = { it.id } - ) { - RecommendCardForAlbum( - modifier = Modifier.animateItemPlacement(), - width = { 100.dp }, - height = { 100.dp }, - item = { it }, - onClick = { -// navigator.navigate(AlbumDetailScreenDestination(albumId = it.id)) - } - ) - } - } else { - Text(modifier = Modifier.padding(20.dp), text = "无匹配专辑") - } - } - } - - searchItem( - name = "艺术家", - showCount = 5, - getId = { it.id }, - items = searchVM.artistsResult.value, - getContentType = { LArtist::class }, - onClickHeader = { - if (searchVM.artistsResult.value.isNotEmpty()) { -// navigator.navigate( -// ArtistsScreenDestination( -// title = "[${keyword.value}]\n艺术家搜索结果", -// sortFor = "SearchResultForArtist", -// artistIdsText = searchVM.artistsResult.value.map(LArtist::name).json() -// ) -// ) - } - } - ) { item -> - ArtistCard( - artist = item, - isPlaying = { - playingVM.isItemPlaying { playing -> - playing.let { it as? LSong } - ?.let { song -> song.artists.any { it.name == item.name } } - ?: false - } - }, - onClick = { -// navigator.navigate(ArtistDetailScreenDestination(artistName = item.name)) - } - ) - } - } - ) +// } +// }, +// footerContent = { +// val onAlbumHeaderClick = { +// if (searchVM.albumsResult.value.isNotEmpty()) { +//// navigator.navigate( +//// AlbumsScreenDestination( +//// title = "[${keyword.value}]\n专辑搜索结果", +//// sortFor = "SearchResultForAlbum", +//// albumIdsText = searchVM.albumsResult.value.map(LAlbum::id).json() +//// ) +//// ) +// } +// } +// +// item(key = "AlbumHeader") { +// RecommendTitle( +// title = "专辑", +// modifier = Modifier.height(64.dp), +// onClick = onAlbumHeaderClick +// ) { +// AnimatedVisibility(visible = searchVM.albumsResult.value.isNotEmpty()) { +// Chip( +// onClick = onAlbumHeaderClick, +// ) { +// Text( +// text = "${searchVM.albumsResult.value.size} 条结果", +// style = MaterialTheme.typography.caption, +// ) +// } +// } +// } +// } +// item(key = "AlbumItems") { +// AnimatedContent( +// targetState = searchVM.albumsResult.value.isNotEmpty(), +// label = "" +// ) { show -> +// if (show) { +// RecommendRow( +// items = { searchVM.albumsResult.value }, +// getId = { it.id } +// ) { +// RecommendCardForAlbum( +// modifier = Modifier.animateItemPlacement(), +// width = { 100.dp }, +// height = { 100.dp }, +// item = { it }, +// onClick = { +//// navigator.navigate(AlbumDetailScreenDestination(albumId = it.id)) +// } +// ) +// } +// } else { +// Text(modifier = Modifier.padding(20.dp), text = "无匹配专辑") +// } +// } +// } +// +// searchItem( +// name = "艺术家", +// showCount = 5, +// getId = { it.id }, +// items = searchVM.artistsResult.value, +// getContentType = { LArtist::class }, +// onClickHeader = { +// if (searchVM.artistsResult.value.isNotEmpty()) { +//// navigator.navigate( +//// ArtistsScreenDestination( +//// title = "[${keyword.value}]\n艺术家搜索结果", +//// sortFor = "SearchResultForArtist", +//// artistIdsText = searchVM.artistsResult.value.map(LArtist::name).json() +//// ) +//// ) +// } +// } +// ) { item -> +// ArtistCard( +// artist = item, +// isPlaying = { +// playingVM.isItemPlaying { playing -> +// playing.let { it as? LSong } +// ?.let { song -> song.artists.any { it.name == item.name } } +// ?: false +// } +// }, +// onClick = { +//// navigator.navigate(ArtistDetailScreenDestination(artistName = item.name)) +// } +// ) +// } +// } +// ) } @OptIn(ExperimentalMaterialApi::class) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 74d293223..6172569a4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -149,7 +149,8 @@ data class SongsScreen( songsSM = sm, isSelecting = { sm.selector.isSelecting.value }, isSelected = { sm.selector.isSelected(it) }, - onSelect = { sm.selector.onSelect(it) } + onSelect = { sm.selector.onSelect(it) }, + onClickGroup = { sm.showJumperDialog.value = true } ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index b9be50d61..fba5ddf86 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -1,42 +1,60 @@ package com.lalilu.lmusic.compose.screen.songs +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.lalilu.common.base.Playable import com.lalilu.component.base.smartBarPadding import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.StickyHeaderOffsetHelper import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.flow.collectLatest +@OptIn(ExperimentalMaterialApi::class) @Composable internal fun SongsScreenContent( songsSM: SongsSM, isSelecting: () -> Boolean = { false }, isSelected: (Playable) -> Boolean = { false }, - onSelect: (Playable) -> Unit = {} + onSelect: (Playable) -> Unit = {}, + onClickGroup: (GroupIdentity) -> Unit = {} ) { val density = LocalDensity.current val hapticFeedback = LocalHapticFeedback.current val listState: LazyListState = rememberLazyListState() val statusBar = WindowInsets.statusBars + val statusBarHeight = remember(statusBar, density) { statusBar.getTop(density) } val songs by songsSM.songs LaunchedEffect(Unit) { @@ -45,7 +63,7 @@ internal fun SongsScreenContent( is SongsScreenEvent.ScrollToItem -> { listState.scrollToItem( index = it.index, - scrollOffset = -statusBar.getTop(density) + scrollOffset = -statusBarHeight ) } } @@ -71,11 +89,38 @@ internal fun SongsScreenContent( } songs.forEach { (group, list) -> - itemWithRecord( + stickyHeaderWithRecord( key = group, contentType = "group" ) { - Text(text = "$group") + StickyHeaderOffsetHelper( + key = group, + listState = listState, + minOffset = statusBarHeight + ) { modifier, isFloating -> + Text( + modifier = modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .widthIn(min = 64.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { onClickGroup(group) } + .border( + width = 1.dp, + color = MaterialTheme.colors.onBackground.copy(0.1f), + shape = RoundedCornerShape(8.dp) + ) + .background(color = MaterialTheme.colors.background) + .padding( + horizontal = 16.dp, + vertical = 12.dp + ), + textAlign = TextAlign.Start, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 14.sp, + text = group.text + ) + } } itemsWithRecord( diff --git a/component/src/main/java/com/lalilu/component/LLazyVerticalStaggeredGrid.kt b/component/src/main/java/com/lalilu/component/LLazyVerticalStaggeredGrid.kt deleted file mode 100644 index 0d864c7e5..000000000 --- a/component/src/main/java/com/lalilu/component/LLazyVerticalStaggeredGrid.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.lalilu.component - -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope -import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan -import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.lalilu.component.base.LocalSmartBarPadding - -@Composable -fun LLazyVerticalStaggeredGrid( - columns: StaggeredGridCells, - modifier: Modifier = Modifier, - state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), - contentPadding: PaddingValues = PaddingValues(0.dp), - reverseLayout: Boolean = false, - verticalItemSpacing: Dp = 0.dp, - horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(0.dp), - flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), - userScrollEnabled: Boolean = true, - content: LazyStaggeredGridScope.() -> Unit -) { - val padding by LocalSmartBarPadding.current - - LazyVerticalStaggeredGrid( - columns = columns, - modifier = modifier, - state = state, - contentPadding = contentPadding, - reverseLayout = reverseLayout, - verticalItemSpacing = verticalItemSpacing, - horizontalArrangement = horizontalArrangement, - flingBehavior = flingBehavior, - userScrollEnabled = userScrollEnabled, - ) { - content() - item(span = StaggeredGridItemSpan.FullLine) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(padding.calculateBottomPadding() + 20.dp) - ) - } - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/SongListWrapper.kt b/component/src/main/java/com/lalilu/component/SongListWrapper.kt deleted file mode 100644 index 696792a88..000000000 --- a/component/src/main/java/com/lalilu/component/SongListWrapper.kt +++ /dev/null @@ -1,302 +0,0 @@ -package com.lalilu.component - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Chip -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import com.lalilu.common.base.Playable -import com.lalilu.component.card.SongCard -import com.lalilu.component.extension.ItemSelectHelper -import com.lalilu.component.extension.LazyListScrollToHelper -import com.lalilu.component.extension.SwipeAction -import com.lalilu.component.extension.SwipeActionRow -import com.lalilu.component.extension.rememberFixedStatusBarHeightDp -import com.lalilu.component.extension.rememberStickyHelper -import com.lalilu.component.extension.stickyHeaderExtent -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyColumnState - - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) -@Composable -fun SongListWrapper( - modifier: Modifier = Modifier, - state: LazyListState = rememberLazyListState(), - itemSelectHelper: () -> ItemSelectHelper? = { null }, - scrollToHelper: () -> LazyListScrollToHelper? = { null }, - idMapper: (K) -> String, - itemsMap: Map>, - onClickItem: (Playable) -> Unit = {}, - onLongClickItem: (Playable) -> Unit = {}, - onDoubleClickItem: (Playable) -> Unit = {}, - onHeaderClick: (Any) -> Unit = {}, - hasLyric: (Playable) -> Boolean = { false }, - isFavourite: (Playable) -> Boolean = { false }, - isItemPlaying: (Playable) -> Boolean = { false }, - showPrefixContent: () -> Boolean = { false }, - emptyContent: @Composable () -> Unit = {}, - prefixContent: @Composable (item: Playable) -> Unit = {}, - headerContent: LazyListScope.() -> Unit, - footerContent: LazyListScope.() -> Unit, -) { - val haptic = LocalHapticFeedback.current - - val scrollHelper = remember { scrollToHelper() } - val selector = remember { itemSelectHelper() } - val stickyHelper = rememberStickyHelper( - listState = state, - contentType = { "GroupIdentity::class" } - ) - - LLazyColumn( - modifier = modifier, - state = state, - contentPadding = PaddingValues(top = rememberFixedStatusBarHeightDp()), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - headerContent() - scrollHelper?.startRecord() - - if (!itemsMap.values.any { it.isNotEmpty() }) { - scrollHelper?.doRecord("EMPTY_CONTENT") - item(key = "EMPTY_CONTENT") { emptyContent() } - } else { - for ((key, list) in itemsMap) { - val headerTitle = idMapper(key) - - if (headerTitle != "") { - scrollHelper?.doRecord(key) - stickyHeaderExtent( - helper = stickyHelper, - key = { key } - ) { - Chip( - modifier = Modifier - .animateItemPlacement() - .offsetWithHelper() - .zIndexWithHelper(), - onClick = { onHeaderClick(key) } - ) { - Text( - style = MaterialTheme.typography.h6, - text = headerTitle - ) - } - } - } - - scrollHelper?.doRecord(list.map { it.mediaId }) - items( - items = list, - key = { it.mediaId }, - contentType = { Playable::class } - ) { item -> - val interactionSource = remember { MutableInteractionSource() } - val swipeAction = remember { - SwipeAction.BySwipe( - iconRes = R.drawable.ic_play_list_2_fill, - titleRes = R.string.select_action_title_select_all, - onAction = { - if (selector?.isSelecting() != true) { - onDoubleClickItem(item) - } - } - ) - } - - SwipeActionRow( - actionAtLeft = swipeAction, - interactionSource = interactionSource, - ) { - SongCard( - song = { item }, - modifier = Modifier.animateItemPlacement(), - onClick = { - if (selector?.isSelecting() == true) { - selector.onSelect(item) - } else { - onClickItem(item) - } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClickItem(item) - }, - onEnterSelect = { selector?.onSelect(item) }, - isSelected = { selector?.isSelected(item) ?: false }, - isPlaying = { isItemPlaying(item) }, - showPrefix = showPrefixContent, - hasLyric = { hasLyric(item) }, - prefixContent = { modifier -> - Row( - modifier = modifier - .clip(CircleShape) - .background(MaterialTheme.colors.surface) - .padding(start = 4.dp, end = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - prefixContent(item) - } - } - ) - } - } - } - } - scrollHelper?.endRecord() - - footerContent() - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ReorderableSongListWrapper( - modifier: Modifier = Modifier, - items: List, - listState: LazyListState = rememberLazyListState(), - onDragMoveEnd: (List) -> Unit, - itemSelectHelper: () -> ItemSelectHelper? = { null }, - scrollToHelper: () -> LazyListScrollToHelper? = { null }, - onClickItem: (Playable) -> Unit = {}, - onLongClickItem: (Playable) -> Unit = {}, - onDoubleClickItem: (Playable) -> Unit = {}, - hasLyric: (Playable) -> Boolean = { false }, - isFavourite: (Playable) -> Boolean = { false }, - isItemPlaying: (Playable) -> Boolean = { false }, - showPrefixContent: () -> Boolean = { false }, - emptyContent: @Composable () -> Unit = {}, - prefixContent: @Composable (item: Playable) -> Unit = {}, - headerContent: LazyListScope.() -> Unit, - footerContent: LazyListScope.() -> Unit, -) { - val haptic = LocalHapticFeedback.current - - val scrollHelper = remember { scrollToHelper() } - val selector = remember { itemSelectHelper() } - val itemsState = remember(items) { items.toMutableStateList() } - - val reorderableState = rememberReorderableLazyColumnState( - lazyListState = listState - ) { from, to -> - itemsState.toMutableList().apply { - val toIndex = indexOfFirst { it.mediaId == to.key } - val fromIndex = indexOfFirst { it.mediaId == from.key } - if (toIndex < 0 || fromIndex < 0) return@rememberReorderableLazyColumnState - - add(toIndex, removeAt(fromIndex)) - itemsState.clear() - itemsState.addAll(this) - } - } - - LLazyColumn( - modifier = modifier, - state = listState, - contentPadding = PaddingValues(top = rememberFixedStatusBarHeightDp()), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - headerContent() - scrollHelper?.startRecord() - - if (itemsState.isEmpty()) { - scrollHelper?.doRecord("EMPTY_CONTENT") - item(key = "EMPTY_CONTENT") { - emptyContent() - } - } else { - scrollHelper?.doRecord(itemsState.map { it.mediaId }) - items( - items = itemsState, - key = { it.mediaId }, - contentType = { Playable::class } - ) { item -> - ReorderableItem( - reorderableLazyListState = reorderableState, - key = item.mediaId - ) { isDragging -> - val interactionSource = remember { MutableInteractionSource() } - val swipeAction = remember { - SwipeAction.BySwipe( - iconRes = R.drawable.ic_play_list_2_fill, - titleRes = R.string.select_action_title_select_all, - onAction = { - if (selector?.isSelecting() != true) { - onDoubleClickItem(item) - } - } - ) - } - - SwipeActionRow( - actionAtLeft = swipeAction, - interactionSource = interactionSource, - ) { - SongCard( - song = { item }, - modifier = Modifier.animateItemPlacement(), - dragModifier = Modifier.draggableHandle( - onDragStopped = { onDragMoveEnd(itemsState) } - ), - interactionSource = interactionSource, - onClick = { - if (selector?.isSelecting() == true) { - selector.onSelect(item) - } else { - onClickItem(item) - } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClickItem(item) - }, - onEnterSelect = { selector?.onSelect(item) }, - isSelected = { selector?.isSelected(item) ?: false }, - isPlaying = { isItemPlaying(item) }, - showPrefix = showPrefixContent, - hasLyric = { hasLyric(item) }, - prefixContent = { modifier -> - Row( - modifier = modifier - .clip(CircleShape) - .background(MaterialTheme.colors.surface) - .padding(start = 4.dp, end = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - prefixContent(item) - } - } - ) - } - } - } - } - - - scrollHelper?.endRecord() - footerContent() - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/Songs.kt b/component/src/main/java/com/lalilu/component/Songs.kt index e1062b7da..d4742b4c8 100644 --- a/component/src/main/java/com/lalilu/component/Songs.kt +++ b/component/src/main/java/com/lalilu/component/Songs.kt @@ -53,7 +53,6 @@ import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ToastUtils -import com.lalilu.common.base.BaseSp import com.lalilu.common.base.Playable import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.extension.DialogItem @@ -65,12 +64,10 @@ import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.extension.rememberItemSelectHelper import com.lalilu.component.extension.rememberLazyListScrollToHelper import com.lalilu.component.extension.singleViewModel -import com.lalilu.component.navigation.AppRouter import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.component.viewmodel.SongsViewModel import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmedia.extension.ListAction -import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmedia.extension.Sortable class SongsScreenModel : ScreenModel { @@ -134,12 +131,12 @@ fun Screen.Songs( selected = songsSM.selectedItems ) - val sortRuleStr = registerSortPanel( - sp = songsVM.sp, - sortFor = sortFor, - showPanelState = songsSM.showSortPanel, - supportListAction = supportListAction, - ) +// val sortRuleStr = registerSortPanel( +// sp = songsVM.sp, +// sortFor = sortFor, +// showPanelState = songsSM.showSortPanel, +// supportListAction = supportListAction, +// ) registerGroupLabelJumper( items = { songsState.value.keys }, @@ -155,108 +152,108 @@ fun Screen.Songs( } if (onDragMoveEnd != null) { - ReorderableSongListWrapper( - modifier = modifier, - items = songsState.value.values.flatten(), - listState = listState, - onDragMoveEnd = onDragMoveEnd, - scrollToHelper = { scrollToHelper }, - itemSelectHelper = { selectorHelper }, - hasLyric = { playingVM.requireHasLyric(it)[it.mediaId] ?: false }, - isFavourite = { playingVM.isFavourite(it) }, - isItemPlaying = { playingVM.isItemPlaying(it.mediaId, Playable::mediaId) }, - showPrefixContent = { showPrefixContent(sortRuleStr) }, - headerContent = { headerContent(songsState) }, - footerContent = { footerContent(songsState) }, - emptyContent = emptyContent, - prefixContent = { prefixContent(it, sortRuleStr) }, - onLongClickItem = { - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) - .push() - }, - onClickItem = { - playingVM.play( - mediaId = it.mediaId, - mediaIds = songsState.value.values.flatten().map(Playable::mediaId), - playOrPause = true - ) - }, - ) +// ReorderableSongListWrapper( +// modifier = modifier, +// items = songsState.value.values.flatten(), +// listState = listState, +// onDragMoveEnd = onDragMoveEnd, +// scrollToHelper = { scrollToHelper }, +// itemSelectHelper = { selectorHelper }, +// hasLyric = { playingVM.requireHasLyric(it)[it.mediaId] ?: false }, +// isFavourite = { playingVM.isFavourite(it) }, +// isItemPlaying = { playingVM.isItemPlaying(it.mediaId, Playable::mediaId) }, +//// showPrefixContent = { showPrefixContent(sortRuleStr) }, +// headerContent = { headerContent(songsState) }, +// footerContent = { footerContent(songsState) }, +// emptyContent = emptyContent, +//// prefixContent = { prefixContent(it, sortRuleStr) }, +// onLongClickItem = { +// AppRouter.route("/pages/songs/detail") +// .with("mediaId", it.mediaId) +// .push() +// }, +// onClickItem = { +// playingVM.play( +// mediaId = it.mediaId, +// mediaIds = songsState.value.values.flatten().map(Playable::mediaId), +// playOrPause = true +// ) +// }, +// ) } else { - SongListWrapper( - modifier = modifier, - state = listState, - itemsMap = songsState.value, - idMapper = { - when { - it is GroupIdentity.Time -> it.time - it is GroupIdentity.FirstLetter -> it.letter - it is GroupIdentity.DiskNumber && it.number > 0 -> it.number.toString() - else -> "" - } - }, - scrollToHelper = { scrollToHelper }, - itemSelectHelper = { selectorHelper }, - hasLyric = { playingVM.requireHasLyric(it)[it.mediaId] ?: false }, - isFavourite = { playingVM.isFavourite(it) }, - isItemPlaying = { playingVM.isItemPlaying(it.mediaId, Playable::mediaId) }, - onHeaderClick = { songsSM.isFastJumping.value = true }, - showPrefixContent = { showPrefixContent(sortRuleStr) }, - headerContent = { headerContent(songsState) }, - footerContent = { footerContent(songsState) }, - emptyContent = emptyContent, - prefixContent = { prefixContent(it, sortRuleStr) }, - onLongClickItem = { - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) - .push() - }, - onClickItem = { - playingVM.play( - mediaId = it.mediaId, - playOrPause = true, - addToNext = true - ) - }, - onDoubleClickItem = { - playingVM.play( - mediaId = it.mediaId, - mediaIds = songsState.value.values.flatten().map(Playable::mediaId), - playOrPause = true - ) - }, - ) +// SongListWrapper( +// modifier = modifier, +// state = listState, +// itemsMap = songsState.value, +// idMapper = { +// when { +// it is GroupIdentity.Time -> it.time +// it is GroupIdentity.FirstLetter -> it.letter +// it is GroupIdentity.DiskNumber && it.number > 0 -> it.number.toString() +// else -> "" +// } +// }, +// scrollToHelper = { scrollToHelper }, +// itemSelectHelper = { selectorHelper }, +// hasLyric = { playingVM.requireHasLyric(it)[it.mediaId] ?: false }, +// isFavourite = { playingVM.isFavourite(it) }, +// isItemPlaying = { playingVM.isItemPlaying(it.mediaId, Playable::mediaId) }, +// onHeaderClick = { songsSM.isFastJumping.value = true }, +//// showPrefixContent = { showPrefixContent(sortRuleStr) }, +// headerContent = { headerContent(songsState) }, +// footerContent = { footerContent(songsState) }, +// emptyContent = emptyContent, +//// prefixContent = { prefixContent(it, sortRuleStr) }, +// onLongClickItem = { +// AppRouter.route("/pages/songs/detail") +// .with("mediaId", it.mediaId) +// .push() +// }, +// onClickItem = { +// playingVM.play( +// mediaId = it.mediaId, +// playOrPause = true, +// addToNext = true +// ) +// }, +// onDoubleClickItem = { +// playingVM.play( +// mediaId = it.mediaId, +// mediaIds = songsState.value.values.flatten().map(Playable::mediaId), +// playOrPause = true +// ) +// }, +// ) } } -@Composable -private fun registerSortPanel( - sortFor: String, - sp: BaseSp, - showPanelState: MutableState, - supportListAction: () -> List -): State { - val sortRule = sp.obtain("${sortFor}_SORT_RULE", SortStaticAction.Normal::class.java.name) - val reverseOrder = sp.obtain("${sortFor}_SORT_RULE_REVERSE_ORDER", false) - val flattenOverride = sp.obtain("${sortFor}_SORT_RULE_FLATTEN_OVERRIDE", false) - - val dialog = remember { - DialogItem.Dynamic(backgroundColor = Color.Transparent) { - SortPanel( - sortRule = sortRule, - reverseOrder = reverseOrder, - flattenOverride = flattenOverride, - supportListAction = supportListAction, - onClose = { showPanelState.value = false } - ) - } - } - - DialogWrapper.register(isVisible = showPanelState, dialogItem = dialog) - return sortRule -} +//@Composable +//private fun registerSortPanel( +// sortFor: String, +// sp: BaseSp, +// showPanelState: MutableState, +// supportListAction: () -> List +//): State { +// val sortRule = sp.obtain("${sortFor}_SORT_RULE", SortStaticAction.Normal::class.java.name) +// val reverseOrder = sp.obtain("${sortFor}_SORT_RULE_REVERSE_ORDER", false) +// val flattenOverride = sp.obtain("${sortFor}_SORT_RULE_FLATTEN_OVERRIDE", false) +// +// val dialog = remember { +// DialogItem.Dynamic(backgroundColor = Color.Transparent) { +// SortPanel( +// sortRule = sortRule, +// reverseOrder = reverseOrder, +// flattenOverride = flattenOverride, +// supportListAction = supportListAction, +// onClose = { showPanelState.value = false } +// ) +// } +// } +// +// DialogWrapper.register(isVisible = showPanelState, dialogItem = dialog) +// return sortRule +//} @Composable diff --git a/component/src/main/java/com/lalilu/component/SortPanel.kt b/component/src/main/java/com/lalilu/component/SortPanel.kt deleted file mode 100644 index a3357729c..000000000 --- a/component/src/main/java/com/lalilu/component/SortPanel.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.lalilu.component - -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ChipDefaults -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FilterChip -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.lmedia.extension.ListAction - -/** - * 将元素的分类分组和顺序设置功能统一成一个SortPanel组件 - */ -@Composable -fun SortPanel( - sortRule: MutableState, - reverseOrder: MutableState, - flattenOverride: MutableState, - supportListAction: () -> List, - onClose: () -> Unit = {} -) { - val supportPresets by remember { derivedStateOf { supportListAction() } } - val currentPreset = remember { - derivedStateOf { - supportPresets.firstOrNull { it::class.java.name == sortRule.value } - } - } - - Surface( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(start = 15.dp, end = 15.dp, bottom = 20.dp), - border = BorderStroke(1.dp, dayNightTextColor(0.1f)), - shape = RoundedCornerShape(18.dp), - elevation = 10.dp - ) { - PresetSortPanel( - modifier = Modifier.padding(20.dp), - sortPreset = currentPreset, - reverseOrder = reverseOrder, - flattenOverride = flattenOverride, - supportSortPresets = { supportPresets }, - onReverseOrderUpdate = { reverseOrder.value = it }, - onFlattenOverrideUpdate = { flattenOverride.value = it }, - onUpdateSortPreset = { sortRule.value = it::class.java.name }, - onClose = onClose - ) - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun PresetSortPanel( - modifier: Modifier = Modifier, - sortPreset: State, - reverseOrder: State, - flattenOverride: State, - supportSortPresets: () -> List, - onReverseOrderUpdate: (Boolean) -> Unit = {}, - onFlattenOverrideUpdate: (Boolean) -> Unit = {}, - onUpdateSortPreset: (ListAction) -> Unit, - onClose: () -> Unit = {} -) { - val colors = ChipDefaults.filterChipColors( - selectedBackgroundColor = Color(0xFF029DF3), - selectedContentColor = Color.White, - backgroundColor = MaterialTheme.colors.onSurface - .compositeOver(MaterialTheme.colors.surface) - .copy(alpha = 0.05f) - ) - - Row( - modifier = modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.spacedBy(15.dp) - ) { - Column( - modifier = Modifier - .weight(1f) - .wrapContentHeight(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "常用排序逻辑", - style = MaterialTheme.typography.caption - ) - - supportSortPresets().forEach { preset -> - val title = stringResource(id = preset.titleRes) - FilterChip( - modifier = Modifier - .fillMaxWidth() - .height(36.dp), - colors = colors, - shape = RoundedCornerShape(5.dp), - selected = sortPreset.value == preset, - onClick = { onUpdateSortPreset(preset) }, - trailingIcon = { -// Icon( -// modifier = Modifier.size(18.dp), -// contentDescription = title, -// painter = painterResource( -// id = when (preset.orderRule) { -// OrderRule.Normal -> R.drawable.ic_sort_desc -// OrderRule.Reverse -> R.drawable.ic_sort_asc -// OrderRule.Shuffle -> R.drawable.ic_shuffle_line -// } -// ), -// ) - } - ) { - Text(modifier = Modifier.weight(1f), text = title) - } - } - } - - val animateColorForFlattenOverride = animateColorAsState( - targetValue = if (flattenOverride.value) Color.LightGray else Color(0xFF9CAD00), - label = "" - ) - - val animateColorForReverseOrder = animateColorAsState( - targetValue = if (reverseOrder.value) Color(0xFFFFAA00) else Color.LightGray, - label = "" - ) - - Column( - modifier = Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom) - ) { - IconTextButton( - text = "分组", - modifier = Modifier.height(36.dp), - shape = RoundedCornerShape(5.dp), - color = animateColorForFlattenOverride.value, - onClick = { onFlattenOverrideUpdate(!flattenOverride.value) } - ) - IconTextButton( - text = "倒序", - modifier = Modifier.height(36.dp), - shape = RoundedCornerShape(5.dp), - color = animateColorForReverseOrder.value, - onClick = { onReverseOrderUpdate(!reverseOrder.value) } - ) - IconTextButton( - text = "关闭", - modifier = Modifier.height(36.dp), - shape = RoundedCornerShape(5.dp), - color = Color(0xFF009AAD), - onClick = onClose - ) - } - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/TwoColumnWithPad.kt b/component/src/main/java/com/lalilu/component/TwoColumnWithPad.kt deleted file mode 100644 index fae3dd099..000000000 --- a/component/src/main/java/com/lalilu/component/TwoColumnWithPad.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.lalilu.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBarsIgnoringVisibility -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun TwoColumnWithPad( - modifier: Modifier = Modifier, - density: Density = LocalDensity.current, - minWidthBreakPoint: Dp = 500.dp, - modifierForPad: Modifier = Modifier, - modifierForNormal: Modifier = Modifier, - arrangementForPad: Arrangement.Vertical = Arrangement.Top, - arrangementForNormal: Arrangement.Vertical = Arrangement.Top, - columnForPad: LazyListScope.() -> Unit = {}, - columnForNormal: LazyListScope.(isPad: Boolean) -> Unit = {}, -) { - val paddingTop = WindowInsets.statusBarsIgnoringVisibility.asPaddingValues() - .calculateTopPadding() - - BoxWithConstraints(modifier = modifier.fillMaxSize()) { - val isPad = with(density) { constraints.maxWidth.toDp() } > minWidthBreakPoint - - Row(modifier = Modifier.fillMaxSize()) { - if (isPad) { - LLazyColumn( - modifier = modifierForPad - .fillMaxHeight() - .wrapContentWidth(), - contentPadding = PaddingValues(top = paddingTop, start = 20.dp), - verticalArrangement = arrangementForPad, - content = columnForPad, - ) - } - LLazyColumn( - modifier = modifierForNormal - .fillMaxSize() - .weight(1f), - contentPadding = PaddingValues(top = paddingTop), - content = { columnForNormal(isPad) }, - verticalArrangement = arrangementForNormal - ) - } - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt index b7fb19594..6b868a595 100644 --- a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt +++ b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt @@ -1,17 +1,34 @@ package com.lalilu.component.extension +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf +@OptIn(ExperimentalFoundationApi::class) class LazyListRecordScope internal constructor( var recorder: ItemRecorder, ) { var lazyListScope: LazyListScope? = null internal set + fun stickyHeaderWithRecord( + key: Any? = null, + contentType: Any? = null, + content: @Composable LazyItemScope.() -> Unit + ) { + lazyListScope?.let { scope -> + recorder.record(key) + scope.stickyHeader( + key = key, + contentType = contentType, + content = content + ) + } + } + fun itemWithRecord( key: Any? = null, contentType: Any? = null, diff --git a/component/src/main/java/com/lalilu/component/extension/StickyHeaderHelper.kt b/component/src/main/java/com/lalilu/component/extension/StickyHeaderHelper.kt deleted file mode 100644 index e5fc19197..000000000 --- a/component/src/main/java/com/lalilu/component/extension/StickyHeaderHelper.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.lalilu.component.extension - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.zIndex - - -class StickyHelper( - val headerKeyFirst: State, - val headerKeySecond: State, - val headerOffsetFirst: State, - val headerOffsetSecond: State, - val contentType: () -> Any -) - -abstract class ExtentLazyItemScope( - private val key: () -> Any, - private val helper: StickyHelper, - private val scope: LazyItemScope -) : LazyItemScope by scope { - - fun Modifier.offsetWithHelper() = this.offset { - IntOffset( - x = 0, - y = when (key()) { - helper.headerKeyFirst.value -> helper.headerOffsetFirst.value - helper.headerKeySecond.value -> helper.headerOffsetSecond.value - else -> 0 - } - ) - } - - fun Modifier.zIndexWithHelper() = this.zIndex( - when (key()) { - helper.headerKeyFirst.value -> 1f - helper.headerKeySecond.value -> 2f - else -> 0f - } - ) -} - -@OptIn(ExperimentalFoundationApi::class) -fun LazyListScope.stickyHeaderExtent( - key: () -> Any, - helper: StickyHelper, - headerContent: @Composable ExtentLazyItemScope.() -> Unit -) { - stickyHeader( - key = key(), - contentType = helper.contentType() - ) { - val scope = object : ExtentLazyItemScope(key = key, helper = helper, scope = this) {} - scope.headerContent() - } -} - -@Composable -fun rememberStickyHelper( - listState: LazyListState, - headerMinOffset: () -> Int = { 0 }, - contentType: () -> Any, -): StickyHelper { - val minOffset by remember { derivedStateOf { headerMinOffset() } } - - val headerFirst by remember { - derivedStateOf { - listState.layoutInfo.visibleItemsInfo - .firstOrNull { it.contentType == contentType() } - } - } - val headerSecond by remember { - derivedStateOf { - listState.layoutInfo.visibleItemsInfo - .filter { it.contentType == contentType() } - .getOrNull(1) - } - } - - val headerKeyFirst = remember { derivedStateOf { headerFirst?.key } } - val headerKeySecond = remember { derivedStateOf { headerSecond?.key } } - - val headerOffsetSecond = remember(minOffset) { - derivedStateOf { - val offset = headerSecond?.offset - if (offset == null || offset > minOffset) 0 else minOffset - offset - } - } - - val headerOffsetFirst = remember(minOffset) { - derivedStateOf { - val offset = headerFirst?.offset - if (headerOffsetSecond.value > 0) return@derivedStateOf Int.MAX_VALUE - if (offset == null || offset > minOffset) 0 else minOffset - offset - } - } - - return remember { - StickyHelper( - headerKeyFirst = headerKeyFirst, - headerKeySecond = headerKeySecond, - headerOffsetFirst = headerOffsetFirst, - headerOffsetSecond = headerOffsetSecond, - contentType = contentType - ) - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/extension/StickyHeaderOffsetHelper.kt b/component/src/main/java/com/lalilu/component/extension/StickyHeaderOffsetHelper.kt new file mode 100644 index 000000000..4e15c3343 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/extension/StickyHeaderOffsetHelper.kt @@ -0,0 +1,55 @@ +package com.lalilu.component.extension + +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.zIndex + + +@Composable +fun StickyHeaderOffsetHelper( + modifier: Modifier = Modifier, + key: Any, + minOffset: Int = 0, + listState: LazyListState, + block: @Composable (Modifier, isFloating: Boolean) -> Unit +) { + val zIndex = remember { mutableFloatStateOf(0f) } + val floating = remember { mutableStateOf(false) } + + block( + modifier + .offset { + val visibleItems = listState.layoutInfo.visibleItemsInfo + val index = visibleItems.indexOfFirst { it.key == key } + val item = visibleItems.getOrNull(index) + + if (item == null) { + floating.value = false + return@offset IntOffset.Zero + } + + val offset = item.offset + zIndex.floatValue = index.toFloat() + + when { + offset > minOffset -> { + floating.value = false + IntOffset.Zero + } + + else -> { + floating.value = true + IntOffset(0, minOffset - offset) + } + } + } + .zIndex(zIndex.floatValue), + floating.value + ) +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt b/component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt index c0ee327e4..1cb8ea9db 100644 --- a/component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt +++ b/component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt @@ -8,6 +8,7 @@ import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.base.screen.ScreenType data object EmptyScreen : Screen, ScreenType.Empty { + private fun readResolve(): Any = EmptyScreen @Composable override fun Content() { diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt index 5f135ffc1..c03f38f81 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt @@ -1,29 +1,18 @@ package com.lalilu.lalbum.screen -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.Songs -import com.lalilu.component.base.LoadingScaffold -import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.collectAsLoadingState import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType import com.lalilu.lalbum.R -import com.lalilu.lalbum.component.AlbumCoverCard import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LAlbum import com.zhangke.krouter.annotation.Destination @@ -71,42 +60,42 @@ private fun Screen.AlbumDetail( ) { val albumLoadingState = albumDetailSM.album.collectAsLoadingState() - LoadingScaffold( - modifier = Modifier.fillMaxSize(), - targetState = albumLoadingState, - onLoadErrorContent = { - Box(modifier = Modifier.fillMaxSize()) { - Text(text = "loading") - } - } - ) { album -> - Songs( - modifier = Modifier.fillMaxSize(), - mediaIds = album.songs.map { it.mediaId }, - sortFor = "ALBUM_DETAIL", - supportListAction = { emptyList() }, - headerContent = { - item { - AlbumCoverCard( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), - shape = RoundedCornerShape(10.dp), - elevation = 2.dp, - imageData = { album }, - onClick = { } - ) - } - - item { - NavigatorHeader( - title = album.name, - subTitle = "共 ${it.value.values.flatten().size} 首歌曲,总时长 ${ - album.requireItemsDuration().durationToTime() - }" - ) - } - } - ) - } +// LoadingScaffold( +// modifier = Modifier.fillMaxSize(), +// targetState = albumLoadingState, +// onLoadErrorContent = { +// Box(modifier = Modifier.fillMaxSize()) { +// Text(text = "loading") +// } +// } +// ) { album -> +// Songs( +// modifier = Modifier.fillMaxSize(), +// mediaIds = album.songs.map { it.mediaId }, +// sortFor = "ALBUM_DETAIL", +// supportListAction = { emptyList() }, +// headerContent = { +// item { +// AlbumCoverCard( +// modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), +// shape = RoundedCornerShape(10.dp), +// elevation = 2.dp, +// imageData = { album }, +// onClick = { } +// ) +// } +// +// item { +// NavigatorHeader( +// title = album.name, +// subTitle = "共 ${it.value.values.flatten().size} 首歌曲,总时长 ${ +// album.requireItemsDuration().durationToTime() +// }" +// ) +// } +// } +// ) +// } } fun Long.durationToTime(): String { diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index 05c58b56e..f7e840ac6 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -3,18 +3,23 @@ package com.lalilu.lalbum.screen import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Surface import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -22,8 +27,8 @@ import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.LLazyVerticalStaggeredGrid import com.lalilu.component.base.LoadingScaffold +import com.lalilu.component.base.LocalSmartBarPadding import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.collectAsLoadingState @@ -103,11 +108,12 @@ private fun AlbumsScreen( LoadingScaffold( targetState = albumsState ) { albums -> - LLazyVerticalStaggeredGrid( + LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(if (isPad) 3 else 2), - horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier, + contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = statusBarPadding), verticalItemSpacing = 10.dp, - contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = statusBarPadding) + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { item(key = "Header", contentType = "Header") { Surface(shape = RoundedCornerShape(5.dp)) { @@ -148,6 +154,16 @@ private fun AlbumsScreen( } ) } + + item(span = StaggeredGridItemSpan.FullLine) { + val padding by LocalSmartBarPadding.current + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(padding.calculateBottomPadding() + 20.dp) + ) + } } } diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt index dbc279cf6..24a245068 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt @@ -1,28 +1,15 @@ package com.lalilu.lartist.screen -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.Songs import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.LoadingScaffold -import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState -import com.lalilu.component.extension.SelectAction -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent import com.lalilu.lartist.R -import com.lalilu.lartist.component.ArtistCard import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LArtist import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -67,57 +54,57 @@ private fun DynamicScreen.ArtistDetail( ) { val artistState = artistDetailSM.artist.collectAsLoadingState() - LoadingScaffold(targetState = artistState) { artist -> - val relateArtist = remember { - derivedStateOf { - artist.songs.map { it.artists } - .flatten() - .toSet() - .filter { it.id != artist.name } - } - } - - Songs( - mediaIds = artist.songs.map { it.mediaId }, - selectActions = { getAll -> - listOf(SelectAction.StaticAction.SelectAll(getAll)) - }, - sortFor = "ArtistDetail", - supportListAction = { emptyList() }, - headerContent = { - item { - NavigatorHeader( - title = artist.name, - subTitle = "共 ${artist.requireItemsCount()} 首歌曲,总时长 ${ - artist.requireItemsDuration().durationToTime() - }" - ) - } - }, - footerContent = { - if (relateArtist.value.isNotEmpty()) { - item { - NavigatorHeader( - modifier = Modifier.padding(top = 20.dp), - titleScale = 0.8f, - title = "相关歌手", - subTitle = "共 ${relateArtist.value.size} 位" - ) - } - items(items = relateArtist.value) { - ArtistCard( - artist = it, - onClick = { - AppRouter.intent( - NavIntent.Push(ArtistDetailScreen(it.id)) - ) - } - ) - } - } - } - ) - } +// LoadingScaffold(targetState = artistState) { artist -> +// val relateArtist = remember { +// derivedStateOf { +// artist.songs.map { it.artists } +// .flatten() +// .toSet() +// .filter { it.id != artist.name } +// } +// } +// +// Songs( +// mediaIds = artist.songs.map { it.mediaId }, +// selectActions = { getAll -> +// listOf(SelectAction.StaticAction.SelectAll(getAll)) +// }, +// sortFor = "ArtistDetail", +// supportListAction = { emptyList() }, +// headerContent = { +// item { +// NavigatorHeader( +// title = artist.name, +// subTitle = "共 ${artist.requireItemsCount()} 首歌曲,总时长 ${ +// artist.requireItemsDuration().durationToTime() +// }" +// ) +// } +// }, +// footerContent = { +// if (relateArtist.value.isNotEmpty()) { +// item { +// NavigatorHeader( +// modifier = Modifier.padding(top = 20.dp), +// titleScale = 0.8f, +// title = "相关歌手", +// subTitle = "共 ${relateArtist.value.size} 位" +// ) +// } +// items(items = relateArtist.value) { +// ArtistCard( +// artist = it, +// onClick = { +// AppRouter.intent( +// NavIntent.Push(ArtistDetailScreen(it.id)) +// ) +// } +// ) +// } +// } +// } +// ) +// } } fun Long.durationToTime(): String { diff --git a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt index 305af36a9..70996be57 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt @@ -1,15 +1,9 @@ package com.lalilu.lhistory.screen -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.Songs import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.LoadingScaffold -import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState import com.lalilu.lhistory.R @@ -53,20 +47,20 @@ private fun DynamicScreen.HistoryScreen( ) { val mediaIdsState = historySM.mediaIds.collectAsLoadingState() - LoadingScaffold( - modifier = Modifier.fillMaxSize(), - targetState = mediaIdsState - ) { mediaIds -> - Songs( - modifier = Modifier.fillMaxSize(), - mediaIds = mediaIds, - supportListAction = { listOf() }, - headerContent = { - item { - NavigatorHeader(title = stringResource(id = R.string.history_screen_title)) - } - }, - footerContent = {} - ) - } +// LoadingScaffold( +// modifier = Modifier.fillMaxSize(), +// targetState = mediaIdsState +// ) { mediaIds -> +// Songs( +// modifier = Modifier.fillMaxSize(), +// mediaIds = mediaIds, +// supportListAction = { listOf() }, +// headerContent = { +// item { +// NavigatorHeader(title = stringResource(id = R.string.history_screen_title)) +// } +// }, +// footerContent = {} +// ) +// } } \ No newline at end of file From 9f5e5a364bbf257ac8cfc82fc51e9209480c6fd8 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 30 Aug 2024 01:38:28 +0800 Subject: [PATCH 080/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84StickyHeade?= =?UTF-8?q?r=E7=9A=84=E5=85=83=E7=B4=A0=E6=A0=B7=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E8=A7=A3=E5=86=B3=E6=97=8B=E8=BD=AC=E5=90=8E=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E9=94=99=E8=AF=AF=E5=81=8F=E7=A7=BB=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E5=88=9D=E6=AD=A5=E5=88=9B=E5=BB=BA?= =?UTF-8?q?Scrollbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/songs/SongsScreenContent.kt | 345 ++++++++++++++---- component/build.gradle.kts | 2 + .../extension/StickyHeaderOffsetHelper.kt | 6 +- 3 files changed, 270 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index fba5ddf86..ee87f0e58 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -1,29 +1,45 @@ package com.lalilu.lmusic.compose.screen.songs +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback @@ -31,6 +47,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.gigamole.composefadingedges.FadingEdgesGravity +import com.gigamole.composefadingedges.content.FadingEdgesContentType +import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig +import com.gigamole.composefadingedges.fill.FadingEdgesFillType +import com.gigamole.composefadingedges.verticalFadingEdges import com.lalilu.common.base.Playable import com.lalilu.component.base.smartBarPadding import com.lalilu.component.card.SongCard @@ -40,8 +61,10 @@ import com.lalilu.component.navigation.AppRouter import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.flow.collectLatest +import my.nanihadesuka.compose.InternalLazyColumnScrollbar +import my.nanihadesuka.compose.ScrollbarSelectionMode +import my.nanihadesuka.compose.ScrollbarSettings -@OptIn(ExperimentalMaterialApi::class) @Composable internal fun SongsScreenContent( songsSM: SongsSM, @@ -54,107 +77,269 @@ internal fun SongsScreenContent( val hapticFeedback = LocalHapticFeedback.current val listState: LazyListState = rememberLazyListState() val statusBar = WindowInsets.statusBars - val statusBarHeight = remember(statusBar, density) { statusBar.getTop(density) } val songs by songsSM.songs LaunchedEffect(Unit) { - songsSM.event().collectLatest { - when (it) { + songsSM.event().collectLatest { event -> + when (event) { is SongsScreenEvent.ScrollToItem -> { - listState.scrollToItem( - index = it.index, - scrollOffset = -statusBarHeight - ) + val targetIndex = event.index + var maxScrollCount = 3 + + // 限制最多滚动3次,避免无限死循环 + while (maxScrollCount-- > 0) { + val targetItem = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == targetIndex } + + // 判断元素是否在可见范围内 + if (targetItem != null) { + val isStickHeader = targetItem.contentType == "group" + + if (isStickHeader) { + val isFirstGroupItem = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.contentType == "group" } + ?.index == targetIndex + + if (isFirstGroupItem) { + listState.scrollToItem( + index = targetIndex, + scrollOffset = -statusBar.getTop(density) + ) + } else { + listState.animateScrollToItem( + index = targetIndex, + scrollOffset = -statusBar.getTop(density) + ) + } + } else { + val lastGroupItemOffset = listState.layoutInfo.visibleItemsInfo + .lastOrNull { it.contentType == "group" && it.index < targetIndex } + ?.size ?: 0 + + listState.animateScrollToItem( + index = targetIndex, + scrollOffset = -(statusBar.getTop(density) + lastGroupItemOffset) + ) + } + break + } else { + listState.scrollToItem( + index = targetIndex, + scrollOffset = -statusBar.getTop(density) + ) + } + } } } } } - LazyColumn( - state = listState, + SongsScreenScrollBar( modifier = Modifier.fillMaxSize(), + listState = listState ) { - startRecord(songsSM.recorder) { - itemWithRecord(key = "全部歌曲") { - Column( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - ) { - Text(text = "全部歌曲") - + LazyColumn( + modifier = Modifier + .fillMaxSize() + .verticalFadingEdges( + length = statusBar + .asPaddingValues() + .calculateTopPadding(), + contentType = FadingEdgesContentType.Dynamic.Lazy.List( + scrollConfig = FadingEdgesScrollConfig.Dynamic(), + state = listState + ), + gravity = FadingEdgesGravity.Start, + fillType = remember { + FadingEdgesFillType.FadeClip( + fillStops = Triple(0f, 0.7f, 1f) + ) + } + ), + state = listState, + ) { + startRecord(songsSM.recorder) { + itemWithRecord(key = "全部歌曲") { val count = remember(songs) { songs.values.flatten().size } - Text(text = "$count 首歌曲") - } - } - songs.forEach { (group, list) -> - stickyHeaderWithRecord( - key = group, - contentType = "group" - ) { - StickyHeaderOffsetHelper( - key = group, - listState = listState, - minOffset = statusBarHeight - ) { modifier, isFloating -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text( - modifier = modifier - .padding(horizontal = 12.dp, vertical = 8.dp) - .widthIn(min = 64.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable { onClickGroup(group) } - .border( - width = 1.dp, - color = MaterialTheme.colors.onBackground.copy(0.1f), - shape = RoundedCornerShape(8.dp) - ) - .background(color = MaterialTheme.colors.background) - .padding( - horizontal = 16.dp, - vertical = 12.dp - ), - textAlign = TextAlign.Start, + text = "全部歌曲", + fontSize = 20.sp, + lineHeight = 20.sp, fontWeight = FontWeight.Bold, - fontSize = 14.sp, - lineHeight = 14.sp, - text = group.text + color = MaterialTheme.colors.onBackground + ) + Text( + text = "共 $count 首歌曲", + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 12.sp, + lineHeight = 12.sp, ) } } - itemsWithRecord( - items = list, - key = { it.mediaId }, - contentType = { it::class.java } - ) { - SongCard( - song = { it }, - isSelected = { isSelected(it) }, - onClick = { - if (isSelecting()) { - onSelect(it) - } else { - PlayerAction.PlayById(it.mediaId).action() - } - }, - onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + songs.forEach { (group, list) -> + stickyHeaderWithRecord( + key = group, + contentType = "group" + ) { + SongsScreenStickyHeader( + listState = listState, + group = group, + minOffset = { statusBar.getTop(density) }, + onClickGroup = onClickGroup + ) + } - if (isSelecting()) { - onSelect(it) - } else { - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) - .jump() - } - }, - onEnterSelect = { onSelect(it) } - ) + itemsWithRecord( + items = list, + key = { it.mediaId }, + contentType = { it::class.java } + ) { + SongCard( + song = { it }, + isSelected = { isSelected(it) }, + onClick = { + if (isSelecting()) { + onSelect(it) + } else { + PlayerAction.PlayById(it.mediaId).action() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + if (isSelecting()) { + onSelect(it) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.mediaId) + .jump() + } + }, + onEnterSelect = { onSelect(it) } + ) + } } } + + smartBarPadding() + } + } +} + +@Composable +fun SongsScreenStickyHeader( + modifier: Modifier = Modifier, + listState: LazyListState, + group: GroupIdentity, + minOffset: () -> Int, + onClickGroup: (GroupIdentity) -> Unit +) { + StickyHeaderOffsetHelper( + modifier = modifier, + key = group, + listState = listState, + minOffset = minOffset, + ) { modifierFromHelper, isFloating -> + Box( + modifier = modifierFromHelper + .padding(horizontal = 12.dp, vertical = 8.dp) + .widthIn(min = 64.dp) + .height(IntrinsicSize.Max) + .clip(RoundedCornerShape(8.dp)) + .clickable { onClickGroup(group) } + .border( + width = 1.dp, + color = MaterialTheme.colors.onBackground.copy(0.1f), + shape = RoundedCornerShape(8.dp) + ) + .background(color = MaterialTheme.colors.background) + ) { + Text( + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 12.dp + ), + textAlign = TextAlign.Start, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 14.sp, + text = group.text + ) + + Spacer( + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxHeight() + .padding(vertical = 12.dp) + .padding(start = 6.dp) + .width(2.dp) + .clip(RoundedCornerShape(50)) + .drawBehind { drawRect(color = Color(0xFF0088FF)) } + ) } + } +} + +@Composable +fun SongsScreenScrollBar( + modifier: Modifier = Modifier, + listState: LazyListState, + content: @Composable () -> Unit +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + content() - smartBarPadding() + InternalLazyColumnScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(0.5f), + state = listState, + settings = ScrollbarSettings( + alwaysShowScrollbar = true, + scrollbarPadding = 4.dp, + thumbMinLength = 0.2f, + selectionMode = ScrollbarSelectionMode.Full, + thumbUnselectedColor = MaterialTheme.colors.onBackground.copy(0.2f), + thumbSelectedColor = MaterialTheme.colors.onBackground.copy(0.8f), + ), + indicatorContent = { index, isThumbSelected -> + AnimatedVisibility( + modifier = Modifier.offset(x = (-20).dp), + enter = fadeIn() + scaleIn(initialScale = 0.5f), + exit = fadeOut() + scaleOut(targetScale = 0.5f), + visible = isThumbSelected + ) { + Text( + modifier = Modifier + .widthIn(min = 100.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colors.onBackground.copy(0.1f), + shape = RoundedCornerShape(8.dp) + ) + .background(color = MaterialTheme.colors.background) + .padding(horizontal = 16.dp, vertical = 16.dp), + textAlign = TextAlign.End, + text = "$index", + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 14.sp + ) + } + } + ) } } \ No newline at end of file diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 5274bb77c..aeaa78225 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -58,6 +58,8 @@ dependencies { api("com.github.cy745.KRouter:core:fcf40f4b15") api("com.cheonjaeung.compose.grid:grid:2.0.0") api("com.github.cy745.RemixIcon-Kmp:core:1a3c554a35") + api("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") + api("com.github.GIGAMOLE:ComposeFadingEdges:1.0.4") // compose // api(platform(libs.compose.bom)) diff --git a/component/src/main/java/com/lalilu/component/extension/StickyHeaderOffsetHelper.kt b/component/src/main/java/com/lalilu/component/extension/StickyHeaderOffsetHelper.kt index 4e15c3343..4ec0d18fd 100644 --- a/component/src/main/java/com/lalilu/component/extension/StickyHeaderOffsetHelper.kt +++ b/component/src/main/java/com/lalilu/component/extension/StickyHeaderOffsetHelper.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.zIndex fun StickyHeaderOffsetHelper( modifier: Modifier = Modifier, key: Any, - minOffset: Int = 0, + minOffset: () -> Int = { 0 }, listState: LazyListState, block: @Composable (Modifier, isFloating: Boolean) -> Unit ) { @@ -38,14 +38,14 @@ fun StickyHeaderOffsetHelper( zIndex.floatValue = index.toFloat() when { - offset > minOffset -> { + offset > minOffset() -> { floating.value = false IntOffset.Zero } else -> { floating.value = true - IntOffset(0, minOffset - offset) + IntOffset(0, minOffset() - offset) } } } From c2b5cbd947e1179c80f777b0cce16d41ef1611da Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 1 Sep 2024 10:21:20 +0800 Subject: [PATCH 081/213] =?UTF-8?q?[refactor]=E8=BD=AC=E7=A7=BBSongs?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6=E8=87=B3component=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E4=BE=9B=E5=85=B6=E4=BB=96=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=85=AC=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/songs/SongsScreen.kt | 6 + .../screen/songs/SongsScreenContent.kt | 161 +------ .../main/java/com/lalilu/component/Songs.kt | 431 ------------------ .../base}/songs/SongsHeaderJumperDialog.kt | 4 +- .../lalilu/component/base}/songs/SongsSM.kt | 12 +- .../base/songs/SongsScreenScrollBar.kt | 46 ++ .../base/songs/SongsScreenStickyHeader.kt | 84 ++++ .../base}/songs/SongsSearcherPanel.kt | 4 +- .../base}/songs/SongsSelectorPanel.kt | 4 +- .../base}/songs/SongsSortPanelDialog.kt | 21 +- 10 files changed, 172 insertions(+), 601 deletions(-) delete mode 100644 component/src/main/java/com/lalilu/component/Songs.kt rename {app/src/main/java/com/lalilu/lmusic/compose/screen => component/src/main/java/com/lalilu/component/base}/songs/SongsHeaderJumperDialog.kt (97%) rename {app/src/main/java/com/lalilu/lmusic/compose/screen => component/src/main/java/com/lalilu/component/base}/songs/SongsSM.kt (96%) create mode 100644 component/src/main/java/com/lalilu/component/base/songs/SongsScreenScrollBar.kt create mode 100644 component/src/main/java/com/lalilu/component/base/songs/SongsScreenStickyHeader.kt rename {app/src/main/java/com/lalilu/lmusic/compose/screen => component/src/main/java/com/lalilu/component/base}/songs/SongsSearcherPanel.kt (98%) rename {app/src/main/java/com/lalilu/lmusic/compose/screen => component/src/main/java/com/lalilu/component/base}/songs/SongsSelectorPanel.kt (93%) rename {app/src/main/java/com/lalilu/lmusic/compose/screen => component/src/main/java/com/lalilu/component/base}/songs/SongsSortPanelDialog.kt (94%) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 6172569a4..67c9ce9b4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -15,6 +15,12 @@ import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType +import com.lalilu.component.base.songs.SongsHeaderJumperDialog +import com.lalilu.component.base.songs.SongsSM +import com.lalilu.component.base.songs.SongsScreenAction +import com.lalilu.component.base.songs.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSelectorPanel +import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.remixicon.Design diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index ee87f0e58..78bfd9b1c 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -1,50 +1,28 @@ package com.lalilu.lmusic.compose.screen.songs -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.gigamole.composefadingedges.FadingEdgesGravity @@ -54,16 +32,16 @@ import com.gigamole.composefadingedges.fill.FadingEdgesFillType import com.gigamole.composefadingedges.verticalFadingEdges import com.lalilu.common.base.Playable import com.lalilu.component.base.smartBarPadding +import com.lalilu.component.base.songs.SongsSM +import com.lalilu.component.base.songs.SongsScreenEvent +import com.lalilu.component.base.songs.SongsScreenScrollBar +import com.lalilu.component.base.songs.SongsScreenStickyHeader import com.lalilu.component.card.SongCard -import com.lalilu.component.extension.StickyHeaderOffsetHelper import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.flow.collectLatest -import my.nanihadesuka.compose.InternalLazyColumnScrollbar -import my.nanihadesuka.compose.ScrollbarSelectionMode -import my.nanihadesuka.compose.ScrollbarSettings @Composable internal fun SongsScreenContent( @@ -186,16 +164,18 @@ internal fun SongsScreenContent( } songs.forEach { (group, list) -> - stickyHeaderWithRecord( - key = group, - contentType = "group" - ) { - SongsScreenStickyHeader( - listState = listState, - group = group, - minOffset = { statusBar.getTop(density) }, - onClickGroup = onClickGroup - ) + if (group !is GroupIdentity.None) { + stickyHeaderWithRecord( + key = group, + contentType = "group" + ) { + SongsScreenStickyHeader( + listState = listState, + group = group, + minOffset = { statusBar.getTop(density) }, + onClickGroup = onClickGroup + ) + } } itemsWithRecord( @@ -233,113 +213,4 @@ internal fun SongsScreenContent( smartBarPadding() } } -} - -@Composable -fun SongsScreenStickyHeader( - modifier: Modifier = Modifier, - listState: LazyListState, - group: GroupIdentity, - minOffset: () -> Int, - onClickGroup: (GroupIdentity) -> Unit -) { - StickyHeaderOffsetHelper( - modifier = modifier, - key = group, - listState = listState, - minOffset = minOffset, - ) { modifierFromHelper, isFloating -> - Box( - modifier = modifierFromHelper - .padding(horizontal = 12.dp, vertical = 8.dp) - .widthIn(min = 64.dp) - .height(IntrinsicSize.Max) - .clip(RoundedCornerShape(8.dp)) - .clickable { onClickGroup(group) } - .border( - width = 1.dp, - color = MaterialTheme.colors.onBackground.copy(0.1f), - shape = RoundedCornerShape(8.dp) - ) - .background(color = MaterialTheme.colors.background) - ) { - Text( - modifier = Modifier.padding( - horizontal = 16.dp, - vertical = 12.dp - ), - textAlign = TextAlign.Start, - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - lineHeight = 14.sp, - text = group.text - ) - - Spacer( - modifier = Modifier - .align(Alignment.CenterStart) - .fillMaxHeight() - .padding(vertical = 12.dp) - .padding(start = 6.dp) - .width(2.dp) - .clip(RoundedCornerShape(50)) - .drawBehind { drawRect(color = Color(0xFF0088FF)) } - ) - } - } -} - -@Composable -fun SongsScreenScrollBar( - modifier: Modifier = Modifier, - listState: LazyListState, - content: @Composable () -> Unit -) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - content() - - InternalLazyColumnScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight(0.5f), - state = listState, - settings = ScrollbarSettings( - alwaysShowScrollbar = true, - scrollbarPadding = 4.dp, - thumbMinLength = 0.2f, - selectionMode = ScrollbarSelectionMode.Full, - thumbUnselectedColor = MaterialTheme.colors.onBackground.copy(0.2f), - thumbSelectedColor = MaterialTheme.colors.onBackground.copy(0.8f), - ), - indicatorContent = { index, isThumbSelected -> - AnimatedVisibility( - modifier = Modifier.offset(x = (-20).dp), - enter = fadeIn() + scaleIn(initialScale = 0.5f), - exit = fadeOut() + scaleOut(targetScale = 0.5f), - visible = isThumbSelected - ) { - Text( - modifier = Modifier - .widthIn(min = 100.dp) - .clip(RoundedCornerShape(8.dp)) - .border( - width = 1.dp, - color = MaterialTheme.colors.onBackground.copy(0.1f), - shape = RoundedCornerShape(8.dp) - ) - .background(color = MaterialTheme.colors.background) - .padding(horizontal = 16.dp, vertical = 16.dp), - textAlign = TextAlign.End, - text = "$index", - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - lineHeight = 14.sp - ) - } - } - ) - } } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/Songs.kt b/component/src/main/java/com/lalilu/component/Songs.kt deleted file mode 100644 index d4742b4c8..000000000 --- a/component/src/main/java/com/lalilu/component/Songs.kt +++ /dev/null @@ -1,431 +0,0 @@ -package com.lalilu.component - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -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.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Chip -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.rememberScreenModel -import cafe.adriel.voyager.core.screen.Screen -import com.blankj.utilcode.util.ToastUtils -import com.lalilu.common.base.Playable -import com.lalilu.component.base.screen.ScreenBarFactory -import com.lalilu.component.extension.DialogItem -import com.lalilu.component.extension.DialogWrapper -import com.lalilu.component.extension.ItemSelectHelper -import com.lalilu.component.extension.LazyListScrollToHelper -import com.lalilu.component.extension.SelectAction -import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.rememberItemSelectHelper -import com.lalilu.component.extension.rememberLazyListScrollToHelper -import com.lalilu.component.extension.singleViewModel -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.component.viewmodel.SongsViewModel -import com.lalilu.lmedia.extension.GroupIdentity -import com.lalilu.lmedia.extension.ListAction -import com.lalilu.lmedia.extension.Sortable - -class SongsScreenModel : ScreenModel { - val isFastJumping = mutableStateOf(false) - val isSelecting = mutableStateOf(false) - val selectedItems = mutableStateOf>(emptyList()) - val showSortPanel = mutableStateOf(false) -} - -@Composable -fun DefaultEmptyContent() { - Box( - modifier = Modifier - .fillMaxSize() - .heightIn(min = 200.dp), - contentAlignment = Alignment.Center - ) { - Text( - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - text = stringResource(R.string.empty_screen_no_items).uppercase(), - color = dayNightTextColor() - ) - } -} - -@Deprecated("耦合度过高,待重新实现") -@Composable -fun Screen.Songs( - modifier: Modifier = Modifier, - mediaIds: List, - showAll: Boolean = false, - sortFor: String = Sortable.SORT_FOR_SONGS, - listState: LazyListState = rememberLazyListState(), - supportListAction: () -> List, - selectActions: (getAll: () -> List) -> List = { emptyList() }, - scrollToHelper: LazyListScrollToHelper = rememberLazyListScrollToHelper(listState), - songsSM: SongsScreenModel = rememberScreenModel { SongsScreenModel() }, - showPrefixContent: (sortRuleStr: State) -> Boolean = { false }, - onDragMoveEnd: ((List) -> Unit)? = null, - emptyContent: @Composable () -> Unit = { DefaultEmptyContent() }, - prefixContent: @Composable (item: Playable, sortRuleStr: State) -> Unit = { _, _ -> }, - headerContent: LazyListScope.(State>>) -> Unit = {}, - footerContent: LazyListScope.(State>>) -> Unit = {} -) { - val songsVM: SongsViewModel = singleViewModel() - val playingVM: IPlayingViewModel = singleViewModel() - val songsState = songsVM.output - - LaunchedEffect(mediaIds) { - songsVM.updateByIds( - songIds = mediaIds, - sortFor = sortFor, - showAll = showAll, - supportSortRules = supportListAction(), - ) - } - - val selectorHelper = rememberItemSelectHelper( - isSelecting = songsSM.isSelecting, - selected = songsSM.selectedItems - ) - -// val sortRuleStr = registerSortPanel( -// sp = songsVM.sp, -// sortFor = sortFor, -// showPanelState = songsSM.showSortPanel, -// supportListAction = supportListAction, -// ) - - registerGroupLabelJumper( - items = { songsState.value.keys }, - scrollToHelper = scrollToHelper, - isVisible = songsSM.isFastJumping - ) - - if (this is ScreenBarFactory) { - registerSelectPanel( - selectActions = { selectActions { songsState.value.values.flatten() } }, - selector = selectorHelper - ) - } - - if (onDragMoveEnd != null) { -// ReorderableSongListWrapper( -// modifier = modifier, -// items = songsState.value.values.flatten(), -// listState = listState, -// onDragMoveEnd = onDragMoveEnd, -// scrollToHelper = { scrollToHelper }, -// itemSelectHelper = { selectorHelper }, -// hasLyric = { playingVM.requireHasLyric(it)[it.mediaId] ?: false }, -// isFavourite = { playingVM.isFavourite(it) }, -// isItemPlaying = { playingVM.isItemPlaying(it.mediaId, Playable::mediaId) }, -//// showPrefixContent = { showPrefixContent(sortRuleStr) }, -// headerContent = { headerContent(songsState) }, -// footerContent = { footerContent(songsState) }, -// emptyContent = emptyContent, -//// prefixContent = { prefixContent(it, sortRuleStr) }, -// onLongClickItem = { -// AppRouter.route("/pages/songs/detail") -// .with("mediaId", it.mediaId) -// .push() -// }, -// onClickItem = { -// playingVM.play( -// mediaId = it.mediaId, -// mediaIds = songsState.value.values.flatten().map(Playable::mediaId), -// playOrPause = true -// ) -// }, -// ) - } else { -// SongListWrapper( -// modifier = modifier, -// state = listState, -// itemsMap = songsState.value, -// idMapper = { -// when { -// it is GroupIdentity.Time -> it.time -// it is GroupIdentity.FirstLetter -> it.letter -// it is GroupIdentity.DiskNumber && it.number > 0 -> it.number.toString() -// else -> "" -// } -// }, -// scrollToHelper = { scrollToHelper }, -// itemSelectHelper = { selectorHelper }, -// hasLyric = { playingVM.requireHasLyric(it)[it.mediaId] ?: false }, -// isFavourite = { playingVM.isFavourite(it) }, -// isItemPlaying = { playingVM.isItemPlaying(it.mediaId, Playable::mediaId) }, -// onHeaderClick = { songsSM.isFastJumping.value = true }, -//// showPrefixContent = { showPrefixContent(sortRuleStr) }, -// headerContent = { headerContent(songsState) }, -// footerContent = { footerContent(songsState) }, -// emptyContent = emptyContent, -//// prefixContent = { prefixContent(it, sortRuleStr) }, -// onLongClickItem = { -// AppRouter.route("/pages/songs/detail") -// .with("mediaId", it.mediaId) -// .push() -// }, -// onClickItem = { -// playingVM.play( -// mediaId = it.mediaId, -// playOrPause = true, -// addToNext = true -// ) -// }, -// onDoubleClickItem = { -// playingVM.play( -// mediaId = it.mediaId, -// mediaIds = songsState.value.values.flatten().map(Playable::mediaId), -// playOrPause = true -// ) -// }, -// ) - } -} - - -//@Composable -//private fun registerSortPanel( -// sortFor: String, -// sp: BaseSp, -// showPanelState: MutableState, -// supportListAction: () -> List -//): State { -// val sortRule = sp.obtain("${sortFor}_SORT_RULE", SortStaticAction.Normal::class.java.name) -// val reverseOrder = sp.obtain("${sortFor}_SORT_RULE_REVERSE_ORDER", false) -// val flattenOverride = sp.obtain("${sortFor}_SORT_RULE_FLATTEN_OVERRIDE", false) -// -// val dialog = remember { -// DialogItem.Dynamic(backgroundColor = Color.Transparent) { -// SortPanel( -// sortRule = sortRule, -// reverseOrder = reverseOrder, -// flattenOverride = flattenOverride, -// supportListAction = supportListAction, -// onClose = { showPanelState.value = false } -// ) -// } -// } -// -// DialogWrapper.register(isVisible = showPanelState, dialogItem = dialog) -// return sortRule -//} - - -@Composable -fun ScreenBarFactory.registerSelectPanel( - modifier: Modifier = Modifier, - selector: ItemSelectHelper = rememberItemSelectHelper(), - selectActions: () -> List = { emptyList() } -) { - RegisterContent( - isVisible = selector.isSelecting, - onBackPressed = { selector.clear() } - ) { - Row( - modifier = modifier - .clickable(enabled = false) {} - .height(52.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(start = 16.dp, end = 24.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = Color(0x2F006E7C), - contentColor = Color(0xFF006E7C) - ), - onClick = { selector.clear() } - ) { - Image( - painter = painterResource(id = R.drawable.ic_close_line), - contentDescription = "cancelButton", - colorFilter = ColorFilter.tint(color = Color(0xFF006E7C)) - ) - Text( - text = "取消 [${selector.selected.value.size}]", - fontSize = 14.sp - ) - } - - LazyRow( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.End - ) { - items(items = selectActions()) { - if (it is SelectAction.ComposeAction) { - it.content.invoke(selector) - return@items - } - - if (it is SelectAction.StaticAction) { - LongClickableTextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = 20.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = it.color.copy(alpha = 0.15f), - contentColor = it.color - ), - enableLongClickMask = it.forLongClick, - onLongClick = { if (it.forLongClick) it.onAction(selector) }, - onClick = { - if (it.forLongClick) { - ToastUtils.showShort("请长按此按钮以继续") - } else { - it.onAction(selector) - } - }, - ) { - it.icon?.let { icon -> - Image( - modifier = Modifier.size(20.dp), - painter = painterResource(id = icon), - contentDescription = stringResource(id = it.title), - colorFilter = ColorFilter.tint(color = it.color) - ) - Spacer(modifier = Modifier.width(6.dp)) - } - Text( - text = stringResource(id = it.title), - fontSize = 14.sp - ) - } - } - } - } - } - } -} - - -@Composable -private fun registerGroupLabelJumper( - items: () -> Collection, - scrollToHelper: LazyListScrollToHelper, - isVisible: MutableState -) { - val statusBarPadding = WindowInsets.statusBars.asPaddingValues() - val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues() - - val dialog = remember { - DialogItem.Dynamic(backgroundColor = Color.Transparent) { - val charMapping = remember { - items().filter { it.text.isNotBlank() } - .groupBy { it.text[0].category } - } - - val paddingValues = remember { - val topDp = statusBarPadding.calculateTopPadding() - val bottomDp = navigationBarPadding.calculateBottomPadding() - PaddingValues(top = topDp, bottom = bottomDp) - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = paddingValues - ) { - charMapping.forEach { - charCategoryMapping( - category = it.key, - items = it.value, - onClick = { key -> - scrollToHelper.scrollToItem(key) - isVisible.value = false - } - ) - } - } - } - } - - DialogWrapper.register(isVisible = isVisible, dialogItem = dialog) -} - -@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) -private fun LazyListScope.charCategoryMapping( - category: CharCategory, - items: Collection, - onClick: (GroupIdentity) -> Unit = {} -) { - item { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - style = MaterialTheme.typography.h6, - color = Color.White, - text = category.name - // TODO 需要为CharCategory设置i18n转换 - ) - } - - item { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.Start) - ) { - // TODO 需要为时间类型的分组使用日历组件,方便查找 - items.forEach { key -> - Chip( - modifier = Modifier, - onClick = { onClick(key) } - ) { - Text( - style = MaterialTheme.typography.h6, - text = key.text - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt similarity index 97% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt rename to component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt index 0b98e004e..d0da4c533 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsHeaderJumperDialog.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.screen.songs +package com.lalilu.component.base.songs import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -32,7 +32,7 @@ import com.lalilu.component.extension.DialogWrapper import com.lalilu.lmedia.extension.GroupIdentity @Composable -internal fun SongsHeaderJumperDialog( +fun SongsHeaderJumperDialog( isVisible: MutableState, items: () -> Collection, onSelectItem: (item: GroupIdentity) -> Unit = {} diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt similarity index 96% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt rename to component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt index 304c1f59a..b5e999597 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSM.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.screen.songs +package com.lalilu.component.base.songs import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -40,18 +40,18 @@ import kotlinx.coroutines.launch import org.koin.core.qualifier.named import org.koin.java.KoinJavaComponent.inject -internal sealed interface SongsScreenAction { +sealed interface SongsScreenAction { data object ToggleSortPanel : SongsScreenAction data object LocaleToPlayingItem : SongsScreenAction data class LocaleToGroupItem(val item: GroupIdentity) : SongsScreenAction data class SearchFor(val keyword: String) : SongsScreenAction } -internal sealed interface SongsScreenEvent { +sealed interface SongsScreenEvent { data class ScrollToItem(val index: Int) : SongsScreenEvent } -internal class SongsSM( +class SongsSM( private val mediaIds: List, ) : ScreenModel { // 持久化元素的状态 @@ -124,7 +124,7 @@ internal class SongsSM( val recorder = ItemRecorder() } -internal class ItemSearcher( +class ItemSearcher( sourceFlow: Flow> ) { val keywordState = mutableStateOf("") @@ -147,7 +147,7 @@ internal class ItemSearcher( } @OptIn(ExperimentalCoroutinesApi::class) -internal class ItemSorter( +class ItemSorter( sourceFlow: Flow>, private val supportSortActions: Set, ) { diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsScreenScrollBar.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsScreenScrollBar.kt new file mode 100644 index 000000000..0ca404007 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsScreenScrollBar.kt @@ -0,0 +1,46 @@ +package com.lalilu.component.base.songs + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import my.nanihadesuka.compose.InternalLazyColumnScrollbar +import my.nanihadesuka.compose.ScrollbarSelectionMode +import my.nanihadesuka.compose.ScrollbarSettings + + +@Composable +fun SongsScreenScrollBar( + modifier: Modifier = Modifier, + listState: LazyListState, + content: @Composable () -> Unit +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + content() + + InternalLazyColumnScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(0.5f), + state = listState, + settings = ScrollbarSettings( + alwaysShowScrollbar = true, + scrollbarPadding = 4.dp, + thumbMinLength = 0.2f, + thumbShape = RectangleShape, + selectionMode = ScrollbarSelectionMode.Full, + thumbUnselectedColor = MaterialTheme.colors.onBackground.copy(0.4f), + thumbSelectedColor = MaterialTheme.colors.onBackground.copy(0.8f), + ) + ) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsScreenStickyHeader.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsScreenStickyHeader.kt new file mode 100644 index 000000000..07bf05cbd --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsScreenStickyHeader.kt @@ -0,0 +1,84 @@ +package com.lalilu.component.base.songs + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.extension.StickyHeaderOffsetHelper +import com.lalilu.lmedia.extension.GroupIdentity + + +@Composable +fun SongsScreenStickyHeader( + modifier: Modifier = Modifier, + listState: LazyListState, + group: GroupIdentity, + minOffset: () -> Int, + onClickGroup: (GroupIdentity) -> Unit +) { + StickyHeaderOffsetHelper( + modifier = modifier, + key = group, + listState = listState, + minOffset = minOffset, + ) { modifierFromHelper, isFloating -> + Box( + modifier = modifierFromHelper + .padding(horizontal = 12.dp, vertical = 8.dp) + .widthIn(min = 64.dp) + .height(IntrinsicSize.Max) + .clip(RoundedCornerShape(8.dp)) + .clickable { onClickGroup(group) } + .border( + width = 1.dp, + color = MaterialTheme.colors.onBackground.copy(0.1f), + shape = RoundedCornerShape(8.dp) + ) + .background(color = MaterialTheme.colors.background) + ) { + Text( + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 12.dp + ), + textAlign = TextAlign.Start, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 14.sp, + text = group.text + ) + + Spacer( + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxHeight() + .padding(vertical = 12.dp) + .padding(start = 6.dp) + .width(2.dp) + .clip(RoundedCornerShape(50)) + .drawBehind { drawRect(color = Color(0xFF0088FF)) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSearcherPanel.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt similarity index 98% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSearcherPanel.kt rename to component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt index 5de4322b5..85eb7bc30 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSearcherPanel.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.screen.songs +package com.lalilu.component.base.songs import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedVisibility @@ -48,7 +48,7 @@ import com.lalilu.remixicon.arrows.arrowLeftSLine import com.lalilu.remixicon.system.closeLine @Composable -internal fun ScreenBarFactory.SongsSearcherPanel( +fun ScreenBarFactory.SongsSearcherPanel( isVisible: MutableState, keyword: () -> String, onUpdateKeyword: (String) -> Unit diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSelectorPanel.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt similarity index 93% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSelectorPanel.kt rename to component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt index 62db08960..3cd61eaef 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSelectorPanel.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.screen.songs +package com.lalilu.component.base.songs import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -15,7 +15,7 @@ import com.lalilu.remixicon.system.closeLine @Composable -internal fun ScreenBarFactory.SongsSelectorPanel( +fun ScreenBarFactory.SongsSelectorPanel( isVisible: MutableState, screenActions: List? = null, ) { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt similarity index 94% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt rename to component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt index ae4038bba..db1ce7706 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsSortPanelDialog.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.screen.songs +package com.lalilu.component.base.songs import android.content.res.Configuration import androidx.compose.foundation.BorderStroke @@ -39,11 +39,10 @@ import com.lalilu.component.extension.DialogItem import com.lalilu.component.extension.DialogWrapper import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lmusic.LMusicTheme @Composable -internal fun SongsSortPanelDialog( +fun SongsSortPanelDialog( isVisible: MutableState, supportSortActions: Set, isSortActionSelected: (ListAction) -> Boolean = { false }, @@ -188,11 +187,9 @@ private fun SortItem( ) @Composable private fun SongsSortPanelDialogPVDay() { - LMusicTheme { - SongsSortPanelDialogContent( - supportSortActions = setOf(SortStaticAction.Normal) - ) - } + SongsSortPanelDialogContent( + supportSortActions = setOf(SortStaticAction.Normal) + ) } @Preview( @@ -202,9 +199,7 @@ private fun SongsSortPanelDialogPVDay() { ) @Composable private fun SongsSortPanelDialogPV() { - LMusicTheme { - SongsSortPanelDialogContent( - supportSortActions = setOf(SortStaticAction.Normal) - ) - } + SongsSortPanelDialogContent( + supportSortActions = setOf(SortStaticAction.Normal) + ) } \ No newline at end of file From 026efcaba0995eb05b64b74120f52617ca675eb1 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Wed, 4 Sep 2024 18:20:55 +0800 Subject: [PATCH 082/213] =?UTF-8?q?[refactor]=E5=BC=95=E5=85=A5media3?= =?UTF-8?q?=E5=92=8Cexoplayer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lplayer/build.gradle.kts | 3 + lplayer/src/main/AndroidManifest.xml | 22 +++++++ .../com/lalilu/lplayer/service/MService.kt | 59 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index dd5347f69..2c3053acc 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -28,4 +28,7 @@ android { dependencies { implementation(project(":common")) implementation("com.github.cy745:AndroidVideoCache:2.7.2") + + implementation("androidx.media3:media3-exoplayer:1.4.1") + implementation("androidx.media3:media3-session:1.4.1") } \ No newline at end of file diff --git a/lplayer/src/main/AndroidManifest.xml b/lplayer/src/main/AndroidManifest.xml index a5918e68a..6719312ec 100644 --- a/lplayer/src/main/AndroidManifest.xml +++ b/lplayer/src/main/AndroidManifest.xml @@ -1,4 +1,26 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt new file mode 100644 index 000000000..f9875bad6 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -0,0 +1,59 @@ +package com.lalilu.lplayer.service + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +class MService : MediaLibraryService(), + MediaLibrarySession.Callback, + CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() + + private var exoPlayer: ExoPlayer? = null + private var mediaSession: MediaLibrarySession? = null + + override fun onCreate() { + super.onCreate() + + exoPlayer = ExoPlayer + .Builder(this) + .build() + + mediaSession = MediaLibrarySession + .Builder(this, exoPlayer!!, this) + .build() + } + + override fun onDestroy() { + // 释放相关实例 + exoPlayer?.stop() + exoPlayer?.release() + exoPlayer = null + mediaSession?.release() + mediaSession = null + super.onDestroy() + } + + override fun onGetSession( + controllerInfo: MediaSession.ControllerInfo + ): MediaLibrarySession? { + return mediaSession + } + + @OptIn(UnstableApi::class) + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo + ): ListenableFuture { + // TODO 待完成继续播放的逻辑 + return super.onPlaybackResumption(mediaSession, controller) + } +} \ No newline at end of file From 003f0e73fc7db685d09d609df4b5f6ff3c4caa1a Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 7 Sep 2024 15:56:57 +0800 Subject: [PATCH 083/213] =?UTF-8?q?[refactor]=E6=96=B0=E5=A2=9ELazyGridCon?= =?UTF-8?q?tent=E7=94=A8=E4=BA=8E=E5=B0=81=E8=A3=85=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E7=9A=84=E7=BB=84=E4=BB=B6=E7=BB=93=E6=9E=84=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4ScreenInfo=E7=9A=84=E5=8F=82=E6=95=B0=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E8=B5=8B=E4=BA=88=E6=9B=B4=E5=A4=9A=E7=81=B5?= =?UTF-8?q?=E6=B4=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../main/java/com/lalilu/lmusic/AppModule.kt | 10 +- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 1 + .../lmusic/compose/LayoutWrapperContent.kt | 2 +- .../compose/new_screen/SearchLyricScreen.kt | 3 +- .../compose/new_screen/SettingsScreen.kt | 7 +- .../new_screen/detail/SongDetailScreen.kt | 4 +- .../compose/new_screen/home/HomeScreen.kt | 74 ------ .../compose/new_screen/search/SearchScreen.kt | 8 +- .../lmusic/compose/screen/home/HomeScreen.kt | 31 +++ .../compose/screen/home/HomeScreenContent.kt | 48 ++++ .../compose/screen/songs/SongsScreen.kt | 6 +- .../lalilu/lmusic/extension/DailyRecommend.kt | 235 ++++++++---------- .../com/lalilu/lmusic/extension/EntryPanel.kt | 124 +++++---- .../lalilu/lmusic/extension/HistoryPanel.kt | 116 +++++---- .../lalilu/lmusic/extension/LatestPanel.kt | 107 ++++---- .../lmusic/viewmodel/HistoryViewModel.kt | 14 +- .../lmusic/viewmodel/LibraryViewModel.kt | 9 +- .../com/lalilu/component/LazyGridContent.kt | 80 ++++++ .../base/screen/ScreenInfoFactory.kt | 7 +- .../component/navigation/NavigateTabBar.kt | 36 +-- .../navigation/NavigationSmartBar.kt | 6 +- .../lalilu/lalbum/screen/AlbumDetailScreen.kt | 5 +- .../com/lalilu/lalbum/screen/AlbumsScreen.kt | 9 +- .../lalilu/lplaylist/screen/PlaylistScreen.kt | 8 +- .../screen/add/PlaylistAddToScreen.kt | 4 +- .../screen/detail/PlaylistDetailScreen.kt | 5 +- 27 files changed, 558 insertions(+), 402 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/new_screen/home/HomeScreen.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreen.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt create mode 100644 component/src/main/java/com/lalilu/component/LazyGridContent.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e0eb33cb..9aed9aa34 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -173,6 +173,7 @@ dependencies { implementation(project(":lartist")) implementation(project(":lalbum")) implementation(project(":ldictionary")) + ksp(libs.koin.compiler) implementation(libs.room.ktx) implementation(libs.room.runtime) diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index 0ba60c1c4..d91c18ef6 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -34,8 +34,6 @@ import com.lalilu.lmusic.utils.coil.keyer.PlayableKeyer import com.lalilu.lmusic.utils.coil.keyer.SongCoverKeyer import com.lalilu.lmusic.utils.coil.mapper.LSongMapper import com.lalilu.lmusic.utils.extension.toBitmap -import com.lalilu.lmusic.viewmodel.HistoryViewModel -import com.lalilu.lmusic.viewmodel.LibraryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchLyricViewModel import com.lalilu.lmusic.viewmodel.SearchViewModel @@ -47,12 +45,18 @@ import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.module import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.net.URLDecoder +@Module +@ComponentScan("com.lalilu.lmusic") +object MainModule + val AppModule = module { single { androidApplication() as ViewModelStoreOwner } single { @@ -101,9 +105,7 @@ val ViewModelModule = module { viewModelOf(::SearchViewModel) viewModelOf(::AlbumsViewModel) viewModelOf(::ArtistsViewModel) - viewModelOf(::HistoryViewModel) viewModelOf(::SearchLyricViewModel) - viewModelOf(::LibraryViewModel) } val RuntimeModule = module { diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 97e525217..7a764a2b2 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -54,6 +54,7 @@ class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, V startKoin { androidContext(this@LMusicApp) modules( + MainModule.module, AppModule, ApiModule, ViewModelModule, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt index d4bc02434..be5c36383 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt @@ -41,7 +41,7 @@ import com.lalilu.component.extension.DynamicTipsHost import com.lalilu.component.navigation.CustomTransition import com.lalilu.component.navigation.HostNavigator import com.lalilu.component.navigation.NavigationSmartBar -import com.lalilu.lmusic.compose.new_screen.home.HomeScreen +import com.lalilu.lmusic.compose.screen.home.HomeScreen import com.lalilu.lmusic.compose.screen.playing.PlayingLayout import com.lalilu.lmusic.compose.screen.playing.PlayingLayoutExpended import com.lalilu.lmusic.compose.screen.playing.PlayingSmartCard diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt index f83ec341a..0f7a81eb4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em @@ -61,7 +62,7 @@ data class SearchLyricScreen( @Composable override fun provideScreenInfo(): ScreenInfo = remember { ScreenInfo( - title = R.string.preference_lyric_settings + title = { stringResource(id = R.string.preference_lyric_settings) } ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt index 9f210b2f5..3ed1f5e3a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt @@ -31,6 +31,7 @@ import com.blankj.utilcode.util.ToastUtils import com.google.accompanist.flowlayout.FlowRow import com.lalilu.BuildConfig import com.lalilu.R +import com.lalilu.RemixIcon import com.lalilu.common.CustomRomUtils import com.lalilu.component.IconTextButton import com.lalilu.component.LLazyColumn @@ -49,6 +50,8 @@ import com.lalilu.lmusic.GuidingActivity import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.utils.EQHelper import com.lalilu.lmusic.utils.extension.getActivity +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.settings4Line import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -60,8 +63,8 @@ object SettingsScreen : Screen, ScreenInfoFactory { @Composable override fun provideScreenInfo(): ScreenInfo = remember { ScreenInfo( - title = R.string.screen_title_settings, - icon = R.drawable.ic_settings_4_line + title = { stringResource(id = R.string.screen_title_settings) }, + icon = RemixIcon.System.settings4Line, ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index 4a386d088..0412859a5 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -39,7 +39,9 @@ data class SongDetailScreen( @Composable override fun provideScreenInfo(): ScreenInfo = remember { - ScreenInfo(title = R.string.screen_title_song_detail) + ScreenInfo( + title = { stringResource(id = R.string.screen_title_song_detail) } + ) } @Composable diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/home/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/home/HomeScreen.kt deleted file mode 100644 index 427a3b5d4..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/home/HomeScreen.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.lalilu.lmusic.compose.new_screen.home - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsTopHeight -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import cafe.adriel.voyager.core.screen.Screen -import com.lalilu.R -import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.TabScreen -import com.lalilu.component.base.screen.ScreenInfo -import com.lalilu.component.extension.singleViewModel -import com.lalilu.lmusic.extension.EntryPanel -import com.lalilu.lmusic.extension.dailyRecommend -import com.lalilu.lmusic.extension.historyPanel -import com.lalilu.lmusic.extension.latestPanel -import com.lalilu.lmusic.viewmodel.HistoryViewModel -import com.lalilu.lmusic.viewmodel.LibraryViewModel -import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.zhangke.krouter.annotation.Destination - -@Destination("/pages/home") -object HomeScreen : TabScreen, Screen { - private fun readResolve(): Any = HomeScreen - - @Composable - override fun provideScreenInfo(): ScreenInfo = remember { - ScreenInfo( - title = R.string.screen_title_home, - icon = R.drawable.ic_loader_line - ) - } - - @Composable - override fun Content() { - val libraryVM: LibraryViewModel = singleViewModel() - val historyVM: HistoryViewModel = singleViewModel() - val playingVM: PlayingViewModel = singleViewModel() - - LaunchedEffect(Unit) { - libraryVM.checkOrUpdateToday() - } - - LLazyColumn(modifier = Modifier.fillMaxSize()) { - item { - Spacer( - modifier = Modifier - .windowInsetsTopHeight(WindowInsets.statusBars) - ) - } - - dailyRecommend(libraryVM = libraryVM) - - latestPanel( - libraryVM = libraryVM, - playingVM = playingVM - ) - - historyPanel( - historyVM = historyVM, - playingVM = playingVM - ) - - item { - EntryPanel() - } - } - } -} diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt index 7d34a4424..475694134 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt @@ -22,10 +22,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.KeyboardUtils import com.lalilu.R +import com.lalilu.RemixIcon import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo @@ -35,6 +37,8 @@ import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.utils.extension.getActivity import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchViewModel +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.search2Line import com.zhangke.krouter.annotation.Destination @Destination("/pages/search") @@ -44,8 +48,8 @@ object SearchScreen : Screen, TabScreen, ScreenBarFactory { @Composable override fun provideScreenInfo(): ScreenInfo = remember { ScreenInfo( - title = R.string.screen_title_search, - icon = R.drawable.ic_search_2_line + title = { stringResource(id = R.string.screen_title_search) }, + icon = RemixIcon.System.search2Line, ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreen.kt new file mode 100644 index 000000000..24d65c9af --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreen.kt @@ -0,0 +1,31 @@ +package com.lalilu.lmusic.compose.screen.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.screen.Screen +import com.lalilu.R +import com.lalilu.RemixIcon +import com.lalilu.component.base.TabScreen +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.loaderLine +import com.zhangke.krouter.annotation.Destination + +@Destination("/pages/home") +object HomeScreen : TabScreen, Screen { + private fun readResolve(): Any = HomeScreen + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.screen_title_home) }, + icon = RemixIcon.System.loaderLine, + ) + } + + @Composable + override fun Content() { + HomeScreenContent() + } +} diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt new file mode 100644 index 000000000..6ff2633f4 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt @@ -0,0 +1,48 @@ +package com.lalilu.lmusic.compose.screen.home + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lalilu.component.base.LocalSmartBarPadding +import com.lalilu.component.divider +import com.lalilu.lmusic.extension.DailyRecommend +import com.lalilu.lmusic.extension.EntryPanel +import com.lalilu.lmusic.extension.HistoryPanel +import com.lalilu.lmusic.extension.LatestPanel + +@Composable +fun HomeScreenContent( + modifier: Modifier = Modifier, +) { + val padding by LocalSmartBarPadding.current + + val dailyRecommend = DailyRecommend.register() + val entryPanel = EntryPanel.register() + val historyPanel = HistoryPanel.register() + val latestPanel = LatestPanel.register() + + LazyVerticalGrid( + modifier = modifier, + columns = GridCells.Fixed(12), + contentPadding = WindowInsets.systemBars.asPaddingValues() + ) { + dailyRecommend(this) + + latestPanel(this) + + historyPanel(this) + + entryPanel(this) + + divider { + it.height(padding.calculateBottomPadding() + 16.dp) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 67c9ce9b4..a24840845 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -25,10 +25,12 @@ import com.lalilu.component.extension.DialogWrapper import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.remixicon.Design import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.Media import com.lalilu.remixicon.System import com.lalilu.remixicon.design.editBoxLine import com.lalilu.remixicon.design.focus3Line import com.lalilu.remixicon.editor.sortDesc +import com.lalilu.remixicon.media.music2Line import com.lalilu.remixicon.system.checkboxMultipleBlankLine import com.lalilu.remixicon.system.checkboxMultipleLine import com.lalilu.remixicon.system.menuSearchLine @@ -45,8 +47,8 @@ data class SongsScreen( @Composable override fun provideScreenInfo(): ScreenInfo = remember { ScreenInfo( - title = R.string.screen_title_songs, - icon = R.drawable.ic_music_2_line + title = { stringResource(id = R.string.screen_title_songs) }, + icon = RemixIcon.Media.music2Line, ) } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index 2c11b3b34..6244657ea 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -1,28 +1,24 @@ package com.lalilu.lmusic.extension -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -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.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.material.Chip import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.lalilu.component.LazyGridContent import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent @@ -31,73 +27,63 @@ import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.compose.screen.songs.SongsScreen import com.lalilu.lmusic.viewmodel.LibraryViewModel +import org.koin.compose.koinInject -@OptIn(ExperimentalMaterialApi::class) -fun LazyListScope.dailyRecommend( - libraryVM: LibraryViewModel, -) { - item { - RecommendTitle( - modifier = Modifier.padding(vertical = 8.dp), - title = "每日推荐", - onClick = { - val ids = libraryVM.dailyRecommends.value.map { it.mediaId } - AppRouter.intent(NavIntent.Push(SongsScreen(mediaIds = ids))) - } - ) { - Chip(onClick = { libraryVM.forceUpdate() }) { - Text( - style = MaterialTheme.typography.caption, - text = "换一换" - ) - } - } - } - item { - AnimatedContent( - targetState = LocalWindowSize.current.widthSizeClass, - label = "" - ) { windowWidthSizeClass -> - when (windowWidthSizeClass) { - WindowWidthSizeClass.Medium -> RecommendRowForSizeMedium(libraryVM) - WindowWidthSizeClass.Expanded -> RecommendRowForSizeExpanded(libraryVM) - else -> RecommendRow( - items = { libraryVM.dailyRecommends.value }, - getId = { it.id } +object DailyRecommend : LazyGridContent { + + @OptIn(ExperimentalMaterialApi::class) + @Composable + override fun register(): LazyGridScope.() -> Unit { + val libraryVM: LibraryViewModel = koinInject() + val windowWidthClass = LocalWindowSize.current.widthSizeClass + + return fun LazyGridScope.() { + item( + key = "daily_recommend_header", + contentType = "daily_recommend_header", + span = { GridItemSpan(maxLineSpan) } + ) { + RecommendTitle( + modifier = Modifier.padding(vertical = 8.dp), + title = "每日推荐", + onClick = { + val ids = libraryVM.dailyRecommends.value.map { it.mediaId } + AppRouter.intent(NavIntent.Push(SongsScreen(mediaIds = ids))) + } ) { - RecommendCard2( - item = { it }, - modifier = Modifier.size(width = 250.dp, height = 250.dp), - onClick = { - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.id) - .jump() - } - ) + Chip(onClick = { libraryVM.forceUpdate() }) { + Text( + style = MaterialTheme.typography.caption, + text = "换一换" + ) + } } } + + when (windowWidthClass) { + WindowWidthSizeClass.Compact -> dailyRecommendForSideCompat() + WindowWidthSizeClass.Medium -> dailyRecommendForSideMedium() + WindowWidthSizeClass.Expanded -> dailyRecommendForSideExpanded(libraryVM) + } } } } - -@Composable -fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { - val items by remember { derivedStateOf { libraryVM.dailyRecommends.value.take(3) } } - - Row( - modifier = Modifier - .height(250.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) +fun LazyGridScope.dailyRecommendForSideCompat() { + item( + key = "daily_recommend", + contentType = "daily_recommend", + span = { GridItemSpan(maxLineSpan) } ) { - items.getOrNull(0)?.let { + val libraryVM: LibraryViewModel = koinInject() + + RecommendRow( + items = { libraryVM.dailyRecommends.value }, + getId = { it.id } + ) { RecommendCard2( item = { it }, - modifier = Modifier - .fillMaxHeight() - .weight(1f), + modifier = Modifier.size(width = 250.dp, height = 250.dp), onClick = { AppRouter.route("/pages/songs/detail") .with("mediaId", it.id) @@ -105,97 +91,82 @@ fun RecommendRowForSizeMedium(libraryVM: LibraryViewModel) { } ) } + } +} + +fun LazyGridScope.dailyRecommendForSideMedium() { + dailyRecommendForSideCompat() +} - items.getOrNull(1)?.let { +fun LazyGridScope.dailyRecommendForSideExpanded( + libraryVM: LibraryViewModel +) { + item( + key = "daily_recommend_left", + contentType = "daily_recommend_left", + span = { GridItemSpan(8) } + ) { + val item = libraryVM.dailyRecommends.value.getOrNull(0) + ?: return@item + + Row( + modifier = Modifier + .height(250.dp) + .fillMaxWidth() + .padding(start = 16.dp) + ) { RecommendCard2( - item = { it }, - modifier = Modifier - .width(150.dp) - .fillMaxHeight(), + item = { item }, + modifier = Modifier.fillMaxSize(), onClick = { AppRouter.route("/pages/songs/detail") - .with("mediaId", it.id) + .with("mediaId", item.id) .jump() } ) } + } + + item( + key = "daily_recommend_right", + contentType = "daily_recommend_right", + span = { GridItemSpan(4) } + ) { + val item = libraryVM.dailyRecommends.value.getOrNull(1) + ?: return@item + val item2 = libraryVM.dailyRecommends.value.getOrNull(2) + ?: return@item - items.getOrNull(2)?.let { + Column( + modifier = Modifier + .height(250.dp) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { RecommendCard2( - item = { it }, + item = { item }, modifier = Modifier - .width(150.dp) - .fillMaxHeight(), + .weight(1f) + .fillMaxWidth(), onClick = { AppRouter.route("/pages/songs/detail") - .with("mediaId", it.id) + .with("mediaId", item.id) .jump() } ) - } - } -} -@Composable -fun RecommendRowForSizeExpanded(libraryVM: LibraryViewModel) { - val items by remember { derivedStateOf { libraryVM.dailyRecommends.value.take(3) } } - - Row( - modifier = Modifier - .height(250.dp) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - items.getOrNull(0)?.let { RecommendCard2( - item = { it }, + item = { item2 }, modifier = Modifier - .fillMaxHeight() - .weight(1f), + .weight(1f) + .fillMaxWidth(), onClick = { AppRouter.route("/pages/songs/detail") - .with("mediaId", it.id) + .with("mediaId", item2.id) .jump() } ) } - - Column( - modifier = Modifier - .width(275.dp) - .fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - items.getOrNull(1)?.let { - RecommendCard2( - item = { it }, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - onClick = { - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.id) - .jump() - } - ) - } - - items.getOrNull(2)?.let { - RecommendCard2( - item = { it }, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - onClick = { - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.id) - .jump() - } - ) - } - } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt index 0d268a3e6..e3e33d2e7 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt @@ -2,27 +2,32 @@ package com.lalilu.lmusic.extension import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.lalilu.component.base.DynamicScreen +import com.lalilu.component.LazyGridContent +import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.component.extension.dayNightTextColor +import com.lalilu.component.divider import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent +import com.lalilu.component.rememberGridItemPadding import com.lalilu.lalbum.screen.AlbumsScreen import com.lalilu.lartist.screen.ArtistsScreen import com.lalilu.ldictionary.screen.DictionaryScreen @@ -31,59 +36,80 @@ import com.lalilu.lmusic.compose.new_screen.SettingsScreen import com.lalilu.lmusic.compose.screen.songs.SongsScreen import com.lalilu.lplaylist.screen.PlaylistScreen -@Composable -fun EntryPanel() { - val screenEntry = remember { - listOf( - SongsScreen(), - ArtistsScreen(), - AlbumsScreen(), - PlaylistScreen, - HistoryScreen, - DictionaryScreen, - SettingsScreen + +object EntryPanel : LazyGridContent { + + @Composable + override fun register(): LazyGridScope.() -> Unit { + val screenEntry = remember { + listOf( + SongsScreen(), + ArtistsScreen(), + AlbumsScreen(), + PlaylistScreen, + HistoryScreen, + DictionaryScreen, + SettingsScreen + ) + } + val defaultString = "Undefined" + val widthSizeClass = LocalWindowSize.current.widthSizeClass + val gridItemPaddings = rememberGridItemPadding( + count = if (widthSizeClass == WindowWidthSizeClass.Expanded) 3 else 2, + gapVertical = 8.dp, + gapHorizontal = 8.dp, + paddingValues = PaddingValues(horizontal = 16.dp) ) - } - Surface( - modifier = Modifier.padding(15.dp), - shape = RoundedCornerShape(15.dp) - ) { - Column { - for (entry in screenEntry) { - val (icon, title) = when (entry) { - is DynamicScreen -> entry.getScreenInfo()?.let { it.icon to it.title } - is ScreenInfoFactory -> entry.provideScreenInfo().let { it.icon to it.title } - else -> null - } ?: continue + return fun LazyGridScope.() { + divider { it.height(16.dp) } + + itemsIndexed( + items = screenEntry, + key = { index, item -> item.key }, + contentType = { index, item -> this@EntryPanel::class.java.name }, + span = { index, item -> + if (widthSizeClass == WindowWidthSizeClass.Expanded) { + GridItemSpan(maxLineSpan / 3) + } else { + GridItemSpan(maxLineSpan / 2) + } + } + ) { index, item -> + val infoFactory = (item as? ScreenInfoFactory)?.provideScreenInfo() + val title = infoFactory?.title?.invoke() ?: defaultString + val icon = infoFactory?.icon - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - AppRouter.intent( - NavIntent.Push(entry) + Surface( + modifier = Modifier.padding(gridItemPaddings(index)), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { AppRouter.intent(NavIntent.Push(item)) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + icon?.let { icon -> + Icon( + imageVector = icon, + contentDescription = title, + tint = MaterialTheme.colors.onBackground.copy(0.7f) ) } - .padding(horizontal = 20.dp, vertical = 15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.dp) - ) { - icon?.let { icon -> - Icon( - painter = painterResource(id = icon), - contentDescription = stringResource(id = title), - tint = dayNightTextColor(0.7f) + + Text( + text = title, + color = MaterialTheme.colors.onBackground.copy(0.6f), + style = MaterialTheme.typography.subtitle2 ) } - - Text( - text = stringResource(id = title), - color = dayNightTextColor(0.6f), - style = MaterialTheme.typography.subtitle2 - ) } } + + divider() } } } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt index 80396406c..62a004499 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt @@ -1,77 +1,89 @@ package com.lalilu.lmusic.extension -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.Chip import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.lalilu.common.base.Playable +import com.lalilu.component.LazyGridContent +import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.card.SongCard import com.lalilu.component.navigation.AppRouter -import com.lalilu.lmedia.entity.LSong import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.HistoryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel +import org.koin.compose.koinInject -@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) -fun LazyListScope.historyPanel( - historyVM: HistoryViewModel, - playingVM: PlayingViewModel -) { - item { - RecommendTitle( - title = "最近播放", - onClick = { } - ) { - Chip( - onClick = { - // navigator.navigate(HistoryScreenDestination) - }, - ) { - Text(style = MaterialTheme.typography.caption, text = "历史记录") - } - } - } +object HistoryPanel : LazyGridContent { - items( - items = historyVM.historyState.value.take(5), - key = { it.id }, - contentType = { LSong::class } - ) { item -> + @OptIn(ExperimentalMaterialApi::class) + @Composable + override fun register(): LazyGridScope.() -> Unit { + val historyVM: HistoryViewModel = koinInject() + val playingVM: PlayingViewModel = koinInject() + val widthSizeClass = LocalWindowSize.current.widthSizeClass val haptic = LocalHapticFeedback.current + val items = historyVM.historyState.value + + return fun LazyGridScope.() { + // 若列表为空,则不显示 + if (items.isEmpty()) return - SongCard( - modifier = Modifier - .animateItemPlacement() - .padding(bottom = 5.dp), - song = { item }, - fixedHeight = { true }, - isSelected = { false }, - onEnterSelect = { }, - isPlaying = { playingVM.isItemPlaying { it.mediaId == item.id } }, - onClick = { - historyVM.requiteHistoryList { - playingVM.play( - mediaId = item.mediaId, - mediaIds = it.map(Playable::mediaId), - playOrPause = true - ) + item(span = { GridItemSpan(maxLineSpan) }) { + RecommendTitle( + title = "最近播放", + onClick = { } + ) { + Chip( + onClick = { // navigator.navigate(HistoryScreenDestination) + }, + ) { + Text(style = MaterialTheme.typography.caption, text = "历史记录") + } } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + + items( + items = items, + key = { it.mediaId }, + contentType = { "History_item" }, + span = { + if (widthSizeClass == WindowWidthSizeClass.Expanded) GridItemSpan(maxLineSpan / 2) + else GridItemSpan(maxLineSpan) + } + ) { item -> + SongCard( + modifier = Modifier + .animateItem() + .padding(bottom = 5.dp), + song = { item }, + isPlaying = { playingVM.isItemPlaying { it.mediaId == item.id } }, + onClick = { + playingVM.play( + mediaId = item.mediaId, + mediaIds = items.map(Playable::mediaId), + playOrPause = true + ) + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) - AppRouter.route("/pages/songs/detail") - .with("mediaId", item.mediaId) - .jump() + AppRouter.route("/pages/songs/detail") + .with("mediaId", item.mediaId) + .jump() + } + ) } - ) + } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt index f05abc7d3..2b3c9b163 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt @@ -1,64 +1,87 @@ package com.lalilu.lmusic.extension -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.material.Chip import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lalilu.common.base.Playable +import com.lalilu.component.LazyGridContent import com.lalilu.component.navigation.AppRouter import com.lalilu.lmusic.compose.component.card.RecommendCard import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.LibraryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel +import org.koin.compose.koinInject -@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) -fun LazyListScope.latestPanel( - libraryVM: LibraryViewModel, - playingVM: PlayingViewModel -) { - item { - RecommendTitle( - title = "最近添加", - onClick = { } - ) { - Chip(onClick = { }) { - Text( - style = MaterialTheme.typography.caption, - text = "所有歌曲" - ) +object LatestPanel : LazyGridContent { + + @OptIn(ExperimentalMaterialApi::class) + @Composable + override fun register(): LazyGridScope.() -> Unit { + val libraryVM: LibraryViewModel = koinInject() + val playingVM: PlayingViewModel = koinInject() + val items by libraryVM.recentlyAdded + + return fun LazyGridScope.() { + // 若列表为空,不显示 + if (items.isEmpty()) return + + item( + key = "latest_header", + contentType = "latest_header", + span = { GridItemSpan(maxLineSpan) } + ) { + RecommendTitle( + title = "最近添加", + onClick = { } + ) { + Chip(onClick = { }) { + Text( + style = MaterialTheme.typography.caption, + text = "所有歌曲" + ) + } + } } - } - } - item { - RecommendRow( - items = { libraryVM.recentlyAdded.value }, - getId = { it.id } - ) { - RecommendCard( - item = { it }, - width = { 100.dp }, - height = { 100.dp }, - modifier = Modifier.animateItem(), - onClick = { - AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) - .jump() - }, - isPlaying = { playingVM.isItemPlaying(it.id, Playable::mediaId) }, - onClickButton = { - playingVM.play( - mediaId = it.id, - playOrPause = true, - addToNext = true + + item( + key = "latest", + contentType = "latest", + span = { GridItemSpan(maxLineSpan) } + ) { + RecommendRow( + items = { items }, + getId = { it.id } + ) { + RecommendCard( + item = { it }, + width = { 100.dp }, + height = { 100.dp }, + modifier = Modifier.animateItem(), + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.mediaId) + .jump() + }, + isPlaying = { playingVM.isItemPlaying(it.id, Playable::mediaId) }, + onClickButton = { + playingVM.play( + mediaId = it.id, + playOrPause = true, + addToNext = true + ) + } ) } - ) + } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt index 8ab9d22d0..945acdb75 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt @@ -2,13 +2,16 @@ package com.lalilu.lmusic.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lalilu.component.extension.toState +import com.lalilu.lhistory.repository.HistoryRepository import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LSong -import com.lalilu.lhistory.repository.HistoryRepository -import com.lalilu.component.extension.toState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single +@Single @OptIn(ExperimentalCoroutinesApi::class) class HistoryViewModel( private val historyRepo: HistoryRepository @@ -20,16 +23,13 @@ class HistoryViewModel( .sortedByDescending { it.second } .map { it.first } LMedia.flowMapBy(ids) - }.toState(emptyList(), viewModelScope) + }.map { it.take(6) } + .toState(emptyList(), viewModelScope) private val historyCountState = historyRepo .getHistoriesIdsMapWithCount() .toState(emptyMap(), viewModelScope) - fun requiteHistoryList(callback: (List) -> Unit) { - callback(historyState.value) - } - fun requiteHistoryCountById(mediaId: String): Int { return historyCountState.value[mediaId] ?: 0 } diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt index d1ecd187a..ab2a5c292 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt @@ -10,19 +10,26 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import org.koin.core.annotation.Single import java.util.Calendar +@Single @OptIn(ExperimentalCoroutinesApi::class) class LibraryViewModel( private val tempSp: TempSp ) : ViewModel() { - val recentlyAdded = LMedia.getFlow().mapLatest { it.take(15) } + val recentlyAdded = LMedia.getFlow() + .mapLatest { it.take(15) } .toState(emptyList(), viewModelScope) val dailyRecommends = tempSp.dailyRecommends.flow(true) .flatMapLatest { LMedia.flowMapBy(it ?: emptyList()) } .toState(emptyList(), viewModelScope) + init { + checkOrUpdateToday() + } + fun checkOrUpdateToday() = viewModelScope.launch { val today = Calendar.getInstance().get(Calendar.DAY_OF_YEAR) diff --git a/component/src/main/java/com/lalilu/component/LazyGridContent.kt b/component/src/main/java/com/lalilu/component/LazyGridContent.kt new file mode 100644 index 000000000..9af01562c --- /dev/null +++ b/component/src/main/java/com/lalilu/component/LazyGridContent.kt @@ -0,0 +1,80 @@ +package com.lalilu.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +interface LazyGridContent { + + @Composable + fun register(): LazyGridScope.() -> Unit +} + +@Composable +fun rememberGridItemPadding( + count: Int, + gapHorizontal: Dp, + gapVertical: Dp = 0.dp, + paddingValues: PaddingValues, +): (Int) -> PaddingValues { + val layoutDirection = LocalLayoutDirection.current + + return remember(count, paddingValues, gapHorizontal, gapVertical, layoutDirection) { + val paddingStart = paddingValues.calculateStartPadding(layoutDirection) + val paddingEnd = paddingValues.calculateStartPadding(layoutDirection) + + val averageGap = + (paddingStart + paddingEnd + (gapHorizontal * (count - 1))) / count.toFloat() + val resultList = mutableListOf() + + var tempPaddingStart = paddingStart + var tempPaddingEnd = averageGap - tempPaddingStart + + for (i in 0 until count) { + resultList.add( + PaddingValues( + start = tempPaddingStart.coerceAtLeast(0.dp), + end = tempPaddingEnd.coerceAtLeast(0.dp) + ) + ) + + tempPaddingStart = gapHorizontal - tempPaddingEnd + tempPaddingEnd = if (i != count - 1) averageGap - tempPaddingStart else paddingEnd + } + + { index -> + resultList.getOrNull(index % count) + ?.let { + if (index < count) it else PaddingValues( + top = gapVertical, + start = it.calculateStartPadding(layoutDirection), + end = it.calculateEndPadding(layoutDirection) + ) + } + ?: PaddingValues() + } + } +} + +fun LazyGridScope.divider(block: (Modifier) -> Modifier = { it }) { + item( + contentType = "divider", + span = { GridItemSpan(maxLineSpan) } + ) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .let(block) + ) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt index 155676ebd..2dfa2572d 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt @@ -1,12 +1,11 @@ package com.lalilu.component.base.screen -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector data class ScreenInfo( - @StringRes val title: Int, - @DrawableRes val icon: Int? = null, + val title: @Composable () -> String, + val icon: ImageVector? = null ) interface ScreenInfoFactory { diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt index 17e7199ba..ee8a89c87 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.FixedScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -32,7 +32,6 @@ import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.R import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.component.extension.dayNightTextColor @Composable @@ -42,6 +41,8 @@ fun NavigateTabBar( tabScreens: () -> List, onSelectTab: (TabScreen) -> Unit = {} ) { + val defaultTitle = stringResource(id = R.string.empty_screen_no_items) + Row( modifier = modifier .height(52.dp) @@ -50,13 +51,13 @@ fun NavigateTabBar( horizontalArrangement = Arrangement.SpaceBetween ) { tabScreens().forEach { - val screenInfo = (it as? ScreenInfoFactory) - ?.provideScreenInfo() + val screenInfo = (it as? ScreenInfoFactory)?.provideScreenInfo() + val title = screenInfo?.title?.invoke() NavigateItem( modifier = Modifier.weight(1f), - titleRes = { screenInfo?.title ?: R.string.empty_screen_no_items }, - iconRes = { screenInfo?.icon ?: R.drawable.ic_close_line }, + title = { title ?: defaultTitle }, + icon = { screenInfo?.icon }, isSelected = { currentScreen() === it }, onClick = { onSelectTab(it) } ) @@ -68,15 +69,14 @@ fun NavigateTabBar( @Composable fun NavigateItem( modifier: Modifier = Modifier, - titleRes: () -> Int, - iconRes: () -> Int, + title: () -> String, + icon: () -> ImageVector?, isSelected: () -> Boolean = { false }, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, baseColor: Color = MaterialTheme.colors.primary, unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f) ) { - val titleValue = stringResource(id = titleRes()) val iconTintColor = animateColorAsState( targetValue = if (isSelected()) baseColor else unSelectedColor, label = "" @@ -102,21 +102,23 @@ fun NavigateItem( .animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally ) { - Image( - painter = painterResource(id = iconRes()), - contentDescription = titleValue, - colorFilter = ColorFilter.tint(iconTintColor.value), - contentScale = FixedScale(if (isSelected()) 1.1f else 1f) - ) + icon()?.let { + Image( + imageVector = it, + contentDescription = title(), + colorFilter = ColorFilter.tint(iconTintColor.value), + contentScale = FixedScale(if (isSelected()) 1.1f else 1f) + ) + } AnimatedVisibility(visible = isSelected()) { Text( - text = titleValue, + text = title(), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Medium, fontSize = 12.sp, letterSpacing = 0.1.sp, - color = dayNightTextColor() + color = MaterialTheme.colors.onBackground ) } } diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt index 0f957b53c..102d93f5a 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.navigator.LocalNavigator import com.lalilu.component.base.ScreenBarComponent @@ -46,8 +45,9 @@ fun NavigationSmartBar( ?.previousScreen() ?.value - val previousTitle = (previousScreen as? ScreenInfoFactory)?.provideScreenInfo() - ?.let { stringResource(id = it.title) } + val previousTitle = (previousScreen as? ScreenInfoFactory) + ?.provideScreenInfo() + ?.title?.invoke() ?: "返回" val mainContent = (currentScreen as? ScreenBarFactory)?.content() diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt index c03f38f81..6faa9c1a3 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt @@ -3,6 +3,7 @@ package com.lalilu.lalbum.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen @@ -29,7 +30,9 @@ data class AlbumDetailScreen( @Composable override fun provideScreenInfo(): ScreenInfo = remember { - ScreenInfo(title = R.string.album_screen_title) + ScreenInfo( + title = { stringResource(id = R.string.album_screen_title) } + ) } @Composable diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index f7e840ac6..952c2d02a 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -22,11 +22,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel +import com.lalilu.RemixIcon import com.lalilu.component.base.LoadingScaffold import com.lalilu.component.base.LocalSmartBarPadding import com.lalilu.component.base.LocalWindowSize @@ -44,13 +46,14 @@ import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LAlbum import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.Sortable +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.media.albumFill import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import org.koin.compose.koinInject -import com.lalilu.component.R as ComponentR @Destination("/pages/albums") data class AlbumsScreen( @@ -59,8 +62,8 @@ data class AlbumsScreen( @Composable override fun provideScreenInfo(): ScreenInfo = remember { ScreenInfo( - title = R.string.album_screen_title, - icon = ComponentR.drawable.ic_album_fill + title = { stringResource(id = R.string.album_screen_title) }, + icon = RemixIcon.Media.albumFill ) } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index ed389d350..1f8ee1968 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -42,6 +42,7 @@ import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ToastUtils +import com.lalilu.RemixIcon import com.lalilu.component.LLazyColumn import com.lalilu.component.LongClickableTextButton import com.lalilu.component.base.NavigatorHeader @@ -58,6 +59,8 @@ import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository import com.lalilu.lplaylist.screen.create.PlaylistCreateOrEditScreen import com.lalilu.lplaylist.screen.detail.PlaylistDetailScreen +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.media.playListFill import com.zhangke.krouter.annotation.Destination import org.koin.compose.koinInject import sh.calvin.reorderable.ReorderableItem @@ -71,12 +74,13 @@ class PlaylistScreenModel : ScreenModel { @Destination("/pages/playlist") data object PlaylistScreen : TabScreen, ScreenBarFactory { + private fun readResolve(): Any = PlaylistScreen @Composable override fun provideScreenInfo(): ScreenInfo = remember { ScreenInfo( - title = R.string.playlist_screen_title, - icon = ComponentR.drawable.ic_play_list_fill + title = { stringResource(id = R.string.playlist_screen_title) }, + icon = RemixIcon.Media.playListFill, ) } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt index 61b78da98..308adec97 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt @@ -30,7 +30,9 @@ data class PlaylistAddToScreen( @Composable override fun provideScreenInfo(): ScreenInfo = remember(this) { - ScreenInfo(title = R.string.playlist_action_add_to_playlist) + ScreenInfo( + title = { stringResource(id = R.string.playlist_action_add_to_playlist) } + ) } @Composable diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt index 943a1c524..93126c63b 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -3,6 +3,7 @@ package com.lalilu.lplaylist.screen.detail import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ToastUtils @@ -26,7 +27,9 @@ data class PlaylistDetailScreen( @Composable override fun provideScreenInfo(): ScreenInfo = remember { - ScreenInfo(title = R.string.playlist_screen_detail) + ScreenInfo( + title = { stringResource(id = R.string.playlist_screen_detail) } + ) } @Composable From 4f29516e3c1846a7a1937860d75299e5a68738de Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 9 Sep 2024 02:54:43 +0800 Subject: [PATCH 084/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E8=89=BA=E6=9C=AF=E5=AE=B6=E5=88=97=E8=A1=A8=E9=A1=B5?= =?UTF-8?q?=EF=BC=8C=E8=B0=83=E6=95=B4=E4=BC=98=E5=8C=96=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 2 - .../main/java/com/lalilu/lmusic/LMusicApp.kt | 2 +- .../compose/screen/playing/LyricLayout.kt | 15 +- .../screen/songs/SongsScreenContent.kt | 54 +----- .../com/lalilu/lmusic/extension/EntryPanel.kt | 6 +- .../lmusic/viewmodel/SearchViewModel.kt | 4 +- .../java/com/lalilu/common/base/Playable.kt | 8 +- .../com/lalilu/component/ComponentModule.kt | 3 - .../lalilu/component/base/songs/SongsSM.kt | 24 +-- .../com/lalilu/component/extension/FlowExt.kt | 5 +- .../component/extension/ItemRecorder.kt | 18 ++ .../extension/LazyListAnimateScroller.kt | 122 ++++++------ .../extension/LazyListScrollToHelper.kt | 97 ---------- .../component/viewmodel/SongsViewModel.kt | 140 -------------- .../com/lalilu/lalbum/screen/AlbumsScreen.kt | 1 - lartist/build.gradle.kts | 1 + .../java/com/lalilu/lartist/ArtistModule.kt | 13 +- .../lalilu/lartist/component/ArtistCard.kt | 36 ++-- .../lartist/screen/ArtistDetailScreen.kt | 28 ++- .../lalilu/lartist/screen/ArtistsScreen.kt | 123 ------------ .../lartist/screen/artists/ArtistsScreen.kt | 160 ++++++++++++++++ .../screen/artists/ArtistsScreenContent.kt | 180 ++++++++++++++++++ .../com/lalilu/lartist/viewModel/ArtistsSM.kt | 94 +++++++++ .../lartist/viewModel/ArtistsViewModel.kt | 20 -- .../com/lalilu/lhistory/ExtendSortRule.kt | 4 +- lmedia | 2 +- .../com/lalilu/lplayer/service/LService.kt | 2 +- .../create/PlaylistCreateOrEditScreen.kt | 8 +- 28 files changed, 596 insertions(+), 576 deletions(-) delete mode 100644 component/src/main/java/com/lalilu/component/extension/LazyListScrollToHelper.kt delete mode 100644 lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt create mode 100644 lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt create mode 100644 lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt create mode 100644 lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt delete mode 100644 lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsViewModel.kt diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index d91c18ef6..0041cfb1d 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -12,7 +12,6 @@ import com.lalilu.R import com.lalilu.common.base.SourceType import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lalbum.viewModel.AlbumsViewModel -import com.lalilu.lartist.viewModel.ArtistsViewModel import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.indexer.Filter import com.lalilu.lmedia.indexer.FilterGroup @@ -104,7 +103,6 @@ val ViewModelModule = module { viewModel { get() } viewModelOf(::SearchViewModel) viewModelOf(::AlbumsViewModel) - viewModelOf(::ArtistsViewModel) viewModelOf(::SearchLyricViewModel) } diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 7a764a2b2..f14c8eac2 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -64,7 +64,7 @@ class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, V ComponentModule, HistoryModule.module, PlaylistModule2.module, - ArtistModule, + ArtistModule.module, AlbumModule, DictionaryModule, LPlayer.module, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt index acf8b4d6e..487104f3a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonDefaults @@ -39,8 +38,9 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller -import com.lalilu.component.extension.rememberLazyListScrollToHelper +import com.lalilu.component.extension.startRecord import com.lalilu.lmusic.utils.extension.edgeTransparent import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest @@ -132,11 +132,11 @@ fun LyricLayout( ) { val textMeasurer = rememberTextMeasurer() val isUserScrolling = remember(isUserScrollEnable()) { mutableStateOf(isUserScrollEnable()) } - val scrollToHelper = rememberLazyListScrollToHelper(listState) + val recorder = remember { ItemRecorder() } val scroller = rememberLazyListAnimateScroller( listState = listState, enableScrollAnimation = { !isUserScrolling.value }, - keysKeeper = { scrollToHelper.getKeys() } + keysKeeper = { recorder.list().filterNotNull() } ) val currentItemIndex = remember { @@ -209,9 +209,9 @@ fun LyricLayout( userScrollEnabled = true, contentPadding = remember { PaddingValues(top = 300.dp, bottom = 500.dp) } ) { - scrollToHelper.record { + startRecord(recorder) { if (lyricEntry.value.isEmpty()) { - item { + itemWithRecord(key = "EMPTY_TIPS") { LyricSentence( lyric = EMPTY_SENTENCE_TIPS, maxWidth = maxWidth, @@ -231,8 +231,7 @@ fun LyricLayout( ) } } else { - record(lyricEntry.value.map { it.key }) - itemsIndexed( + itemsIndexedWithRecord( items = lyricEntry.value, key = { _, item -> item.key }, contentType = { _, _ -> LyricEntry::class } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index 78bfd9b1c..5b09a5a0e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -37,6 +37,7 @@ import com.lalilu.component.base.songs.SongsScreenEvent import com.lalilu.component.base.songs.SongsScreenScrollBar import com.lalilu.component.base.songs.SongsScreenStickyHeader import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter import com.lalilu.lmedia.extension.GroupIdentity @@ -56,58 +57,19 @@ internal fun SongsScreenContent( val listState: LazyListState = rememberLazyListState() val statusBar = WindowInsets.statusBars val songs by songsSM.songs + val scroller = rememberLazyListAnimateScroller( + listState = listState, + keysKeeper = { songsSM.recorder.list().filterNotNull() } + ) LaunchedEffect(Unit) { songsSM.event().collectLatest { event -> when (event) { is SongsScreenEvent.ScrollToItem -> { - val targetIndex = event.index - var maxScrollCount = 3 - - // 限制最多滚动3次,避免无限死循环 - while (maxScrollCount-- > 0) { - val targetItem = listState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == targetIndex } - - // 判断元素是否在可见范围内 - if (targetItem != null) { - val isStickHeader = targetItem.contentType == "group" - - if (isStickHeader) { - val isFirstGroupItem = listState.layoutInfo.visibleItemsInfo - .firstOrNull { it.contentType == "group" } - ?.index == targetIndex - - if (isFirstGroupItem) { - listState.scrollToItem( - index = targetIndex, - scrollOffset = -statusBar.getTop(density) - ) - } else { - listState.animateScrollToItem( - index = targetIndex, - scrollOffset = -statusBar.getTop(density) - ) - } - } else { - val lastGroupItemOffset = listState.layoutInfo.visibleItemsInfo - .lastOrNull { it.contentType == "group" && it.index < targetIndex } - ?.size ?: 0 - - listState.animateScrollToItem( - index = targetIndex, - scrollOffset = -(statusBar.getTop(density) + lastGroupItemOffset) - ) - } - break - } else { - listState.scrollToItem( - index = targetIndex, - scrollOffset = -statusBar.getTop(density) - ) - } - } + scroller.animateTo(event.key) } + + else -> {} } } } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt index e3e33d2e7..e1ec4fa5c 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt @@ -29,12 +29,12 @@ import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent import com.lalilu.component.rememberGridItemPadding import com.lalilu.lalbum.screen.AlbumsScreen -import com.lalilu.lartist.screen.ArtistsScreen import com.lalilu.ldictionary.screen.DictionaryScreen import com.lalilu.lhistory.screen.HistoryScreen import com.lalilu.lmusic.compose.new_screen.SettingsScreen import com.lalilu.lmusic.compose.screen.songs.SongsScreen import com.lalilu.lplaylist.screen.PlaylistScreen +import com.zhangke.krouter.KRouter object EntryPanel : LazyGridContent { @@ -42,9 +42,9 @@ object EntryPanel : LazyGridContent { @Composable override fun register(): LazyGridScope.() -> Unit { val screenEntry = remember { - listOf( + listOfNotNull( SongsScreen(), - ArtistsScreen(), + KRouter.route("/pages/artists"), AlbumsScreen(), PlaylistScreen, HistoryScreen, diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt index 1093968e1..238b9fc7f 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt @@ -3,13 +3,13 @@ package com.lalilu.lmusic.viewmodel import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lalilu.component.extension.toState import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.Item import com.lalilu.lmedia.entity.LAlbum import com.lalilu.lmedia.entity.LArtist import com.lalilu.lmedia.entity.LGenre import com.lalilu.lmedia.entity.LSong -import com.lalilu.component.extension.toState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow @@ -39,7 +39,7 @@ class SearchViewModel : ViewModel() { private fun Flow>.searchFor(keywords: Flow>): Flow> = combine(keywords) { items, keywordList -> if (keywordList.isEmpty()) return@combine emptyList() - items.filter { item -> keywordList.all { item.matchStr.contains(it) } } + items.filter { item -> keywordList.all { item.getMatchStr().contains(it) } } } fun searchFor(str: String) { diff --git a/common/src/main/java/com/lalilu/common/base/Playable.kt b/common/src/main/java/com/lalilu/common/base/Playable.kt index c0e70f4aa..8c28fcc44 100644 --- a/common/src/main/java/com/lalilu/common/base/Playable.kt +++ b/common/src/main/java/com/lalilu/common/base/Playable.kt @@ -20,5 +20,11 @@ interface Playable { val imageSource: Any? val sticker: List - val metaDataCompat: MediaMetadataCompat + fun provideMediaData(): MediaMetadataCompat = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId) + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subTitle) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs) + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, targetUri.toString()) + .build() } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/ComponentModule.kt b/component/src/main/java/com/lalilu/component/ComponentModule.kt index 530948ddf..74d93ec96 100644 --- a/component/src/main/java/com/lalilu/component/ComponentModule.kt +++ b/component/src/main/java/com/lalilu/component/ComponentModule.kt @@ -1,12 +1,9 @@ package com.lalilu.component import com.lalilu.component.viewmodel.SongsSp -import com.lalilu.component.viewmodel.SongsViewModel import org.koin.android.ext.koin.androidApplication -import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.dsl.module val ComponentModule = module { single { SongsSp(androidApplication()) } - viewModelOf(::SongsViewModel) } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt index b5e999597..1835fc9da 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt @@ -15,9 +15,8 @@ import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.ItemSelector import com.lalilu.component.extension.toState import com.lalilu.component.viewmodel.SongsSp -import com.lalilu.component.viewmodel.findInstance import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.BaseMatchable +import com.lalilu.lmedia.entity.FullTextMatchable import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmedia.extension.ListAction @@ -25,9 +24,7 @@ import com.lalilu.lmedia.extension.SortDynamicAction import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmedia.extension.Sortable import com.lalilu.lplayer.LPlayer -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -48,7 +45,7 @@ sealed interface SongsScreenAction { } sealed interface SongsScreenEvent { - data class ScrollToItem(val index: Int) : SongsScreenEvent + data class ScrollToItem(val key: Any) : SongsScreenEvent } class SongsSM( @@ -78,12 +75,7 @@ class SongsSM( val mediaId = LPlayer.runtime.info.playingIdFlow.value ?: return@launch - val index = recorder.list() - .indexOf(mediaId) - .takeIf { it >= 0 } - ?: return@launch - - eventFlow.emit(SongsScreenEvent.ScrollToItem(index)) + eventFlow.emit(SongsScreenEvent.ScrollToItem(mediaId)) } SongsScreenAction.ToggleSortPanel -> { @@ -118,13 +110,12 @@ class SongsSM( val songs = sorter.output.toState( defaultValue = emptyMap(), scope = screenModelScope, - context = Dispatchers.IO + SupervisorJob() ) val selector = ItemSelector() val recorder = ItemRecorder() } -class ItemSearcher( +class ItemSearcher( sourceFlow: Flow> ) { val keywordState = mutableStateOf("") @@ -137,7 +128,7 @@ class ItemSearcher( } val output: Flow> = sourceFlow.combine(keywordFlow) { source, keywords -> - source.filter { item -> keywords.all { item.matchStr.contains(it) } } + source.filter { item -> keywords.all { item.getMatchStr().contains(it) } } } val isSearching: State @@ -182,3 +173,8 @@ class ItemSorter( return sortActionKey.value == action::class.java.name } } + +inline fun Collection.findInstance(check: (T) -> Boolean): T? { + return this.filterIsInstance(T::class.java) + .firstOrNull(check) +} diff --git a/component/src/main/java/com/lalilu/component/extension/FlowExt.kt b/component/src/main/java/com/lalilu/component/extension/FlowExt.kt index 7851e45d0..f02d62a45 100644 --- a/component/src/main/java/com/lalilu/component/extension/FlowExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/FlowExt.kt @@ -8,12 +8,10 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext /** * 将Flow转换为State @@ -30,10 +28,9 @@ fun Flow.toState(scope: CoroutineScope): State { fun Flow.toState( defaultValue: T, scope: CoroutineScope, - context: CoroutineContext = Dispatchers.Unconfined ): State { return mutableStateOf(defaultValue).also { state -> - scope.launch(context = context) { collect { state.value = it } } + this.onEach { state.value = it }.launchIn(scope) } } diff --git a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt index 6b868a595..6a9d10694 100644 --- a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt +++ b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf @@ -60,6 +61,23 @@ class LazyListRecordScope internal constructor( ) } } + + inline fun itemsIndexedWithRecord( + items: List, + noinline key: ((index: Int, item: T) -> Any)? = null, + crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null }, + crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit + ) { + lazyListScope?.let { scope -> + recorder.recordAll(items.mapIndexed { index, item -> key?.invoke(index, item) }) + scope.itemsIndexed( + items = items, + key = key, + contentType = contentType, + itemContent = itemContent + ) + } + } } class ItemRecorder { diff --git a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt index ff1b6653f..947bf2cf0 100644 --- a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt +++ b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt @@ -3,96 +3,108 @@ package com.lalilu.component.extension import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableFloatState -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce import androidx.dynamicanimation.animation.springAnimationOf import androidx.dynamicanimation.animation.withSpringForceProperties +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class LazyListAnimateScroller internal constructor( private val keysKeeper: () -> Collection, + private val enable: () -> Boolean = { true }, private val listState: LazyListState, - private val currentValue: MutableFloatState, - private val targetValue: MutableFloatState, - private val deltaValue: MutableFloatState, - private val targetRange: MutableState, - private val sizeMap: SnapshotStateMap + private val scope: CoroutineScope ) { - private val keyEvent: MutableSharedFlow = MutableSharedFlow(1) + private val currentValue = mutableFloatStateOf(0f) + private val targetValue = mutableFloatStateOf(0f) + private val targetRange = mutableStateOf(IntRange(0, 0)) + private val sizeMap = mutableStateMapOf() + private var exactAnimation: Boolean = false + private var targetIndex: Int = -1 + private val animator: SpringAnimation = springAnimationOf( getter = { currentValue.floatValue }, setter = { - deltaValue.floatValue = it - currentValue.floatValue + onScroll(it - currentValue.floatValue) currentValue.floatValue = it }, finalPosition = 0f ).withSpringForceProperties { dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY stiffness = SpringForce.STIFFNESS_VERY_LOW + }.addUpdateListener { animation, value, velocity -> + val percent = if (targetValue.floatValue <= 0) 0f else value / targetValue.floatValue + + if (percent > 0.5f && !exactAnimation && targetIndex != -1) { + val tempIndex = targetIndex + scope.launch { scrollTo(tempIndex) } + targetIndex = -1 + } }.addEndListener { animation, canceled, value, velocity -> if (!canceled) { targetRange.value = IntRange.EMPTY } } - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private fun onScroll(dy: Float) { + if (!enable()) return + + scope.launch { + try { + listState.scroll { scrollBy(dy) } + } catch (e: Exception) { + // 若是CancellationException,则停止animator动画 + if (e is CancellationException) { + animator.cancel() + } + } + } + } + + /** + * 启动循环任务,用于监听可见元素列表的变化,并计算目标元素的偏移量 + */ internal suspend fun startLoop(scope: CoroutineScope) = withContext(scope.coroutineContext) { snapshotFlow { listState.layoutInfo.visibleItemsInfo } .distinctUntilChanged() .onEach { list -> list.forEach { sizeMap[it.index] = it.size } } .launchIn(this) + } - keyEvent.mapLatest { key -> - // 1. 从当前可见元素直接查找offset (准确值) - // get the offset directly from the visibleItemsInfo - val offset = listState.layoutInfo.visibleItemsInfo - .firstOrNull { it.key == key } - ?.offset - - if (offset != null) { - doScroll(offset.toFloat(), true) - println("[visible target]: ${targetValue.floatValue}") - return@mapLatest null - } - - return@mapLatest key - }.debounce(20L) - .collectLatest { key -> - if (key == null) return@collectLatest + fun animateTo(key: Any) = scope.launch { + // 1. 从当前可见元素直接查找offset (准确值) + // get the offset directly from the visibleItemsInfo + val offset = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == key } + ?.offset - val index = keysKeeper().indexOfFirst { it == key } - if (index == -1) return@collectLatest // 元素不存在keys列表中,则不进行滚动 + if (offset != null) { + doScroll(offset.toFloat(), true) + return@launch + } - // 2. 使用实时维护的sizeMap查找并计算目标元素的offset (非准确值) - // Use the real-time maintained sizeMap to find and calculate the offset of the target element - scrollTo(index) - } - } + val index = keysKeeper().indexOfFirst { it == key } + if (index == -1) return@launch // 元素不存在keys列表中,则不进行滚动 - fun animateTo(key: Any) { - keyEvent.tryEmit(key) + // 2. 使用实时维护的sizeMap查找并计算目标元素的offset (非准确值) + // Use the real-time maintained sizeMap to find and calculate the offset of the target element + scrollTo(index) } private fun doScroll( @@ -136,6 +148,7 @@ class LazyListAnimateScroller internal constructor( // 使用非准确值进行滚动 // use the non-accurate value for scrolling if (!isActive) return@withContext + targetIndex = index doScroll(offsetTemp * forwardMultiple, false) println("[calculate target]: ${targetValue.floatValue} -> range: [${targetRange.value.first} -> ${targetRange.value.last}]") @@ -148,33 +161,18 @@ fun rememberLazyListAnimateScroller( enableScrollAnimation: () -> Boolean = { true }, keysKeeper: () -> Collection = { emptyList() }, ): LazyListAnimateScroller { - val currentValue = remember { mutableFloatStateOf(0f) } - val targetValue = remember { mutableFloatStateOf(0f) } - val deltaValue = remember { mutableFloatStateOf(0f) } - val targetRange = remember { mutableStateOf(IntRange(0, 0)) } - val sizeMap = remember { mutableStateMapOf() } val enableAnimation = rememberUpdatedState(enableScrollAnimation()) + val scope = rememberCoroutineScope() val scroller = remember { LazyListAnimateScroller( listState = listState, - currentValue = currentValue, - targetValue = targetValue, - deltaValue = deltaValue, - targetRange = targetRange, - sizeMap = sizeMap, - keysKeeper = keysKeeper + enable = { enableAnimation.value }, + keysKeeper = keysKeeper, + scope = scope ) } - LaunchedEffect(Unit) { - snapshotFlow { deltaValue.floatValue } - .collectLatest { - if (!enableAnimation.value) return@collectLatest - listState.scroll { scrollBy(it) } - } - } - LaunchedEffect(Unit) { scroller.startLoop(this) } diff --git a/component/src/main/java/com/lalilu/component/extension/LazyListScrollToHelper.kt b/component/src/main/java/com/lalilu/component/extension/LazyListScrollToHelper.kt deleted file mode 100644 index 15f6a2ccd..000000000 --- a/component/src/main/java/com/lalilu/component/extension/LazyListScrollToHelper.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.lalilu.component.extension - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - - -interface ScrollToHelperScope { - fun record(key: Any) - fun record(keys: Collection) -} - -class LazyListScrollToHelper internal constructor( - private val onScrollTo: (delay: Long, scrollOffset: Int, animateTo: Boolean, action: () -> Int?) -> Unit -) : ScrollToHelperScope { - private val keys: MutableSet = mutableSetOf() - private var finished: Boolean = false - - fun getKeys(): Collection = keys - - fun startRecord() { - keys.clear() - finished = false - } - - fun doRecord(key: Any) { - if (finished) return - keys.add(key) - } - - fun doRecord(key: Collection) { - if (finished) return - keys.addAll(key) - } - - fun endRecord() { - finished = true - } - - override fun record(key: Any) { - if (finished) return - this.keys.add(key) - } - - override fun record(keys: Collection) { - if (finished) return - this.keys.addAll(keys) - } - - fun record(action: ScrollToHelperScope.() -> Unit) { - startRecord() - action() - endRecord() - } - - fun scrollToItem( - key: Any, - animateTo: Boolean = false, - scrollOffset: Int = 0, - delay: Long = 0L - ) { - onScrollTo(delay, scrollOffset, animateTo) { - keys.indexOf(key) - .takeIf { it >= 0 } - } - } -} - -@Composable -fun rememberLazyListScrollToHelper( - listState: LazyListState -): LazyListScrollToHelper { - val scope = rememberCoroutineScope() - - return remember { - LazyListScrollToHelper { delayTimeMillis, scrollOffset, animateTo, action -> - scope.launch { - delay(delayTimeMillis) - val index = action() ?: return@launch - if (animateTo) { - listState.animateScrollToItem( - index = index, - scrollOffset = scrollOffset - ) - } else { - listState.scrollToItem( - index = index, - scrollOffset = scrollOffset - ) - } - } - } - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt b/component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt index c98d2247d..31f6979d7 100644 --- a/component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt +++ b/component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt @@ -3,28 +3,7 @@ package com.lalilu.component.viewmodel import android.app.Application import android.content.Context import android.content.SharedPreferences -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.lalilu.common.base.BaseSp -import com.lalilu.component.extension.toState -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.BaseMatchable -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmedia.extension.GroupIdentity -import com.lalilu.lmedia.extension.ListAction -import com.lalilu.lmedia.extension.SortDynamicAction -import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lmedia.extension.Sortable -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch class SongsSp(private val context: Context) : BaseSp() { override fun obtainSourceSp(): SharedPreferences { @@ -34,122 +13,3 @@ class SongsSp(private val context: Context) : BaseSp() { ) } } - -@OptIn(ExperimentalCoroutinesApi::class) -class SongsViewModel(val sp: SongsSp) : ViewModel() { - private val showAllFlow: MutableStateFlow = MutableStateFlow(false) - private val songIdsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) - private val songsSource = songIdsFlow.flatMapLatest { mediaIds -> - showAllFlow.flatMapLatest { showAll -> - if (mediaIds.isEmpty() && showAll) { - LMedia.getFlow() - } else { - LMedia.flowMapBy(mediaIds) - } - } - } - - private val searcher = ItemsBaseSearcher(songsSource) - private val sorter = ItemsBaseSorter(sourceFlow = searcher.output, sp = sp) - val output = sorter.output.toState(emptyMap(), viewModelScope) - - fun updateByIds( - songIds: List, - showAll: Boolean = false, - sortFor: String = Sortable.SORT_FOR_SONGS, - supportSortRules: List? = null, - ) = viewModelScope.launch { - songIdsFlow.value = songIds - showAllFlow.value = showAll - sorter.updateSortFor( - sortFor = sortFor, - supportSortRules = supportSortRules, - ) - } -} - -inline fun Collection.findInstance(check: (T) -> Boolean): T? { - return this.filterIsInstance(T::class.java) - .firstOrNull(check) -} - -@OptIn(ExperimentalCoroutinesApi::class) -class ItemsBaseSorter( - sourceFlow: Flow>, - private val sp: BaseSp -) { - private val supportListActionFlow = MutableStateFlow>(emptySet()) - private val sortForFlow = MutableStateFlow(Sortable.SORT_FOR_SONGS) - - private val sortRuleFlow = sortForFlow.flatMapLatest { sortFor -> - supportListActionFlow.flatMapLatest { supportActions -> - sp.obtain("${sortFor}_SORT_RULE") - .flow(true) - .mapLatest { key -> - key?.let { supportActions.findInstance { it::class.java.name == key } } - ?: SortStaticAction.Normal - } - } - } - - private val reverseOrderFlow = sortForFlow.flatMapLatest { sortFor -> - sp.obtain("${sortFor}_SORT_RULE_REVERSE_ORDER", false) - .flow(true) - .mapLatest { it ?: false } - } - - private val flattenOverrideFlow = sortForFlow.flatMapLatest { sortFor -> - sp.obtain("${sortFor}_SORT_RULE_FLATTEN_OVERRIDE", false) - .flow(true) - .mapLatest { it ?: false } - } - - val output: Flow>> = sortRuleFlow.flatMapLatest { action -> - reverseOrderFlow.flatMapLatest { reverse -> - when (action) { - is SortStaticAction -> sourceFlow.mapLatest { action.doSort(it, reverse) } - is SortDynamicAction -> action.doSort(sourceFlow, reverse) - else -> flowOf(emptyMap()) - } - } - }.flatMapLatest { result -> - flattenOverrideFlow.mapLatest { - if (it) mapOf(GroupIdentity.None to result.values.flatten()) else result - } - } - - fun updateSortFor( - sortFor: String, - supportSortRules: Collection?, - ) { - sortForFlow.value = sortFor - supportListActionFlow.value = supportSortRules?.toSet() ?: emptySet() - } -} - - -@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) -open class ItemsBaseSearcher( - sourceFlow: Flow> -) { - private val keywordStr = MutableStateFlow("") - private val keywords = keywordStr - .debounce { if (it.isEmpty()) 0 else 200 } - .mapLatest { - if (it.isEmpty()) return@mapLatest emptyList() - it.trim().uppercase().split(' ') - } - - val output = sourceFlow.combine(keywords) { items, keywordList -> - if (keywordList.isEmpty()) return@combine items - items.filter { item -> keywordList.all { item.matchStr.contains(it) } } - } - - fun search(keyword: String) { - keywordStr.value = keyword - } - - fun clear() { - keywordStr.value = "" - } -} \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index 952c2d02a..cd62c6e3d 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -102,7 +102,6 @@ private fun AlbumsScreen( title: String = "全部专辑", albumsSM: AlbumsScreenModel, playingVM: IPlayingViewModel = koinInject(), - sortFor: String = Sortable.SORT_FOR_ALBUMS, ) { val isPad = LocalWindowSize.current.widthSizeClass == WindowWidthSizeClass.Expanded val albumsState = albumsSM.albums.collectAsLoadingState() diff --git a/lartist/build.gradle.kts b/lartist/build.gradle.kts index 3420ab214..fb5057153 100644 --- a/lartist/build.gradle.kts +++ b/lartist/build.gradle.kts @@ -36,4 +36,5 @@ composeCompiler { dependencies { implementation(project(":component")) + ksp(libs.koin.compiler) } \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/ArtistModule.kt b/lartist/src/main/java/com/lalilu/lartist/ArtistModule.kt index 1f7a81b5a..02a4573d7 100644 --- a/lartist/src/main/java/com/lalilu/lartist/ArtistModule.kt +++ b/lartist/src/main/java/com/lalilu/lartist/ArtistModule.kt @@ -1,11 +1,8 @@ package com.lalilu.lartist -import com.lalilu.lartist.screen.ArtistDetailScreenModel -import com.lalilu.lartist.screen.ArtistsScreenModel -import org.koin.core.module.dsl.factoryOf -import org.koin.dsl.module +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module -val ArtistModule = module { - factoryOf(::ArtistsScreenModel) - factoryOf(::ArtistDetailScreenModel) -} \ No newline at end of file +@Module +@ComponentScan("com.lalilu.lartist") +object ArtistModule diff --git a/lartist/src/main/java/com/lalilu/lartist/component/ArtistCard.kt b/lartist/src/main/java/com/lalilu/lartist/component/ArtistCard.kt index 3e7dfa067..9154a2bc6 100644 --- a/lartist/src/main/java/com/lalilu/lartist/component/ArtistCard.kt +++ b/lartist/src/main/java/com/lalilu/lartist/component/ArtistCard.kt @@ -1,8 +1,7 @@ package com.lalilu.lartist.component -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.MarqueeSpacing -import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -23,7 +22,9 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -38,36 +39,27 @@ import coil3.request.placeholder import com.lalilu.component.R import com.lalilu.component.card.PlayingTipIcon import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.lmedia.entity.LArtist -@Composable -fun ArtistCard( - artist: LArtist, - isPlaying: () -> Boolean = { false }, - onClick: () -> Unit = {} -) = ArtistCard( - songCount = artist.requireItemsCount(), - title = artist.requireTitle(), - isPlaying = isPlaying, - imageSource = { artist.songs.firstOrNull()?.imageSource }, - onClick = onClick -) - -@OptIn(ExperimentalFoundationApi::class) @Composable fun ArtistCard( modifier: Modifier = Modifier, - songCount: Long, title: String, subTitle: String? = null, + songCount: Long, + isSelected: () -> Boolean = { false }, imageSource: () -> Any? = { null }, isPlaying: () -> Boolean = { false }, onClick: () -> Unit = {} ) { + val bgColor = animateColorAsState( + targetValue = if (isSelected()) MaterialTheme.colors.onBackground.copy(0.3f) + else Color.Transparent, label = "" + ) + Row( modifier = modifier .clickable(onClick = onClick) - .background(dayNightTextColor(0.05f)) + .drawBehind { drawRect(bgColor.value) } .fillMaxWidth() .heightIn(min = 64.dp) .wrapContentHeight() @@ -93,7 +85,7 @@ fun ArtistCard( text = title, fontSize = 14.sp, fontWeight = FontWeight.Black, - color = dayNightTextColor(), + color = MaterialTheme.colors.onBackground, style = MaterialTheme.typography.subtitle1, overflow = TextOverflow.Ellipsis ) @@ -104,7 +96,7 @@ fun ArtistCard( maxLines = 1, text = subTitle, fontSize = 10.sp, - color = dayNightTextColor(0.5f), + color = MaterialTheme.colors.onBackground.copy(0.5f), style = MaterialTheme.typography.subtitle2 ) } @@ -150,7 +142,7 @@ fun ArtistCard( text = "$songCount 首歌曲", maxLines = 1, fontSize = 12.sp, - color = dayNightTextColor(), + color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis ) diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt index 24a245068..ce38130ee 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt @@ -2,33 +2,43 @@ package com.lalilu.lartist.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.ScreenInfo import com.lalilu.component.base.collectAsLoadingState +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.lartist.R import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LArtist +import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch + +@Destination("/pages/artist/detail") data class ArtistDetailScreen( private val artistName: String -) : DynamicScreen() { +) : Screen, ScreenInfoFactory { override val key: ScreenKey = "ARTIST_DETAIL_$artistName" - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.artist_screen_detail, - ) + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.artist_screen_detail) }, + ) + } @Composable override fun Content() { - val artistDetailSM: ArtistDetailScreenModel = getScreenModel() + val artistDetailSM: ArtistDetailScreenModel = + rememberScreenModel { ArtistDetailScreenModel() } LaunchedEffect(Unit) { artistDetailSM.updateArtistName(artistName) @@ -49,7 +59,7 @@ class ArtistDetailScreenModel : ScreenModel { } @Composable -private fun DynamicScreen.ArtistDetail( +private fun ArtistDetail( artistDetailSM: ArtistDetailScreenModel ) { val artistState = artistDetailSM.artist.collectAsLoadingState() diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt deleted file mode 100644 index 82034741b..000000000 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.lalilu.lartist.screen - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope -import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.LoadingScaffold -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenInfo -import com.lalilu.component.base.collectAsLoadingState -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lartist.R -import com.lalilu.lartist.component.ArtistCard -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LArtist -import com.lalilu.lmedia.entity.LSong -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import com.lalilu.component.R as ComponentR - -data class ArtistsScreen( - val artistsName: List = emptyList() -) : DynamicScreen() { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.artist_screen_title, - icon = ComponentR.drawable.ic_user_line - ) - - @Composable - override fun Content() { - val artistsSM = getScreenModel() - - LaunchedEffect(Unit) { - artistsSM.updateArtistsName(artistsName) - } - - ArtistsScreen(artistsSM = artistsSM) - } -} - -@OptIn(ExperimentalCoroutinesApi::class) -class ArtistsScreenModel : ScreenModel { - private val artistsName = MutableStateFlow>(emptyList()) - val artists = artistsName.flatMapLatest { - if (it.isEmpty()) LMedia.getFlow() - else LMedia.flowMapBy(it) - } - - fun updateArtistsName(artistsName: List) = screenModelScope.launch { - this@ArtistsScreenModel.artistsName.emit(artistsName) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun DynamicScreen.ArtistsScreen( - artistsSM: ArtistsScreenModel, - playingVM: IPlayingViewModel = koinInject() -) { - val artistsState = artistsSM.artists.collectAsLoadingState() - - LoadingScaffold( - modifier = Modifier.fillMaxSize(), - targetState = artistsState - ) { artists -> - LLazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - NavigatorHeader( - modifier = Modifier.statusBarsPadding(), - title = stringResource(id = R.string.artist_screen_title), - subTitle = stringResource(id = R.string.artist_screen_title) - ) - } - - itemsIndexed( - items = artists, - key = { _, item -> item.id }, - contentType = { _, _ -> LArtist::class } - ) { index, item -> - ArtistCard( - modifier = Modifier.animateItemPlacement(), - title = item.name, - subTitle = "#$index", - songCount = item.requireItemsCount(), - imageSource = { item.songs.firstOrNull()?.imageSource }, - isPlaying = { - playingVM.isItemPlaying { playing -> - playing.let { it as? LSong } - ?.let { song -> song.artists.any { it.name == item.name } } - ?: false - } - }, - onClick = { - AppRouter.intent( - NavIntent.Push(ArtistDetailScreen(item.id)) - ) - } - ) - } - } - } -} \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt new file mode 100644 index 000000000..8f902d343 --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt @@ -0,0 +1,160 @@ +package com.lalilu.lartist.screen.artists + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import com.blankj.utilcode.util.ToastUtils +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.base.songs.SongsHeaderJumperDialog +import com.lalilu.component.base.songs.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSelectorPanel +import com.lalilu.component.base.songs.SongsSortPanelDialog +import com.lalilu.component.extension.DialogWrapper +import com.lalilu.lartist.R +import com.lalilu.lartist.viewModel.ArtistsSM +import com.lalilu.lartist.viewModel.ArtistsScreenAction +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.remixicon.Design +import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.System +import com.lalilu.remixicon.UserAndFaces +import com.lalilu.remixicon.design.editBoxLine +import com.lalilu.remixicon.design.focus3Line +import com.lalilu.remixicon.editor.sortDesc +import com.lalilu.remixicon.system.checkboxMultipleBlankLine +import com.lalilu.remixicon.system.checkboxMultipleLine +import com.lalilu.remixicon.system.menuSearchLine +import com.lalilu.remixicon.userandfaces.userLine +import com.zhangke.krouter.annotation.Destination + +@Destination("/pages/artists") +object ArtistsScreen : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { + private fun readResolve(): Any = ArtistsScreen + private var artistsSM: ArtistsSM? = null + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.artist_screen_title) }, + icon = RemixIcon.UserAndFaces.userLine + ) + } + + @Composable + override fun provideScreenActions(): List { + return remember { + listOf( + ScreenAction.Static( + title = { "排序" }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, + onAction = { artistsSM?.showSortPanel?.value = true } + ), + ScreenAction.Static( + title = { "选择" }, + icon = { RemixIcon.Design.editBoxLine }, + color = { Color(0xFF009673) }, + onAction = { artistsSM?.selector?.isSelecting?.value = true } + ), + ScreenAction.Static( + title = { "搜索" }, + subTitle = { + val isSearching = artistsSM?.searcher?.isSearching + + if (isSearching?.value == true) "搜索中: ${artistsSM?.searcher?.keywordState?.value}" + else null + }, + icon = { RemixIcon.System.menuSearchLine }, + color = { Color(0xFF8BC34A) }, + dotColor = { + val isSearching = artistsSM?.searcher?.isSearching + + if (isSearching?.value == true) Color.Red + else null + }, + onAction = { + artistsSM?.showSearcherPanel?.value = true + DialogWrapper.dismiss() + } + ), + ScreenAction.Static( + title = { "定位当前播放所属" }, + icon = { RemixIcon.Design.focus3Line }, + color = { Color(0xFF8700FF) }, + onAction = { artistsSM?.doAction(ArtistsScreenAction.LocaleToPlayingItem) } + ), + ) + } + } + + @Composable + override fun Content() { + val sm = rememberScreenModel { ArtistsSM() } + .also { artistsSM = it } + + SongsSortPanelDialog( + isVisible = sm.showSortPanel, + supportSortActions = sm.supportSortActions, + isSortActionSelected = { sm.sorter.isSortActionSelected(it) }, + onSelectSortAction = { sm.sorter.selectSortAction(it) } + ) + + SongsHeaderJumperDialog( + isVisible = sm.showJumperDialog, + items = { sm.recorder.list().filterIsInstance() }, + onSelectItem = { sm.doAction(ArtistsScreenAction.LocaleToGroupItem(it)) } + ) + + SongsSearcherPanel( + isVisible = sm.showSearcherPanel, + keyword = { sm.searcher.keywordState.value }, + onUpdateKeyword = { sm.searcher.keywordState.value = it } + ) + + SongsSelectorPanel( + isVisible = sm.selector.isSelecting, + screenActions = listOfNotNull( + ScreenAction.Static( + title = { "全选" }, + color = { Color(0xFF00ACF0) }, + icon = { RemixIcon.System.checkboxMultipleLine }, + onAction = { + val artists = sm.artists.value.values.flatten() + sm.selector.selectAll(artists) + } + ), + ScreenAction.Static( + title = { "取消全选" }, + icon = { RemixIcon.System.checkboxMultipleBlankLine }, + color = { Color(0xFFFF5100) }, + onAction = { sm.selector.clear() } + ), + ScreenAction.Static( + title = { "添加到播放列表" }, + icon = { RemixIcon.System.checkboxMultipleBlankLine }, + color = { Color(0xFF002FB9) }, + onAction = { + // TODO 选择歌手后将其列表下歌曲添加到播放列表 + ToastUtils.showShort("开发中,敬请期待") + } + ), + ) + ) + + ArtistsScreenContent( + artistsSM = sm, + isSelecting = { sm.selector.isSelecting.value }, + isSelected = { sm.selector.isSelected(it) }, + onSelect = { sm.selector.onSelect(it) }, + onClickGroup = { sm.showJumperDialog.value = true } + ) + } +} \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt new file mode 100644 index 000000000..0c4281b86 --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt @@ -0,0 +1,180 @@ +package com.lalilu.lartist.screen.artists + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gigamole.composefadingedges.FadingEdgesGravity +import com.gigamole.composefadingedges.content.FadingEdgesContentType +import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig +import com.gigamole.composefadingedges.fill.FadingEdgesFillType +import com.gigamole.composefadingedges.verticalFadingEdges +import com.lalilu.component.base.songs.SongsScreenScrollBar +import com.lalilu.component.base.songs.SongsScreenStickyHeader +import com.lalilu.component.extension.rememberLazyListAnimateScroller +import com.lalilu.component.extension.startRecord +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent +import com.lalilu.component.viewmodel.IPlayingViewModel +import com.lalilu.lartist.component.ArtistCard +import com.lalilu.lartist.screen.ArtistDetailScreen +import com.lalilu.lartist.viewModel.ArtistsSM +import com.lalilu.lartist.viewModel.ArtistsScreenEvent +import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import kotlinx.coroutines.flow.collectLatest +import org.koin.compose.koinInject + + +@Composable +internal fun ArtistsScreenContent( + artistsSM: ArtistsSM, + playingVM: IPlayingViewModel = koinInject(), + isSelecting: () -> Boolean = { false }, + isSelected: (LArtist) -> Boolean = { false }, + onSelect: (LArtist) -> Unit = {}, + onClickGroup: (GroupIdentity) -> Unit = {}, +) { + val artists by artistsSM.artists + val listState = rememberLazyListState() + val statusBar = WindowInsets.statusBars + val density = LocalDensity.current + val scroller = rememberLazyListAnimateScroller( + listState = listState, + keysKeeper = { artistsSM.recorder.list().filterNotNull() } + ) + + LaunchedEffect(Unit) { + artistsSM.eventFlow.collectLatest { event -> + when (event) { + is ArtistsScreenEvent.ScrollToItem -> { + scroller.animateTo(event.key) + } + + else -> {} + } + } + } + + SongsScreenScrollBar( + modifier = Modifier.fillMaxSize(), + listState = listState + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .verticalFadingEdges( + length = statusBar + .asPaddingValues() + .calculateTopPadding(), + contentType = FadingEdgesContentType.Dynamic.Lazy.List( + scrollConfig = FadingEdgesScrollConfig.Dynamic(), + state = listState + ), + gravity = FadingEdgesGravity.Start, + fillType = remember { + FadingEdgesFillType.FadeClip( + fillStops = Triple(0f, 0.7f, 1f) + ) + } + ), + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + startRecord(artistsSM.recorder) { + itemWithRecord(key = "艺术家") { + val count = remember(artists) { artists.values.flatten().size } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "艺术家", + fontSize = 20.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + Text( + text = "共 $count 位艺术家", + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + } + + artists.forEach { (group, list) -> + if (group !is GroupIdentity.None) { + stickyHeaderWithRecord( + key = group, + contentType = "group" + ) { + SongsScreenStickyHeader( + modifier = Modifier.animateItem(), + listState = listState, + group = group, + minOffset = { statusBar.getTop(density) }, + onClickGroup = onClickGroup + ) + } + } + + itemsIndexedWithRecord( + items = list, + key = { _, item -> item.id }, + contentType = { _, _ -> LArtist::class } + ) { index, item -> + ArtistCard( + modifier = Modifier.animateItem(), + title = item.name, + subTitle = "#$index", + isSelected = { isSelected(item) }, + songCount = item.songs.size.toLong(), + imageSource = { item.songs.firstOrNull()?.imageSource }, + isPlaying = { + playingVM.isItemPlaying { playing -> + playing.let { it as? LSong } + ?.let { song -> song.artists.any { it.name == item.name } } + ?: false + } + }, + onClick = { + if (isSelecting()) { + onSelect(item) + } else { + AppRouter.intent(NavIntent.Push(ArtistDetailScreen(item.id))) + } + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt new file mode 100644 index 000000000..196484c1d --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt @@ -0,0 +1,94 @@ +package com.lalilu.lartist.viewModel + +import androidx.compose.runtime.mutableStateOf +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.lalilu.component.base.songs.ItemSearcher +import com.lalilu.component.base.songs.ItemSorter +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lplayer.LPlayer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +internal sealed interface ArtistsScreenAction { + data object LocaleToPlayingItem : ArtistsScreenAction + data class LocaleToGroupItem(val item: GroupIdentity) : ArtistsScreenAction +} + +internal sealed interface ArtistsScreenEvent { + data class ScrollToItem(val key: Any) : ArtistsScreenEvent +} + +internal class ArtistsSM : ScreenModel { + // 持久化元素的状态 + val showSortPanel = mutableStateOf(false) + val showJumperDialog = mutableStateOf(false) + val showSearcherPanel = mutableStateOf(false) + val supportSortActions = setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.ItemsCount, + SortStaticAction.Duration, + SortStaticAction.AddTime, + SortStaticAction.Shuffle, + ).filterNotNull() + .toSet() + + // 数据流 + private fun flow(): Flow> = LMedia.getFlow() + val searcher = ItemSearcher(flow()) + val sorter = ItemSorter(searcher.output, supportSortActions) + val artists = sorter.output.toState( + defaultValue = emptyMap(), + scope = screenModelScope, + ) + + val selector = ItemSelector() + val recorder = ItemRecorder() + + + private val _eventFlow = MutableSharedFlow() + val eventFlow: SharedFlow = _eventFlow + + fun doAction(action: ArtistsScreenAction) = screenModelScope.launch { + when (action) { + ArtistsScreenAction.LocaleToPlayingItem -> { + // 获取正在播放的元素ID + val mediaId = LPlayer.runtime.info.playingIdFlow.value + ?: return@launch + + // 获取该元素 + val item = LMedia.get(mediaId) + ?: return@launch + + // 获取该元素的所属分组ID + val artistsIds = item.artists + .map { it.id } + .takeIf { it.isNotEmpty() } + ?: return@launch + + // 获取第一个存在与列表中的元素的Index + val list = recorder.list() + artistsIds.firstOrNull { list.contains(it) }?.let { + _eventFlow.emit(ArtistsScreenEvent.ScrollToItem(it)) + } + } + + is ArtistsScreenAction.LocaleToGroupItem -> { + _eventFlow.emit(ArtistsScreenEvent.ScrollToItem(action.item)) + } + + else -> {} + } + } +} \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsViewModel.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsViewModel.kt deleted file mode 100644 index d1eb46ec5..000000000 --- a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsViewModel.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.lalilu.lartist.viewModel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.lalilu.component.extension.toState -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LArtist -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine - -class ArtistsViewModel : ViewModel() { - private val artistIds = MutableStateFlow>(emptyList()) - private val artistSource = LMedia.getFlow().combine(artistIds) { artists, ids -> - if (ids.isEmpty()) return@combine artists - artists.filter { artist -> artist.name in ids } - } - - val artists = artistSource - .toState(emptyList(), viewModelScope) -} \ No newline at end of file diff --git a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt b/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt index 07528d0f9..3ba53142d 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt @@ -23,7 +23,7 @@ class SortRulePlayCount( return historyRepo .getHistoriesIdsMapWithCount() .combine(items) { map, sources -> - sources.sortedByDescending { song -> map[song.requireId()] } + sources.sortedByDescending { song -> map[song.getValueBy(Sortable.COMPARE_KEY_ID)] } .let { if (reverse) it.reversed() else it } .let { mapOf(GroupIdentity.None to it) } } @@ -43,7 +43,7 @@ class SortRuleLastPlayTime( return historyRepo .getHistoriesIdsMapWithLastTime() .combine(items) { map, sources -> - sources.sortedByDescending { song -> map[song.requireId()] } + sources.sortedByDescending { song -> map[song.getValueBy(Sortable.COMPARE_KEY_ID)] } .let { if (reverse) it.reversed() else it } .let { mapOf(GroupIdentity.None to it) } } diff --git a/lmedia b/lmedia index d4f9219bd..29c2dde9e 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit d4f9219bd68e1f6ade25325d6457567857f1fc05 +Subproject commit 29c2dde9e19ad440e5dde27cc00a474e30ad9d3b diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt index b8c1304d9..22637a269 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt @@ -98,7 +98,7 @@ abstract class LService : MediaBrowserServiceCompat(), LifecycleOwner, Playback. runtime.info.updateIsPlaying(isPlaying) runtime.info.updatePosition(startPosition = position, isPlaying = isPlaying) - mediaSession.setMetadata(item?.metaDataCompat) + mediaSession.setMetadata(item?.provideMediaData()) mediaSession.setPlaybackState( PlaybackStateCompat.Builder() .setActions(LPlayer.MEDIA_DEFAULT_ACTION) diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt index 9b85959bf..85378208e 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt @@ -46,7 +46,6 @@ import com.lalilu.component.base.DynamicScreen import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.ScreenAction import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.rememberLazyListScrollToHelper import com.lalilu.component.extension.toMutableState import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent @@ -177,7 +176,6 @@ private fun DynamicScreen.PlaylistCreateOrEditScreen( createOrEditSM: PlaylistCreateOrEditScreenModel ) { val state = rememberLazyListState() - val scrollToHelper = rememberLazyListScrollToHelper(listState = state) val onFocusCallback: (String) -> Unit = remember { { @@ -204,10 +202,8 @@ private fun DynamicScreen.PlaylistCreateOrEditScreen( verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - scrollToHelper.startRecord() item(key = "header") { - scrollToHelper.doRecord("header") NavigatorHeader( modifier = Modifier.statusBarsPadding(), title = stringResource(id = headerTitleRes), @@ -219,7 +215,7 @@ private fun DynamicScreen.PlaylistCreateOrEditScreen( title = "主标题", minLines = 1, value = createOrEditSM.title, - onInit = { scrollToHelper.doRecord(it) }, + onInit = { }, onFocus = onFocusCallback ) @@ -227,7 +223,7 @@ private fun DynamicScreen.PlaylistCreateOrEditScreen( title = "简介/备注", minLines = 3, value = createOrEditSM.subTitle, - onInit = { scrollToHelper.doRecord(it) }, + onInit = { }, onFocus = onFocusCallback ) } From b4e048e267af89a67831f3486da4ed866e8adc69 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 9 Sep 2024 21:31:26 +0800 Subject: [PATCH 085/213] =?UTF-8?q?[refactor]=E5=B0=9D=E8=AF=95=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=E6=BB=9A=E5=8A=A8=E8=87=B3StickyHeader=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=97=B6=E6=BB=9A=E5=8A=A8=E6=95=88=E6=9E=9C=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/songs/SongsScreenContent.kt | 14 +- .../lalilu/component/base/songs/SongsSM.kt | 7 +- .../extension/LazyListAnimateScroller.kt | 158 +++++++++++++----- 3 files changed, 129 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index 5b09a5a0e..51f8bdadf 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -66,7 +66,19 @@ internal fun SongsScreenContent( songsSM.event().collectLatest { event -> when (event) { is SongsScreenEvent.ScrollToItem -> { - scroller.animateTo(event.key) + scroller.animateTo( + key = event.key, + isStickyHeader = { it.contentType == "group" }, + offsetBlock = { state -> + // TODO 待完善StickyHeader Item的offset计算逻辑 + val lastGroupItemOffset = state.layoutInfo.visibleItemsInfo + .lastOrNull { it.contentType == "group" } + ?.takeIf { it.key != event.key } + ?.size ?: 0 + + -(statusBar.getTop(density) + lastGroupItemOffset) + } + ) } else -> {} diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt index 1835fc9da..653080706 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt @@ -87,12 +87,7 @@ class SongsSM( } is SongsScreenAction.LocaleToGroupItem -> { - val index = recorder.list() - .indexOf(action.item) - .takeIf { it >= 0 } - ?: return@launch - - eventFlow.emit(SongsScreenEvent.ScrollToItem(index)) + eventFlow.emit(SongsScreenEvent.ScrollToItem(action.item)) } else -> {} diff --git a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt index 947bf2cf0..2b615316c 100644 --- a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt +++ b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt @@ -1,11 +1,9 @@ package com.lalilu.component.extension +import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -14,6 +12,7 @@ import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce import androidx.dynamicanimation.animation.springAnimationOf import androidx.dynamicanimation.animation.withSpringForceProperties +import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,38 +29,67 @@ class LazyListAnimateScroller internal constructor( private val listState: LazyListState, private val scope: CoroutineScope ) { - private val currentValue = mutableFloatStateOf(0f) - private val targetValue = mutableFloatStateOf(0f) - private val targetRange = mutableStateOf(IntRange(0, 0)) - private val sizeMap = mutableStateMapOf() + /** + * 滚动任务,用于缓存每一次的主动滚动 + * + * @param key 目标元素key + * @param offsetBlock 当目标元素可见时,计算与顶部偏移量的回调 + * @param isStickyHeader 判断元素是否StickyHeader + * @param onEnd 滚动结束的回调 + */ + data class ScrollTask( + val key: Any, + val onEnd: (isCanceled: Boolean) -> Unit = {}, + val isStickyHeader: (LazyListItemInfo) -> Boolean = { false }, + val offsetBlock: (LazyListState) -> Int = { 0 } + ) { + var isRectified = false + var isFinished = false + var targetIndex = -1 + } - private var exactAnimation: Boolean = false - private var targetIndex: Int = -1 + private var task: ScrollTask? = null + private var currentValue: Float = 0f + private var targetValue: Float = 0f + private var targetRange: IntRange = IntRange(0, 0) + private val sizeMap = mutableMapOf() private val animator: SpringAnimation = springAnimationOf( - getter = { currentValue.floatValue }, - setter = { - onScroll(it - currentValue.floatValue) - currentValue.floatValue = it - }, + getter = { currentValue }, + setter = { onScroll(dy = it - currentValue); currentValue = it }, finalPosition = 0f ).withSpringForceProperties { dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY stiffness = SpringForce.STIFFNESS_VERY_LOW - }.addUpdateListener { animation, value, velocity -> - val percent = if (targetValue.floatValue <= 0) 0f else value / targetValue.floatValue - - if (percent > 0.5f && !exactAnimation && targetIndex != -1) { - val tempIndex = targetIndex - scope.launch { scrollTo(tempIndex) } - targetIndex = -1 + }.addUpdateListener { _, _, _ -> + task?.apply { + // 若尚未纠正位移,且目标元素处于可见范围内,则重新启动计算动画位移 + if (!isRectified && isItemVisible(targetIndex)) { + calcAndStartAnimation() + isRectified = true + } } - }.addEndListener { animation, canceled, value, velocity -> - if (!canceled) { - targetRange.value = IntRange.EMPTY + }.addEndListener { _, canceled, _, _ -> + task?.apply { + // 若结束后,目标元素不在可见范围内,则重启计算动画 + if (!canceled && !isRectified && !isItemVisible(targetIndex)) { + calcAndStartAnimation() + return@apply + } + + isFinished = true + onEnd(canceled) + task = null } } + private fun isItemVisible(index: Int): Boolean { + val startIndex = listState.firstVisibleItemIndex + val endIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + return index in startIndex..endIndex + } + private fun onScroll(dy: Float) { if (!enable()) return @@ -87,34 +115,81 @@ class LazyListAnimateScroller internal constructor( .launchIn(this) } - fun animateTo(key: Any) = scope.launch { + fun animateTo( + key: Any, + onEnd: (Boolean) -> Unit = {}, + isStickyHeader: (LazyListItemInfo) -> Boolean = { false }, + offsetBlock: (LazyListState) -> Int = { 0 }, + ) = animateTo( + ScrollTask( + key = key, + onEnd = onEnd, + isStickyHeader = isStickyHeader, + offsetBlock = offsetBlock + ) + ) + + fun animateTo(scrollTask: ScrollTask) { + task = scrollTask + calcAndStartAnimation() + } + + private fun calcAndStartAnimation() = scope.launch { + // 若当前没有滚动任务,则不继续执行 + task ?: return@launch + + val key = task!!.key + val offsetBlock = task!!.offsetBlock + // 1. 从当前可见元素直接查找offset (准确值) // get the offset directly from the visibleItemsInfo - val offset = listState.layoutInfo.visibleItemsInfo - .firstOrNull { it.key == key } - ?.offset + val tempIndex = listState.layoutInfo.visibleItemsInfo.indexOfFirst { it.key == key } + val targetItem = listState.layoutInfo.visibleItemsInfo.getOrNull(tempIndex) + + val targetOffset = targetItem?.let { item -> + val isSticky = task!!.isStickyHeader.invoke(item) + + // 若目标元素不是stickyHeader,则直接返回offset + if (!isSticky) { + return@let item.offset + } - if (offset != null) { - doScroll(offset.toFloat(), true) + // 若目标时是stickyHeader,则查找下一个元素, + // 若下一个元素的offset等于当前元素的offset + size, + // 则返回当前元素的offset + val nextItem = listState.layoutInfo.visibleItemsInfo.getOrNull(tempIndex + 1) + // TODO 待完善StickyHeader Item的offset计算逻辑 + LogUtils.i("nexItem: ${nextItem?.offset} == ${item.offset} + ${item.size}") + if (nextItem != null && nextItem.offset == (item.offset + item.size)) { + return@let item.offset + } + null + } + + if (targetOffset != null) { + doScroll(targetOffset.toFloat() + offsetBlock(listState)) return@launch } val index = keysKeeper().indexOfFirst { it == key } - if (index == -1) return@launch // 元素不存在keys列表中,则不进行滚动 + if (index == -1) { + task = null + return@launch // 元素不存在keys列表中,则不进行滚动 + } // 2. 使用实时维护的sizeMap查找并计算目标元素的offset (非准确值) // Use the real-time maintained sizeMap to find and calculate the offset of the target element + task?.targetIndex = index scrollTo(index) } private fun doScroll( targetOffset: Float, - isExactScroll: Boolean = false ) { animator.cancel() - exactAnimation = isExactScroll - currentValue.floatValue = 0f - targetValue.floatValue = targetOffset + + currentValue = 0f + targetValue = targetOffset animator.animateToFinalPosition(targetOffset) } @@ -123,7 +198,7 @@ class LazyListAnimateScroller internal constructor( if (!isActive) return@withContext val firstVisibleIndex = listState.firstVisibleItemIndex val firstVisibleOffset = listState.firstVisibleItemScrollOffset - targetRange.value = minOf(firstVisibleIndex, index)..maxOf(firstVisibleIndex, index) + targetRange = minOf(firstVisibleIndex, index)..maxOf(firstVisibleIndex, index) // 计算方向乘数,向下滚动则为正数 // calculate the direction multiplier, if scrolling down, it's positive @@ -133,11 +208,11 @@ class LazyListAnimateScroller internal constructor( // 计算目标距离,若未缓存有相应位置的值,则计算使用平均值 // calculate the target offset,if these no value cached then use the average value val sizeAverage = sizeMap.values.average().toInt() - val sizeSum = targetRange.value.sumOf { - if (it == targetRange.value.last) return@sumOf 0 + val sizeSum = targetRange.sumOf { + if (it == targetRange.last) return@sumOf 0 sizeMap.getOrPut(it) { sizeAverage } } - val spacingSum = (targetRange.value.last - targetRange.value.first) * + val spacingSum = (targetRange.last - targetRange.first) * listState.layoutInfo.mainAxisItemSpacing.toFloat() var offsetTemp = sizeSum + spacingSum @@ -148,10 +223,7 @@ class LazyListAnimateScroller internal constructor( // 使用非准确值进行滚动 // use the non-accurate value for scrolling if (!isActive) return@withContext - targetIndex = index - doScroll(offsetTemp * forwardMultiple, false) - - println("[calculate target]: ${targetValue.floatValue} -> range: [${targetRange.value.first} -> ${targetRange.value.last}]") + doScroll(offsetTemp * forwardMultiple) } } From 4d3cfa618f295aa5374ff626f07d2c2f5e480a9e Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 12 Sep 2024 21:44:05 +0800 Subject: [PATCH 086/213] =?UTF-8?q?[refactor]=E8=B0=83=E6=95=B4=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E7=9A=84=E9=A9=B1=E5=8A=A8=E6=96=B9=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E5=8E=BB=E9=99=A4dynamicanimation=E5=BA=93=E7=9A=84=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=EF=BC=8C=E6=9B=BF=E6=8D=A2=E6=88=90Compose=E7=9A=84An?= =?UTF-8?q?imatable=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extension/LazyListAnimateScroller.kt | 178 ++++++++---------- 1 file changed, 74 insertions(+), 104 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt index 2b615316c..2ede63466 100644 --- a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt +++ b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt @@ -1,5 +1,9 @@ package com.lalilu.component.extension +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable @@ -8,18 +12,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow -import androidx.dynamicanimation.animation.SpringAnimation -import androidx.dynamicanimation.animation.SpringForce -import androidx.dynamicanimation.animation.springAnimationOf -import androidx.dynamicanimation.animation.withSpringForceProperties -import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -41,70 +38,18 @@ class LazyListAnimateScroller internal constructor( val key: Any, val onEnd: (isCanceled: Boolean) -> Unit = {}, val isStickyHeader: (LazyListItemInfo) -> Boolean = { false }, - val offsetBlock: (LazyListState) -> Int = { 0 } + val offsetBlock: (LazyListItemInfo) -> Int = { 0 } ) { var isRectified = false var isFinished = false var targetIndex = -1 } + private val animation by lazy { Animatable(0f, Float.VectorConverter) } private var task: ScrollTask? = null - private var currentValue: Float = 0f - private var targetValue: Float = 0f private var targetRange: IntRange = IntRange(0, 0) private val sizeMap = mutableMapOf() - private val animator: SpringAnimation = springAnimationOf( - getter = { currentValue }, - setter = { onScroll(dy = it - currentValue); currentValue = it }, - finalPosition = 0f - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_VERY_LOW - }.addUpdateListener { _, _, _ -> - task?.apply { - // 若尚未纠正位移,且目标元素处于可见范围内,则重新启动计算动画位移 - if (!isRectified && isItemVisible(targetIndex)) { - calcAndStartAnimation() - isRectified = true - } - } - }.addEndListener { _, canceled, _, _ -> - task?.apply { - // 若结束后,目标元素不在可见范围内,则重启计算动画 - if (!canceled && !isRectified && !isItemVisible(targetIndex)) { - calcAndStartAnimation() - return@apply - } - - isFinished = true - onEnd(canceled) - task = null - } - } - - private fun isItemVisible(index: Int): Boolean { - val startIndex = listState.firstVisibleItemIndex - val endIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - - return index in startIndex..endIndex - } - - private fun onScroll(dy: Float) { - if (!enable()) return - - scope.launch { - try { - listState.scroll { scrollBy(dy) } - } catch (e: Exception) { - // 若是CancellationException,则停止animator动画 - if (e is CancellationException) { - animator.cancel() - } - } - } - } - /** * 启动循环任务,用于监听可见元素列表的变化,并计算目标元素的偏移量 */ @@ -119,7 +64,7 @@ class LazyListAnimateScroller internal constructor( key: Any, onEnd: (Boolean) -> Unit = {}, isStickyHeader: (LazyListItemInfo) -> Boolean = { false }, - offsetBlock: (LazyListState) -> Int = { 0 }, + offsetBlock: (LazyListItemInfo) -> Int = { 0 } ) = animateTo( ScrollTask( key = key, @@ -131,71 +76,81 @@ class LazyListAnimateScroller internal constructor( fun animateTo(scrollTask: ScrollTask) { task = scrollTask - calcAndStartAnimation() + calcAndStartAnimation(scrollTask) } - private fun calcAndStartAnimation() = scope.launch { - // 若当前没有滚动任务,则不继续执行 - task ?: return@launch - - val key = task!!.key - val offsetBlock = task!!.offsetBlock - + private fun calcAndStartAnimation(task: ScrollTask) { // 1. 从当前可见元素直接查找offset (准确值) // get the offset directly from the visibleItemsInfo - val tempIndex = listState.layoutInfo.visibleItemsInfo.indexOfFirst { it.key == key } - val targetItem = listState.layoutInfo.visibleItemsInfo.getOrNull(tempIndex) + val targetItem = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == task.key } val targetOffset = targetItem?.let { item -> - val isSticky = task!!.isStickyHeader.invoke(item) - - // 若目标元素不是stickyHeader,则直接返回offset - if (!isSticky) { + // 若非StickyHeader,则其offset即为准确的滚动位移值 + if (!task.isStickyHeader(item)) { return@let item.offset } - // 若目标时是stickyHeader,则查找下一个元素, - // 若下一个元素的offset等于当前元素的offset + size, - // 则返回当前元素的offset - val nextItem = listState.layoutInfo.visibleItemsInfo.getOrNull(tempIndex + 1) - // TODO 待完善StickyHeader Item的offset计算逻辑 - LogUtils.i("nexItem: ${nextItem?.offset} == ${item.offset} + ${item.size}") - if (nextItem != null && nextItem.offset == (item.offset + item.size)) { - return@let item.offset - } - null + // 若为StickyHeader则使用其下一个元素的offset - 当前元素的size计算获取 + listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == (item.index + 1) } + ?.let { it.offset - item.size - listState.layoutInfo.mainAxisItemSpacing } } - if (targetOffset != null) { - doScroll(targetOffset.toFloat() + offsetBlock(listState)) - return@launch + // 若获取到offset,则直接进行滚动 + targetOffset?.let { offset -> + doScroll(offset.toFloat() + task.offsetBlock(targetItem)) + return } - val index = keysKeeper().indexOfFirst { it == key } + // 若未获取到offset,则使用keys列表查找目标元素,并计算其offset + val index = keysKeeper().indexOfFirst { it == task.key } + task.targetIndex = index + if (index == -1) { - task = null - return@launch // 元素不存在keys列表中,则不进行滚动 + this.task = null + return // 元素不存在keys列表中,则不进行滚动 } // 2. 使用实时维护的sizeMap查找并计算目标元素的offset (非准确值) // Use the real-time maintained sizeMap to find and calculate the offset of the target element - task?.targetIndex = index scrollTo(index) + return } - private fun doScroll( - targetOffset: Float, - ) { - animator.cancel() + private fun doScroll(targetOffset: Float) { + scope.launch { + // 获取上一次滚动时最终的滚动速度 + val oldVelocity = animation.velocity + animation.snapTo(0f) + + var lastValue = 0f + animation.animateTo( + targetValue = targetOffset, + animationSpec = spring(stiffness = Spring.StiffnessVeryLow), + initialVelocity = oldVelocity + ) { + val dy = value - lastValue + lastValue = value + + scope.launch { + try { + listState.scroll { scrollBy(dy) } + } catch (e: Exception) { + if (e is CancellationException) { + animation.stop() + } + } + } - currentValue = 0f - targetValue = targetOffset + // TODO 中途纠正 + } - animator.animateToFinalPosition(targetOffset) + // TODO 末端纠正 + } } - private suspend fun scrollTo(index: Int) = withContext(Dispatchers.Unconfined) { - if (!isActive) return@withContext + private fun scrollTo(index: Int) { val firstVisibleIndex = listState.firstVisibleItemIndex val firstVisibleOffset = listState.firstVisibleItemScrollOffset targetRange = minOf(firstVisibleIndex, index)..maxOf(firstVisibleIndex, index) @@ -204,7 +159,6 @@ class LazyListAnimateScroller internal constructor( // calculate the direction multiplier, if scrolling down, it's positive val forwardMultiple = if (index >= firstVisibleIndex) 1f else -1f - if (!isActive) return@withContext // 计算目标距离,若未缓存有相应位置的值,则计算使用平均值 // calculate the target offset,if these no value cached then use the average value val sizeAverage = sizeMap.values.average().toInt() @@ -222,9 +176,25 @@ class LazyListAnimateScroller internal constructor( // 使用非准确值进行滚动 // use the non-accurate value for scrolling - if (!isActive) return@withContext doScroll(offsetTemp * forwardMultiple) } + + private fun isItemVisible(task: ScrollTask, index: Int): Boolean { + val startIndex = listState.firstVisibleItemIndex + val endIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + val isVisible = index in startIndex..endIndex + if (!isVisible) return false + + val targetItem = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == index } + ?: return false + + val isStickyHeader = task.isStickyHeader(targetItem) + if (!isStickyHeader) return true + + return (index + 1) in startIndex..endIndex + } } @Composable From d352c0a1e3dfb5c717d5247e94574d00acbae707 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 13 Sep 2024 09:08:07 +0800 Subject: [PATCH 087/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E7=BA=A0=E6=AD=A3=E6=BB=9A=E5=8A=A8=E4=BD=8D=E7=A7=BB?= =?UTF-8?q?=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extension/LazyListAnimateScroller.kt | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt index 2ede63466..fceb54ea2 100644 --- a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt +++ b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt @@ -123,6 +123,7 @@ class LazyListAnimateScroller internal constructor( // 获取上一次滚动时最终的滚动速度 val oldVelocity = animation.velocity animation.snapTo(0f) + var canceled = false var lastValue = 0f animation.animateTo( @@ -135,18 +136,39 @@ class LazyListAnimateScroller internal constructor( scope.launch { try { - listState.scroll { scrollBy(dy) } + if (!canceled && enable()) { + listState.scroll { scrollBy(dy) } + } } catch (e: Exception) { if (e is CancellationException) { + canceled = true animation.stop() } } } - // TODO 中途纠正 + task?.apply { + if (!isRectified && !canceled && targetIndex != -1 && + isItemVisible(this, targetIndex) + ) { + // 更新阻止滚动继续的标志 + canceled = true + // 触发计算新的目标元素偏移量 + calcAndStartAnimation(this) + // 标记已纠正,避免无限重复调用计算逻辑 + isRectified = true + } + } } - // TODO 末端纠正 + task?.apply { + if (!isFinished && targetIndex != -1) { + // 触发计算新的目标元素偏移量 + calcAndStartAnimation(this) + // 标记已纠正,避免无限重复调用计算逻辑 + isRectified = true + } + } } } From fa9e39fc371bb5f11f8347fd88cd769a37b3c840 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Fri, 13 Sep 2024 11:27:02 +0800 Subject: [PATCH 088/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E4=BC=98?= =?UTF-8?q?=E5=8C=96LazyListAnimateScroller=EF=BC=8C=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E5=88=B0=E6=8C=87=E5=AE=9A=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E4=B8=8D=E5=87=86=E7=A1=AE=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/LyricLayout.kt | 2 +- .../screen/songs/SongsScreenContent.kt | 17 ++- .../extension/LazyListAnimateScroller.kt | 123 +++++++++--------- .../screen/artists/ArtistsScreenContent.kt | 22 +++- 4 files changed, 93 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt index 487104f3a..ab9b5b3ed 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt @@ -136,7 +136,7 @@ fun LyricLayout( val scroller = rememberLazyListAnimateScroller( listState = listState, enableScrollAnimation = { !isUserScrolling.value }, - keysKeeper = { recorder.list().filterNotNull() } + keys = { recorder.list().filterNotNull() } ) val currentItemIndex = remember { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index 51f8bdadf..6e011e6e0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -59,7 +59,7 @@ internal fun SongsScreenContent( val songs by songsSM.songs val scroller = rememberLazyListAnimateScroller( listState = listState, - keysKeeper = { songsSM.recorder.list().filterNotNull() } + keys = { songsSM.recorder.list().filterNotNull() } ) LaunchedEffect(Unit) { @@ -69,14 +69,17 @@ internal fun SongsScreenContent( scroller.animateTo( key = event.key, isStickyHeader = { it.contentType == "group" }, - offsetBlock = { state -> - // TODO 待完善StickyHeader Item的offset计算逻辑 - val lastGroupItemOffset = state.layoutInfo.visibleItemsInfo - .lastOrNull { it.contentType == "group" } - ?.takeIf { it.key != event.key } + offset = { item -> + // 若是 sticky header,则滚动到顶部 + if (item.contentType == "group") { + return@animateTo -statusBar.getTop(density) + } + + val closestStickyHeaderSize = listState.layoutInfo.visibleItemsInfo + .lastOrNull { it.index < item.index && it.contentType == "group" } ?.size ?: 0 - -(statusBar.getTop(density) + lastGroupItemOffset) + -(statusBar.getTop(density) + closestStickyHeaderSize) } ) } diff --git a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt index fceb54ea2..fc1f6491a 100644 --- a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt +++ b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt @@ -1,6 +1,7 @@ package com.lalilu.component.extension import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.spring @@ -21,24 +22,26 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class LazyListAnimateScroller internal constructor( - private val keysKeeper: () -> Collection, - private val enable: () -> Boolean = { true }, + private val scope: CoroutineScope, private val listState: LazyListState, - private val scope: CoroutineScope + private val keys: () -> Collection, + private val enable: () -> Boolean = { true }, + private val defaultAnimationSpec: AnimationSpec = spring(stiffness = Spring.StiffnessVeryLow), ) { /** * 滚动任务,用于缓存每一次的主动滚动 * * @param key 目标元素key - * @param offsetBlock 当目标元素可见时,计算与顶部偏移量的回调 + * @param offset 当目标元素可见时,计算与顶部偏移量的回调 * @param isStickyHeader 判断元素是否StickyHeader * @param onEnd 滚动结束的回调 */ data class ScrollTask( val key: Any, val onEnd: (isCanceled: Boolean) -> Unit = {}, + val animationSpec: AnimationSpec? = null, val isStickyHeader: (LazyListItemInfo) -> Boolean = { false }, - val offsetBlock: (LazyListItemInfo) -> Int = { 0 } + val offset: (LazyListItemInfo) -> Int = { 0 } ) { var isRectified = false var isFinished = false @@ -46,9 +49,9 @@ class LazyListAnimateScroller internal constructor( } private val animation by lazy { Animatable(0f, Float.VectorConverter) } - private var task: ScrollTask? = null private var targetRange: IntRange = IntRange(0, 0) private val sizeMap = mutableMapOf() + private var task: ScrollTask? = null /** * 启动循环任务,用于监听可见元素列表的变化,并计算目标元素的偏移量 @@ -62,15 +65,17 @@ class LazyListAnimateScroller internal constructor( fun animateTo( key: Any, + animationSpec: AnimationSpec? = null, onEnd: (Boolean) -> Unit = {}, isStickyHeader: (LazyListItemInfo) -> Boolean = { false }, - offsetBlock: (LazyListItemInfo) -> Int = { 0 } + offset: (LazyListItemInfo) -> Int = { 0 } ) = animateTo( ScrollTask( key = key, onEnd = onEnd, + animationSpec = animationSpec, isStickyHeader = isStickyHeader, - offsetBlock = offsetBlock + offset = offset ) ) @@ -99,12 +104,12 @@ class LazyListAnimateScroller internal constructor( // 若获取到offset,则直接进行滚动 targetOffset?.let { offset -> - doScroll(offset.toFloat() + task.offsetBlock(targetItem)) + doScroll(offset.toFloat() + task.offset(targetItem)) return } // 若未获取到offset,则使用keys列表查找目标元素,并计算其offset - val index = keysKeeper().indexOfFirst { it == task.key } + val index = keys().indexOfFirst { it == task.key } task.targetIndex = index if (index == -1) { @@ -118,7 +123,36 @@ class LazyListAnimateScroller internal constructor( return } - private fun doScroll(targetOffset: Float) { + private fun scrollTo(index: Int) { + val firstVisibleIndex = listState.firstVisibleItemIndex + val firstVisibleOffset = listState.firstVisibleItemScrollOffset + targetRange = minOf(firstVisibleIndex, index)..maxOf(firstVisibleIndex, index) + + // 计算方向乘数,向下滚动则为正数 + // calculate the direction multiplier, if scrolling down, it's positive + val forwardMultiple = if (index >= firstVisibleIndex) 1f else -1f + + // 计算目标距离,若未缓存有相应位置的值,则计算使用平均值 + // calculate the target offset,if these no value cached then use the average value + val sizeAverage = sizeMap.values.average().toInt() + val sizeSum = targetRange.sumOf { + if (it == targetRange.last) return@sumOf 0 + sizeMap.getOrPut(it) { sizeAverage } + } + val spacingSum = (targetRange.last - targetRange.first) * + listState.layoutInfo.mainAxisItemSpacing.toFloat() + var offsetTemp = sizeSum + spacingSum + + // 针对firstVisibleItem的边界情况修正offset值 + // fix the offset value for the boundary case of the firstVisibleItem + offsetTemp -= firstVisibleOffset * forwardMultiple + + // 使用非准确值进行滚动 + // use the non-accurate value for scrolling + doScroll(offsetTemp * forwardMultiple) + } + + private fun doScroll(targetOffset: Float) = task?.apply { scope.launch { // 获取上一次滚动时最终的滚动速度 val oldVelocity = animation.velocity @@ -128,7 +162,7 @@ class LazyListAnimateScroller internal constructor( var lastValue = 0f animation.animateTo( targetValue = targetOffset, - animationSpec = spring(stiffness = Spring.StiffnessVeryLow), + animationSpec = animationSpec ?: defaultAnimationSpec, initialVelocity = oldVelocity ) { val dy = value - lastValue @@ -147,58 +181,25 @@ class LazyListAnimateScroller internal constructor( } } - task?.apply { - if (!isRectified && !canceled && targetIndex != -1 && - isItemVisible(this, targetIndex) - ) { - // 更新阻止滚动继续的标志 - canceled = true - // 触发计算新的目标元素偏移量 - calcAndStartAnimation(this) - // 标记已纠正,避免无限重复调用计算逻辑 - isRectified = true - } - } - } - - task?.apply { - if (!isFinished && targetIndex != -1) { + if (!isRectified && !canceled && targetIndex != -1 && + isItemVisible(this@apply, targetIndex) + ) { + // 更新阻止滚动继续的标志 + canceled = true // 触发计算新的目标元素偏移量 - calcAndStartAnimation(this) + calcAndStartAnimation(this@apply) // 标记已纠正,避免无限重复调用计算逻辑 isRectified = true } } - } - } - private fun scrollTo(index: Int) { - val firstVisibleIndex = listState.firstVisibleItemIndex - val firstVisibleOffset = listState.firstVisibleItemScrollOffset - targetRange = minOf(firstVisibleIndex, index)..maxOf(firstVisibleIndex, index) - - // 计算方向乘数,向下滚动则为正数 - // calculate the direction multiplier, if scrolling down, it's positive - val forwardMultiple = if (index >= firstVisibleIndex) 1f else -1f - - // 计算目标距离,若未缓存有相应位置的值,则计算使用平均值 - // calculate the target offset,if these no value cached then use the average value - val sizeAverage = sizeMap.values.average().toInt() - val sizeSum = targetRange.sumOf { - if (it == targetRange.last) return@sumOf 0 - sizeMap.getOrPut(it) { sizeAverage } + if (!isFinished && targetIndex != -1) { + // 触发计算新的目标元素偏移量 + calcAndStartAnimation(this@apply) + // 标记已纠正,避免无限重复调用计算逻辑 + isFinished = true + } } - val spacingSum = (targetRange.last - targetRange.first) * - listState.layoutInfo.mainAxisItemSpacing.toFloat() - var offsetTemp = sizeSum + spacingSum - - // 针对firstVisibleItem的边界情况修正offset值 - // fix the offset value for the boundary case of the firstVisibleItem - offsetTemp -= firstVisibleOffset * forwardMultiple - - // 使用非准确值进行滚动 - // use the non-accurate value for scrolling - doScroll(offsetTemp * forwardMultiple) } private fun isItemVisible(task: ScrollTask, index: Int): Boolean { @@ -222,18 +223,20 @@ class LazyListAnimateScroller internal constructor( @Composable fun rememberLazyListAnimateScroller( listState: LazyListState, + keys: () -> Collection = { emptyList() }, + defaultAnimationSpec: AnimationSpec = spring(stiffness = Spring.StiffnessVeryLow), enableScrollAnimation: () -> Boolean = { true }, - keysKeeper: () -> Collection = { emptyList() }, ): LazyListAnimateScroller { val enableAnimation = rememberUpdatedState(enableScrollAnimation()) val scope = rememberCoroutineScope() val scroller = remember { LazyListAnimateScroller( + scope = scope, + keys = keys, listState = listState, enable = { enableAnimation.value }, - keysKeeper = keysKeeper, - scope = scope + defaultAnimationSpec = defaultAnimationSpec, ) } diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt index 0c4281b86..653d7ca4f 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt @@ -59,16 +59,32 @@ internal fun ArtistsScreenContent( val listState = rememberLazyListState() val statusBar = WindowInsets.statusBars val density = LocalDensity.current + val stickyHeaderContentType = remember { "group" } val scroller = rememberLazyListAnimateScroller( listState = listState, - keysKeeper = { artistsSM.recorder.list().filterNotNull() } + keys = { artistsSM.recorder.list().filterNotNull() } ) LaunchedEffect(Unit) { artistsSM.eventFlow.collectLatest { event -> when (event) { is ArtistsScreenEvent.ScrollToItem -> { - scroller.animateTo(event.key) + scroller.animateTo( + key = event.key, + isStickyHeader = { it.contentType == stickyHeaderContentType }, + offset = { item -> + // 若是 sticky header,则滚动到顶部 + if (item.contentType == stickyHeaderContentType) { + return@animateTo -statusBar.getTop(density) + } + + val closestStickyHeaderSize = listState.layoutInfo.visibleItemsInfo + .lastOrNull { it.index < item.index && it.contentType == stickyHeaderContentType } + ?.size ?: 0 + + -(statusBar.getTop(density) + closestStickyHeaderSize) + } + ) } else -> {} @@ -133,7 +149,7 @@ internal fun ArtistsScreenContent( if (group !is GroupIdentity.None) { stickyHeaderWithRecord( key = group, - contentType = "group" + contentType = stickyHeaderContentType ) { SongsScreenStickyHeader( modifier = Modifier.animateItem(), From 6cb716c9b9abff9c1090a701fd7feb04b7b3f081 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Fri, 13 Sep 2024 19:46:37 +0800 Subject: [PATCH 089/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E8=89=BA=E6=9C=AF=E5=AE=B6=E8=AF=A6=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lartist/screen/ArtistDetailScreen.kt | 209 ++++++++++------- .../screen/ArtistDetailScreenContent.kt | 213 ++++++++++++++++++ .../lartist/viewModel/ArtistDetailSM.kt | 102 +++++++++ 3 files changed, 441 insertions(+), 83 deletions(-) create mode 100644 lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt create mode 100644 lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt index ce38130ee..5936020cb 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt @@ -1,32 +1,49 @@ package com.lalilu.lartist.screen import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel -import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey -import com.lalilu.component.base.collectAsLoadingState +import com.lalilu.RemixIcon +import com.lalilu.common.ext.requestFor +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.base.screen.ScreenType +import com.lalilu.component.base.songs.SongsHeaderJumperDialog +import com.lalilu.component.base.songs.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSelectorPanel +import com.lalilu.component.base.songs.SongsSortPanelDialog +import com.lalilu.component.extension.DialogWrapper import com.lalilu.lartist.R -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lartist.viewModel.ArtistDetailSM +import com.lalilu.lartist.viewModel.ArtistDetailScreenAction +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.remixicon.Design +import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.System +import com.lalilu.remixicon.design.editBoxLine +import com.lalilu.remixicon.design.focus3Line +import com.lalilu.remixicon.editor.sortDesc +import com.lalilu.remixicon.system.checkboxMultipleBlankLine +import com.lalilu.remixicon.system.checkboxMultipleLine +import com.lalilu.remixicon.system.menuSearchLine import com.zhangke.krouter.annotation.Destination -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.launch +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named @Destination("/pages/artist/detail") data class ArtistDetailScreen( private val artistName: String -) : Screen, ScreenInfoFactory { +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory, ScreenType.List { override val key: ScreenKey = "ARTIST_DETAIL_$artistName" + private var artistDetailSM: ArtistDetailSM? = null @Composable override fun provideScreenInfo(): ScreenInfo = remember { @@ -36,85 +53,111 @@ data class ArtistDetailScreen( } @Composable - override fun Content() { - val artistDetailSM: ArtistDetailScreenModel = - rememberScreenModel { ArtistDetailScreenModel() } + override fun provideScreenActions(): List = remember { + listOf( + ScreenAction.Static( + title = { "排序" }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, + onAction = { artistDetailSM?.showSortPanel?.value = true } + ), + ScreenAction.Static( + title = { "选择" }, + icon = { RemixIcon.Design.editBoxLine }, + color = { Color(0xFF009673) }, + onAction = { artistDetailSM?.selector?.isSelecting?.value = true } + ), + ScreenAction.Static( + title = { "搜索" }, + subTitle = { + val isSearching = artistDetailSM?.searcher?.isSearching - LaunchedEffect(Unit) { - artistDetailSM.updateArtistName(artistName) - } + if (isSearching?.value == true) "搜索中: ${artistDetailSM?.searcher?.keywordState?.value}" + else null + }, + icon = { RemixIcon.System.menuSearchLine }, + color = { Color(0xFF8BC34A) }, + dotColor = { + val isSearching = artistDetailSM?.searcher?.isSearching - ArtistDetail(artistDetailSM = artistDetailSM) + if (isSearching?.value == true) Color.Red + else null + }, + onAction = { + artistDetailSM?.showSearcherPanel?.value = true + DialogWrapper.dismiss() + } + ), + ScreenAction.Static( + title = { "定位至当前播放歌曲" }, + icon = { RemixIcon.Design.focus3Line }, + color = { Color(0xFF8700FF) }, + onAction = { artistDetailSM?.doAction(ArtistDetailScreenAction.LocaleToPlayingItem) } + ), + ) } -} -@OptIn(ExperimentalCoroutinesApi::class) -class ArtistDetailScreenModel : ScreenModel { - private val artistName = MutableStateFlow(null) - val artist = artistName.flatMapLatest { LMedia.getFlow(it) } + @Composable + override fun Content() { + val sm = rememberScreenModel { ArtistDetailSM(artistName) } + .also { artistDetailSM = it } - fun updateArtistName(artistName: String) = screenModelScope.launch { - this@ArtistDetailScreenModel.artistName.emit(artistName) - } -} + SongsSortPanelDialog( + isVisible = sm.showSortPanel, + supportSortActions = sm.supportSortActions, + isSortActionSelected = { sm.sorter.isSortActionSelected(it) }, + onSelectSortAction = { sm.sorter.selectSortAction(it) } + ) -@Composable -private fun ArtistDetail( - artistDetailSM: ArtistDetailScreenModel -) { - val artistState = artistDetailSM.artist.collectAsLoadingState() + SongsHeaderJumperDialog( + isVisible = sm.showJumperDialog, + items = { sm.recorder.list().filterIsInstance() }, + onSelectItem = { sm.doAction(ArtistDetailScreenAction.LocaleToGroupItem(it)) } + ) + + SongsSearcherPanel( + isVisible = sm.showSearcherPanel, + keyword = { sm.searcher.keywordState.value }, + onUpdateKeyword = { sm.searcher.keywordState.value = it } + ) -// LoadingScaffold(targetState = artistState) { artist -> -// val relateArtist = remember { -// derivedStateOf { -// artist.songs.map { it.artists } -// .flatten() -// .toSet() -// .filter { it.id != artist.name } -// } -// } -// -// Songs( -// mediaIds = artist.songs.map { it.mediaId }, -// selectActions = { getAll -> -// listOf(SelectAction.StaticAction.SelectAll(getAll)) -// }, -// sortFor = "ArtistDetail", -// supportListAction = { emptyList() }, -// headerContent = { -// item { -// NavigatorHeader( -// title = artist.name, -// subTitle = "共 ${artist.requireItemsCount()} 首歌曲,总时长 ${ -// artist.requireItemsDuration().durationToTime() -// }" -// ) -// } -// }, -// footerContent = { -// if (relateArtist.value.isNotEmpty()) { -// item { -// NavigatorHeader( -// modifier = Modifier.padding(top = 20.dp), -// titleScale = 0.8f, -// title = "相关歌手", -// subTitle = "共 ${relateArtist.value.size} 位" -// ) -// } -// items(items = relateArtist.value) { -// ArtistCard( -// artist = it, -// onClick = { -// AppRouter.intent( -// NavIntent.Push(ArtistDetailScreen(it.id)) -// ) -// } -// ) -// } -// } -// } -// ) -// } + SongsSelectorPanel( + isVisible = sm.selector.isSelecting, + screenActions = listOfNotNull( + ScreenAction.Static( + title = { "全选" }, + color = { Color(0xFF00ACF0) }, + icon = { RemixIcon.System.checkboxMultipleLine }, + onAction = { + val songs = sm.songs.value.values.flatten() + sm.selector.selectAll(songs) + } + ), + ScreenAction.Static( + title = { "取消全选" }, + icon = { RemixIcon.System.checkboxMultipleBlankLine }, + color = { Color(0xFFFF5100) }, + onAction = { sm.selector.clear() } + ), + requestFor( + qualifier = named("add_to_favourite_action"), + parameters = { parametersOf(sm.selector::selected) } + ), + requestFor( + qualifier = named("add_to_playlist_action"), + parameters = { parametersOf(sm.selector::selected) } + ) + ) + ) + + ArtistDetailScreenContent( + artistDetailSM = sm, + isSelecting = { sm.selector.isSelecting.value }, + isSelected = { sm.selector.isSelected(it) }, + onSelect = { sm.selector.onSelect(it) }, + onClickGroup = { sm.showJumperDialog.value = true } + ) + } } fun Long.durationToTime(): String { diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt new file mode 100644 index 000000000..31a26395d --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt @@ -0,0 +1,213 @@ +package com.lalilu.lartist.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gigamole.composefadingedges.FadingEdgesGravity +import com.gigamole.composefadingedges.content.FadingEdgesContentType +import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig +import com.gigamole.composefadingedges.fill.FadingEdgesFillType +import com.gigamole.composefadingedges.verticalFadingEdges +import com.lalilu.common.base.Playable +import com.lalilu.component.base.songs.SongsScreenStickyHeader +import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.rememberLazyListAnimateScroller +import com.lalilu.component.extension.startRecord +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent +import com.lalilu.lartist.component.ArtistCard +import com.lalilu.lartist.viewModel.ArtistDetailSM +import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lplayer.extensions.PlayerAction + +@Composable +internal fun ArtistDetailScreenContent( + artistDetailSM: ArtistDetailSM, + isSelecting: () -> Boolean = { false }, + isSelected: (Playable) -> Boolean = { false }, + onSelect: (Playable) -> Unit = {}, + onClickGroup: (GroupIdentity) -> Unit = {} +) { + val listState = rememberLazyListState() + val statusBar = WindowInsets.statusBars + val density = LocalDensity.current + val stickyHeaderContentType = remember { "group" } + val hapticFeedback = LocalHapticFeedback.current + val scroller = rememberLazyListAnimateScroller( + listState = listState, + keys = { artistDetailSM.recorder.list().filterNotNull() } + ) + + val artist by artistDetailSM.artist + val songs by artistDetailSM.songs + + val relateArtist = remember(artist) { + artist?.songs?.map { it.artists } + ?.flatten() + ?.toSet() + ?.filter { it.id != artist!!.name } + ?.toList() + ?: emptyList() + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .verticalFadingEdges( + length = statusBar + .asPaddingValues() + .calculateTopPadding(), + contentType = FadingEdgesContentType.Dynamic.Lazy.List( + scrollConfig = FadingEdgesScrollConfig.Dynamic(), + state = listState + ), + gravity = FadingEdgesGravity.Start, + fillType = remember { + FadingEdgesFillType.FadeClip( + fillStops = Triple(0f, 0.7f, 1f) + ) + } + ), + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + startRecord(artistDetailSM.recorder) { + itemWithRecord(key = "HEADER") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = artist?.name ?: "Unknown", + fontSize = 20.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + Text( + text = "共 ${artist?.songs?.size ?: 0} 首歌曲", + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + } + + songs.forEach { (group, list) -> + if (group !is GroupIdentity.None) { + stickyHeaderWithRecord( + key = group, + contentType = stickyHeaderContentType + ) { + SongsScreenStickyHeader( + modifier = Modifier.animateItem(), + listState = listState, + group = group, + minOffset = { statusBar.getTop(density) }, + onClickGroup = onClickGroup + ) + } + } + + itemsWithRecord( + items = list, + key = { it.mediaId }, + contentType = { it::class.java } + ) { + SongCard( + song = { it }, + isSelected = { isSelected(it) }, + onClick = { + if (isSelecting()) { + onSelect(it) + } else { + PlayerAction.PlayById(it.mediaId).action() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + if (isSelecting()) { + onSelect(it) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.mediaId) + .jump() + } + }, + onEnterSelect = { onSelect(it) } + ) + } + } + + if (relateArtist.isNotEmpty()) { + itemWithRecord(key = "EXTRA_HEADER") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "相关艺术家", + fontSize = 20.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + } + } + + itemsIndexedWithRecord( + items = relateArtist, + key = { _, item -> item.id }, + contentType = { _, _ -> LArtist::class } + ) { index, item -> + ArtistCard( + modifier = Modifier.animateItem(), + title = item.name, + subTitle = "#$index", + songCount = item.songs.size.toLong(), + imageSource = { item.songs.firstOrNull()?.imageSource }, + isPlaying = { + false +// playingVM.isItemPlaying { playing -> +// playing.let { it as? LSong } +// ?.let { song -> song.artists.any { it.name == item.name } } +// ?: false +// } + }, + onClick = { AppRouter.intent(NavIntent.Push(ArtistDetailScreen(item.id))) } + ) + } + } + } + } +} \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt new file mode 100644 index 000000000..59b6e085e --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt @@ -0,0 +1,102 @@ +package com.lalilu.lartist.viewModel + +import androidx.compose.runtime.mutableStateOf +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.lalilu.common.base.Playable +import com.lalilu.component.base.songs.ItemSearcher +import com.lalilu.component.base.songs.ItemSorter +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lplayer.LPlayer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +internal sealed interface ArtistDetailScreenAction { + data object LocaleToPlayingItem : ArtistDetailScreenAction + data class LocaleToGroupItem(val item: GroupIdentity) : ArtistDetailScreenAction +} + +internal sealed interface ArtistDetailScreenEvent { + data class ScrollToItem(val key: Any) : ArtistDetailScreenEvent +} + +internal class ArtistDetailSM( + private val artistName: String +) : ScreenModel { + // 持久化元素的状态 + val showSortPanel = mutableStateOf(false) + val showJumperDialog = mutableStateOf(false) + val showSearcherPanel = mutableStateOf(false) + val supportSortActions = setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.ItemsCount, + SortStaticAction.Duration, + SortStaticAction.AddTime, + SortStaticAction.Shuffle, + ).filterNotNull() + .toSet() + + // 数据流 + private fun flow(): Flow = LMedia.getFlow(artistName) + val searcher = ItemSearcher(flow().map { it?.songs ?: emptyList() }) + val sorter = ItemSorter(searcher.output, supportSortActions) + + val artist = flow().toState( + defaultValue = null, + scope = screenModelScope + ) + val songs = sorter.output.toState( + defaultValue = emptyMap(), + scope = screenModelScope, + ) + + val selector = ItemSelector() + val recorder = ItemRecorder() + + private val _eventFlow = MutableSharedFlow() + val eventFlow: SharedFlow = _eventFlow + + fun doAction(action: ArtistDetailScreenAction) = screenModelScope.launch { + when (action) { + ArtistDetailScreenAction.LocaleToPlayingItem -> { + // 获取正在播放的元素ID + val mediaId = LPlayer.runtime.info.playingIdFlow.value + ?: return@launch + + // 获取该元素 + val item = LMedia.get(mediaId) + ?: return@launch + + // 获取该元素的所属分组ID + val artistsIds = item.artists + .map { it.id } + .takeIf { it.isNotEmpty() } + ?: return@launch + + // 获取第一个存在与列表中的元素的Index + val list = recorder.list() + artistsIds.firstOrNull { list.contains(it) }?.let { + _eventFlow.emit(ArtistDetailScreenEvent.ScrollToItem(it)) + } + } + + is ArtistDetailScreenAction.LocaleToGroupItem -> { + _eventFlow.emit(ArtistDetailScreenEvent.ScrollToItem(action.item)) + } + + else -> {} + } + } +} \ No newline at end of file From d0122540c5e40328275719adb776bc4f18e555e6 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 19 Sep 2024 19:03:37 +0800 Subject: [PATCH 090/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E9=80=BB=E8=BE=91=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E7=9A=84=E6=92=AD=E6=94=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 34 +++--- .../lmusic/service/LMusicServiceConnector.kt | 109 +++-------------- common/build.gradle.kts | 1 + lmedia | 2 +- lplayer/build.gradle.kts | 3 +- .../com/lalilu/lplayer/service/MService.kt | 115 ++++++++++++++++-- 6 files changed, 146 insertions(+), 118 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e2a8ea6bb..a550042ac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,25 +42,25 @@ android:name="ScopedStorage" android:value="true" /> - - - - - + + + + + + + - + + + + + - - - - - + + + + + () - queue.setIds(songs.map { it.id }) - queue.setCurrentId(songs.getOrNull(0)?.id) - } - } - - private fun initLPlayer() { - LPlayer.runtime.source = object : ItemSource { - override fun getById(id: String): Playable? = LMedia.get(id) - - override fun flowMapId(idFlow: Flow): Flow = - idFlow.flatMapLatest { mediaId -> LMedia.getFlow(mediaId) } - - override fun flowMapIds(idsFlow: Flow>): Flow> = idsFlow - .combine(LPlayer.runtime.info.playingIdFlow) { ids, id -> - id ?: return@combine ids - ids.moveHeadToTailWithSearch(id) { a, b -> a == b } - } - .flatMapLatest { mediaIds -> LMedia.flowMapBy(mediaIds) } - } - - launch { - LPlayer.runtime.info.idsFlow.collectLatest { - lastPlayedSp.lastPlayedListIdsKey.value = it - } - } - - launch { - LPlayer.runtime.info.playingIdFlow.collectLatest { - lastPlayedSp.lastPlayedIdKey.value = it ?: "" - } - } - - launch { - LPlayer.runtime.info.positionFlow.collectLatest { - lastPlayedSp.lastPlayedPositionKey.value = it + launch(Dispatchers.Main) { + val browser = browserFuture.await() + val item = browser.getItem("1000004055") + .await() + .value + + item?.let { + browser.setMediaItem(item) + browser.prepare() + browser.play() } } } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index a7c2cbc81..a00cd54e6 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { api(libs.dynamicanimation.ktx) api(libs.media) + api("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0") api("io.github.billywei01:fastkv:2.4.2") api("io.github.billywei01:packable:1.1.0") diff --git a/lmedia b/lmedia index 29c2dde9e..2a906632c 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 29c2dde9e19ad440e5dde27cc00a474e30ad9d3b +Subproject commit 2a906632c711e3fcb0e1d36f3436497425d06549 diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index 2c3053acc..eb2db6f5c 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -27,8 +27,9 @@ android { dependencies { implementation(project(":common")) + implementation(project(":lmedia")) implementation("com.github.cy745:AndroidVideoCache:2.7.2") implementation("androidx.media3:media3-exoplayer:1.4.1") - implementation("androidx.media3:media3-session:1.4.1") + api("androidx.media3:media3-session:1.4.1") } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index f9875bad6..5276a72a2 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -1,20 +1,25 @@ package com.lalilu.lplayer.service import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import com.lalilu.lmedia2.LMedia import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlin.coroutines.CoroutineContext -class MService : MediaLibraryService(), - MediaLibrarySession.Callback, - CoroutineScope { +class MService : MediaLibraryService(), CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() private var exoPlayer: ExoPlayer? = null @@ -28,7 +33,7 @@ class MService : MediaLibraryService(), .build() mediaSession = MediaLibrarySession - .Builder(this, exoPlayer!!, this) + .Builder(this, exoPlayer!!, MServiceCallback()) .build() } @@ -44,16 +49,112 @@ class MService : MediaLibraryService(), override fun onGetSession( controllerInfo: MediaSession.ControllerInfo - ): MediaLibrarySession? { - return mediaSession + ): MediaLibrarySession? = mediaSession +} + +@UnstableApi +class MServiceCallback : MediaLibrarySession.Callback { + companion object { + const val ROOT = "root" + const val ALL_SONGS = "all_songs" + const val ALL_ARTISTS = "all_artists" + const val ALL_ALBUMS = "all_albums" + } + + private fun buildBrowsableItem(id: String, title: String): MediaItem { + val metadata = MediaMetadata.Builder() + .setTitle(title) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + + return MediaItem.Builder() + .setMediaId(id) + .setMediaMetadata(metadata) + .build() + } + + private fun resolveMediaItems(mediaItems: List): List { + return mediaItems.mapNotNull { item -> LMedia.getItem(item.mediaId) } } + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> = Futures.immediateFuture( + LibraryResult.ofItem(buildBrowsableItem(ROOT, "LMedia Library"), params) + ) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + if (parentId == ROOT) { + return Futures.immediateFuture( + LibraryResult.ofItemList( + listOf( + buildBrowsableItem(ALL_SONGS, "All Songs"), + buildBrowsableItem(ALL_ARTISTS, "All Artists"), + buildBrowsableItem(ALL_ALBUMS, "All Albums") + ), + params + ) + ) + } + + return Futures.immediateFuture( + LibraryResult.ofItemList(LMedia.getChildren(parentId), params) + ) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val item = LMedia.getItem(mediaId) + + return Futures.immediateFuture( + if (item != null) LibraryResult.ofItem(item, null) + else LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + ) + } + + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture = Futures.immediateFuture( + MediaSession.MediaItemsWithStartPosition( + resolveMediaItems(mediaItems), + startIndex, + startPositionMs + ) + ) + + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList + ): ListenableFuture> = Futures.immediateFuture( + resolveMediaItems(mediaItems).toMutableList() + ) + @OptIn(UnstableApi::class) override fun onPlaybackResumption( mediaSession: MediaSession, controller: MediaSession.ControllerInfo ): ListenableFuture { // TODO 待完成继续播放的逻辑 - return super.onPlaybackResumption(mediaSession, controller) + return Futures.immediateFuture( + MediaSession.MediaItemsWithStartPosition(emptyList(), 0, 0L) + ) } } \ No newline at end of file From 8d7775521383e2df545c930bcdc76f67cf1e71b6 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 23 Sep 2024 09:04:56 +0800 Subject: [PATCH 091/213] =?UTF-8?q?[refactor]PlayingLayout=E5=88=9D?= =?UTF-8?q?=E6=AD=A5=E9=80=82=E9=85=8Dmedia3=E7=9A=84MPlayer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 6 +- .../java/com/lalilu/lmusic/MainActivity.kt | 3 - .../component/playing/PlayingToolbar.kt | 12 +- .../compose/screen/playing/PlayingLayout.kt | 19 +- .../compose/screen/playing/PlaylistLayout.kt | 25 +-- .../com/lalilu/lmusic/extension/SleepTimer.kt | 4 +- .../lmusic/service/LMusicServiceConnector.kt | 38 ---- .../utils/coil/fetcher/MediaItemFetcher.kt | 104 ++++++++++ .../lmusic/utils/coil/keyer/SongCoverKeyer.kt | 7 + lmedia | 2 +- lplayer/build.gradle.kts | 1 + lplayer/src/main/AndroidManifest.xml | 14 +- .../main/java/com/lalilu/lplayer/MPlayer.kt | 196 ++++++++++++++++++ .../main/java/com/lalilu/lplayer/Startup.kt | 15 ++ .../com/lalilu/lplayer/extensions/Action.kt | 2 +- .../lalilu/lplayer/extensions/PlayerAction.kt | 6 +- .../lalilu/lplayer/extensions/QueueAction.kt | 4 +- .../com/lalilu/lplayer/service/MService.kt | 7 +- 18 files changed, 378 insertions(+), 87 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/service/LMusicServiceConnector.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/MediaItemFetcher.kt create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/Startup.kt diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index 0041cfb1d..4101b2481 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -24,11 +24,12 @@ import com.lalilu.lmusic.datastore.TempSp import com.lalilu.lmusic.repository.CoverRepository import com.lalilu.lmusic.repository.LyricRepository import com.lalilu.lmusic.service.LMusicNotifier -import com.lalilu.lmusic.service.LMusicServiceConnector import com.lalilu.lmusic.utils.EQHelper import com.lalilu.lmusic.utils.coil.CrossfadeTransitionFactory import com.lalilu.lmusic.utils.coil.fetcher.LAlbumFetcher import com.lalilu.lmusic.utils.coil.fetcher.LSongFetcher +import com.lalilu.lmusic.utils.coil.fetcher.MediaItemFetcher +import com.lalilu.lmusic.utils.coil.keyer.MediaItemKeyer import com.lalilu.lmusic.utils.coil.keyer.PlayableKeyer import com.lalilu.lmusic.utils.coil.keyer.SongCoverKeyer import com.lalilu.lmusic.utils.coil.mapper.LSongMapper @@ -89,6 +90,8 @@ val AppModule = module { add(SongCoverKeyer()) add(PlayableKeyer()) add(LSongMapper()) + add(MediaItemKeyer()) + add(MediaItemFetcher.MediaItemFetcherFactory()) add(LSongFetcher.SongFactory()) add(LAlbumFetcher.AlbumFactory()) } @@ -109,7 +112,6 @@ val ViewModelModule = module { val RuntimeModule = module { singleOf(::LMusicNotifier) single { get() } - singleOf(::LMusicServiceConnector) singleOf(::CoverRepository) singleOf(::LyricRepository) } diff --git a/app/src/main/java/com/lalilu/lmusic/MainActivity.kt b/app/src/main/java/com/lalilu/lmusic/MainActivity.kt index 5f49b7de0..6426e8835 100644 --- a/app/src/main/java/com/lalilu/lmusic/MainActivity.kt +++ b/app/src/main/java/com/lalilu/lmusic/MainActivity.kt @@ -23,13 +23,11 @@ import com.lalilu.lmusic.Config.REQUIRE_PERMISSIONS import com.lalilu.lmusic.compose.App import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.helper.LastTouchTimeHelper -import com.lalilu.lmusic.service.LMusicServiceConnector import com.lalilu.lmusic.utils.dynamicUpdateStatusBarColor import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { private val settingsSp: SettingsSp by inject() - private val connector: LMusicServiceConnector by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -72,7 +70,6 @@ class MainActivity : ComponentActivity() { LMedia.initialize(this) - lifecycle.addObserver(connector) // 注册返回键事件回调 onBackPressedDispatcher.addCallback { this@MainActivity.moveTaskToBack(false) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/PlayingToolbar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/PlayingToolbar.kt index 011189aae..642096472 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/PlayingToolbar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/PlayingToolbar.kt @@ -18,8 +18,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,7 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.lalilu.R import com.lalilu.component.extension.enableFor -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer @Composable @@ -40,7 +38,7 @@ fun PlayingToolbar( fixContent: @Composable RowScope.() -> Unit = {}, extraContent: @Composable AnimatedVisibilityScope.() -> Unit = {} ) { - val song by LPlayer.runtime.info.playingFlow.collectAsState(null) + val metadata = MPlayer.currentMediaMetadata val defaultSloganStr = stringResource(id = R.string.default_slogan) val enter = remember { @@ -98,9 +96,9 @@ fun PlayingToolbar( modifier = Modifier .weight(1f) .padding(end = 10.dp), - title = { song?.title?.takeIf(String::isNotBlank) ?: defaultSloganStr }, - subTitle = { song?.subTitle ?: defaultSloganStr }, - isPlaying = { song?.let { isItemPlaying(it.mediaId) } ?: false } + title = { metadata?.title?.toString()?.takeIf(String::isNotBlank) ?: defaultSloganStr }, + subTitle = { metadata?.subtitle?.toString()?.takeIf(String::isNotBlank) ?: defaultSloganStr }, + isPlaying = { MPlayer.isPlaying } ) fixContent() diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 4f8fe6f39..6698bbd02 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable @@ -44,14 +43,14 @@ import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.mapLatest import org.koin.compose.koinInject import kotlin.math.pow -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalMaterialApi::class) +@OptIn(ExperimentalCoroutinesApi::class) @Composable fun PlayingLayout( playingVM: PlayingViewModel = singleViewModel(), @@ -199,7 +198,7 @@ fun PlayingLayout( blurProgress = { middleToMaxProgress.value }, onBackgroundColorFetched = { backgroundColor.value = it }, imageData = { - playingVM.playing.value + MPlayer.currentMediaItem ?: com.lalilu.component.R.drawable.ic_music_2_line_100dp } ) @@ -240,7 +239,7 @@ fun PlayingLayout( if (isLyricScrollEnable.value) { isLyricScrollEnable.value = false } - LPlayer.controller.doAction(PlayerAction.SeekTo(it.time)) + PlayerAction.SeekTo(it.time).action() }, onItemLongClick = { if (draggable.state.value == DragAnchor.Max) { @@ -255,7 +254,8 @@ fun PlayingLayout( Surface(color = MaterialTheme.colors.background) { PlaylistLayout( modifier = modifier.clipToBounds(), - forceRefresh = { draggable.state.value != DragAnchor.Min } + forceRefresh = { draggable.state.value != DragAnchor.Min }, + items = { MPlayer.currentTimelineItems } ) } }, @@ -274,15 +274,12 @@ fun PlayingLayout( translationY = (1f - animateProgress.value / 100f) * 500f } ) { - val duration = LPlayer.runtime.info.durationFlow.collectAsState() - val currentValue = LPlayer.runtime.info.positionFlow.collectAsState() - SeekbarLayout2( modifier = Modifier.hideControl(enable = { hideComponent.value }), animateColor = { animateColor.value }, onValueChange = { seekbarTime.longValue = it.toLong() }, - maxValue = { duration.value.toFloat() }, - dataValue = { currentValue.value.toFloat() }, + maxValue = { MPlayer.currentDuration.toFloat() }, + dataValue = { MPlayer.currentPosition.toFloat() }, onDispatchDragOffset = { enhanceSheetState?.dispatch(it) }, onDragStop = { result -> if (result == -1) enhanceSheetState?.hide() diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 1a71674be..ee2f6560a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -20,7 +20,6 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,14 +33,13 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.media3.common.MediaItem import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback import coil3.compose.AsyncImage import com.lalilu.common.base.Playable import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lplayer.LPlayer -import org.koin.compose.koinInject +import com.lalilu.lplayer.extensions.PlayerAction data class Item( @@ -102,17 +100,16 @@ fun List>.diff( fun PlaylistLayout( modifier: Modifier = Modifier, forceRefresh: () -> Boolean = { false }, - playingVM: IPlayingViewModel = koinInject() + items: () -> List = { emptyList() } ) { val haptic = LocalHapticFeedback.current val view = LocalView.current val listState = rememberLazyListState() - val items by LPlayer.runtime.info.listFlow.collectAsState(initial = emptyList()) - var actualItems by remember { mutableStateOf(emptyList>()) } + var actualItems by remember { mutableStateOf(emptyList>()) } - LaunchedEffect(items) { - val newList = actualItems.diff(items) { it.mediaId } + LaunchedEffect(items()) { + val newList = actualItems.diff(items()) { it.mediaId } val newListFirst = newList.firstOrNull() val oldListFirst = actualItems.firstOrNull() @@ -153,9 +150,7 @@ fun PlaylistLayout( MediaCard( modifier = Modifier.animateItem(), item = item.data, - onPlayItem = { - playingVM.play(mediaId = item.data.mediaId, playOrPause = true) - }, + onPlayItem = { PlayerAction.PlayById(item.data.mediaId).action() }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) @@ -172,7 +167,7 @@ fun PlaylistLayout( @Composable fun MediaCard( modifier: Modifier = Modifier, - item: Playable, + item: MediaItem, onPlayItem: () -> Unit = {}, onLongClick: () -> Unit = {} ) { @@ -206,14 +201,14 @@ fun MediaCard( Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = item.title, + text = "${item.mediaMetadata.title}", fontSize = 16.sp, lineHeight = 18.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onBackground ) Text( - text = item.subTitle, + text = "${item.mediaMetadata.subtitle}", fontSize = 10.sp, lineHeight = 12.sp, color = MaterialTheme.colors.onBackground.copy(0.7f) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt index d2ca4108a..ef8af0c8e 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt @@ -164,9 +164,9 @@ fun SleepTimer( millisInFuture = millisSecond, onFinish = { if (pauseWhenCompletion.value) { - LPlayer.controller.doAction(PlayerAction.PauseWhenCompletion()) + PlayerAction.PauseWhenCompletion().action() } else { - LPlayer.controller.doAction(PlayerAction.Pause) + PlayerAction.Pause.action() } } ) diff --git a/app/src/main/java/com/lalilu/lmusic/service/LMusicServiceConnector.kt b/app/src/main/java/com/lalilu/lmusic/service/LMusicServiceConnector.kt deleted file mode 100644 index ed81ab143..000000000 --- a/app/src/main/java/com/lalilu/lmusic/service/LMusicServiceConnector.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.lalilu.lmusic.service - -import android.content.ComponentName -import android.content.Context -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.media3.session.MediaBrowser -import androidx.media3.session.SessionToken -import com.lalilu.lplayer.service.MService -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.guava.await -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext - -class LMusicServiceConnector( - private val context: Context, -) : DefaultLifecycleObserver, CoroutineScope { - override val coroutineContext: CoroutineContext = Dispatchers.IO - private val sessionToken by lazy { - SessionToken(context, ComponentName(context, MService::class.java)) - } - private val browserFuture = MediaBrowser.Builder(context, sessionToken).buildAsync() - - init { - launch(Dispatchers.Main) { - val browser = browserFuture.await() - val item = browser.getItem("1000004055") - .await() - .value - - item?.let { - browser.setMediaItem(item) - browser.prepare() - browser.play() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/MediaItemFetcher.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/MediaItemFetcher.kt new file mode 100644 index 000000000..d71c73aa4 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/MediaItemFetcher.kt @@ -0,0 +1,104 @@ +package com.lalilu.lmusic.utils.coil.fetcher + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import com.blankj.utilcode.util.LogUtils +import com.lalilu.lmedia.extension.EXTERNAL_CONTENT_URI +import com.lalilu.lmedia.wrapper.Taglib +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.buffer +import okio.source +import java.io.ByteArrayInputStream +import java.io.InputStream + +class MediaItemFetcher( + private val options: Options, + private val item: MediaItem +) : Fetcher { + + override suspend fun fetch(): FetchResult? { + val songUri = EXTERNAL_CONTENT_URI.buildUpon() + .appendEncodedPath(item.mediaId) + .build() + ?: return null + + val stream = when (item.mediaMetadata.mediaType) { + MediaMetadata.MEDIA_TYPE_MUSIC -> { + fetchCoverByTaglib(options.context, songUri) + ?: fetchCoverByRetriever(options.context, songUri) + ?: fetchMediaStoreCovers(options.context, item.mediaMetadata.artworkUri) + } + + else -> fetchMediaStoreCovers(options.context, item.mediaMetadata.artworkUri) + } ?: return null + + return SourceFetchResult( + source = ImageSource(stream.source().buffer(), options.fileSystem), + mimeType = null, + dataSource = DataSource.DISK + ) + } + + private suspend fun fetchCoverByRetriever( + context: Context, + songUri: Uri + ): InputStream? = withContext(Dispatchers.IO) { + val retriever = MediaMetadataRetriever() + + try { + retriever.setDataSource(context, songUri) + retriever.embeddedPicture?.inputStream() + } catch (e: Exception) { + LogUtils.e(songUri, e) + null + } finally { + retriever.close() + retriever.release() + } + } + + private suspend fun fetchCoverByTaglib( + context: Context, + songUri: Uri + ): ByteArrayInputStream? = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openFileDescriptor(songUri, "r") + }.getOrElse { + LogUtils.e(songUri, it) + null + }?.use { fileDescriptor -> + Taglib.getPictureWithFD(fileDescriptor.detachFd()) + ?.inputStream() + } + } + + /** + * 非音频文件Uri,而是已经缓存在MediaStore中的图片文件Uri + */ + private suspend fun fetchMediaStoreCovers(context: Context, uri: Uri?): InputStream? { + uri ?: return null + + return withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(uri) + }.getOrNull() + } + } + + + class MediaItemFetcherFactory : Fetcher.Factory { + override fun create(data: MediaItem, options: Options, imageLoader: ImageLoader): Fetcher = + MediaItemFetcher(options, data) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt index f5a480de9..3e1f713e1 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt @@ -1,5 +1,6 @@ package com.lalilu.lmusic.utils.coil.keyer +import androidx.media3.common.MediaItem import coil3.key.Keyer import coil3.request.Options import com.lalilu.common.base.Playable @@ -15,4 +16,10 @@ class PlayableKeyer : Keyer { override fun key(data: Playable, options: Options): String { return "${data::class.simpleName}_${data.mediaId}" } +} + +class MediaItemKeyer : Keyer { + override fun key(data: MediaItem, options: Options): String { + return data.mediaId + } } \ No newline at end of file diff --git a/lmedia b/lmedia index 2a906632c..04de31275 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 2a906632c711e3fcb0e1d36f3436497425d06549 +Subproject commit 04de312755840402198c5a4e628728d15f87c87d diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index eb2db6f5c..0a590bd3e 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -28,6 +28,7 @@ android { dependencies { implementation(project(":common")) implementation(project(":lmedia")) + implementation(libs.startup.runtime) implementation("com.github.cy745:AndroidVideoCache:2.7.2") implementation("androidx.media3:media3-exoplayer:1.4.1") diff --git a/lplayer/src/main/AndroidManifest.xml b/lplayer/src/main/AndroidManifest.xml index 6719312ec..a6af91a5b 100644 --- a/lplayer/src/main/AndroidManifest.xml +++ b/lplayer/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -22,5 +23,16 @@ + + + + + \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt new file mode 100644 index 000000000..4725dc4ef --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -0,0 +1,196 @@ +package com.lalilu.lplayer + +import android.content.ComponentName +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.session.MediaBrowser +import androidx.media3.session.SessionToken +import com.blankj.utilcode.util.LogUtils +import com.blankj.utilcode.util.Utils +import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.service.MService +import com.lalilu.lplayer.service.MServiceCallback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +object MPlayer : CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.IO + private val sessionToken by lazy { + SessionToken(Utils.getApp(), ComponentName(Utils.getApp(), MService::class.java)) + } + + private val browserFuture by lazy { + MediaBrowser + .Builder(Utils.getApp(), sessionToken) + .buildAsync() + } + + var isPlaying: Boolean by mutableStateOf(false) + private set + var currentMediaItem by mutableStateOf(null) + private set + var currentMediaMetadata: MediaMetadata? by mutableStateOf(null) + private set + var currentPlaylistMetadata: MediaMetadata? by mutableStateOf(null) + private set + var currentPosition: Long by mutableLongStateOf(0L) + private set + var currentDuration: Long by mutableLongStateOf(0L) + private set + var currentBufferedPosition: Long by mutableLongStateOf(0L) + private set + var currentTimelineItems by mutableStateOf>(emptyList()) + private set + + + internal fun init() { + launch(Dispatchers.Main) { + val browser = browserFuture.await() + browser.addListener(getListener(browser)) + + val items = browser.getChildren(MServiceCallback.ALL_SONGS, 0, Int.MAX_VALUE, null) + .await() + .value + + if (items.isNullOrEmpty()) { + LogUtils.i("No songs found") + return@launch + } + + browser.playWhenReady = false + browser.setMediaItems(items) + browser.prepare() + } + } + + fun doAction(action: PlayerAction) = launch(Dispatchers.Main) { + val browser = browserFuture.await() + + when (action) { + PlayerAction.Play -> browser.play() + PlayerAction.Pause -> browser.pause() + + PlayerAction.SkipToNext -> browser.seekToNext() + PlayerAction.SkipToPrevious -> browser.seekToPrevious() + + PlayerAction.PlayOrPause -> { + if (browser.isPlaying) { + browser.pause() + } else { + browser.play() + } + } + + is PlayerAction.PlayById -> { + browser.getItem(action.mediaId).await().value?.let { + val index = browser.currentTimeline.indexOf(action.mediaId) + + if (index == -1) { + val item = browser.getItem(action.mediaId) + .await().value ?: return@launch + + browser.addMediaItem(0, item) + browser.prepare() + browser.play() + } else { + browser.seekTo(index, 0) + } + } + } + + is PlayerAction.SeekTo -> { + browser.seekTo(action.positionMs) + } + + is PlayerAction.CustomAction -> {} + is PlayerAction.PauseWhenCompletion -> { +// if (action.cancel) cancelPauseWhenCompletion() else pauseWhenCompletion() + } + } + } + + private fun getListener(browser: MediaBrowser) = object : Player.Listener { + private var positionLoopJob: Job? = null + override fun onIsPlayingChanged(isPlaying: Boolean) { + this@MPlayer.isPlaying = isPlaying + + positionLoopJob?.cancel() + if (isPlaying) { + positionLoopJob = launch(Dispatchers.Main) { + while (isActive) { + currentPosition = browser.contentPosition + currentBufferedPosition = browser.bufferedPosition + delay(50) + } + } + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + currentDuration = browser.contentDuration.coerceAtLeast(0) + } + + currentPosition = browser.contentPosition.coerceAtLeast(0) + currentBufferedPosition = browser.bufferedPosition.coerceAtLeast(0) + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + currentMediaItem = mediaItem + +// if (mediaItem == null) return +// val firstItem = currentTimelineItems.firstOrNull() ?: return +// if (firstItem.mediaId != mediaItem.mediaId) { +// val index = browser.currentMediaItemIndex +// val items = browser.currentTimeline.toMediaItems() +// +// currentTimelineItems = items.drop(index) + items.take(index) +// } + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + currentMediaMetadata = mediaMetadata + } + + override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) { + currentPlaylistMetadata = mediaMetadata + } + +// override fun onTimelineChanged(timeline: Timeline, reason: Int) { +// currentTimelineItems = timeline.toMediaItems() +// LogUtils.i("onTimelineChanged: ${timeline.windowCount} ${timeline.periodCount}, reason: $reason ${currentTimelineItems.size}") +// } + + override fun onRepeatModeChanged(repeatMode: Int) { + super.onRepeatModeChanged(repeatMode) + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled) + } + } +} + +fun Timeline.toMediaItems(): List { + return (0 until this.windowCount) + .mapNotNull { this.getWindow(it, Timeline.Window()).mediaItem } +} + +fun Timeline.indexOf(mediaId: String): Int { + return (0 until this.windowCount).firstOrNull { + this.getWindow(it, Timeline.Window()) + .mediaItem.mediaId == mediaId + } ?: -1 +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/Startup.kt b/lplayer/src/main/java/com/lalilu/lplayer/Startup.kt new file mode 100644 index 000000000..6c5283607 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/Startup.kt @@ -0,0 +1,15 @@ +package com.lalilu.lplayer + +import android.content.Context +import androidx.startup.Initializer + + +class Startup : Initializer { + override fun create(context: Context) { + MPlayer.init() + } + + override fun dependencies(): MutableList>> { + return mutableListOf() + } +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt index e15f8cf41..e2ff3b245 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt @@ -2,5 +2,5 @@ package com.lalilu.lplayer.extensions interface Action { - fun action(): Boolean = false + fun action() } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt index e71ef64e2..4784e8396 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt @@ -1,9 +1,11 @@ package com.lalilu.lplayer.extensions -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer sealed class PlayerAction : Action { - override fun action(): Boolean = LPlayer.controller.doAction(this) + override fun action() { + MPlayer.doAction(this@PlayerAction) + } data object Play : PlayerAction() data object Pause : PlayerAction() diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt index 4b4f64d34..27416da96 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt @@ -3,7 +3,9 @@ package com.lalilu.lplayer.extensions import com.lalilu.lplayer.LPlayer sealed class QueueAction : Action { - override fun action(): Boolean = LPlayer.controller.doAction(this) + override fun action() { + LPlayer.controller.doAction(this) + } data object Clear : QueueAction() data object Shuffle : QueueAction() diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index 5276a72a2..d16437241 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -10,6 +10,7 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession +import androidx.media3.session.SessionError import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture @@ -19,6 +20,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlin.coroutines.CoroutineContext +@OptIn(UnstableApi::class) class MService : MediaLibraryService(), CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() @@ -52,7 +54,7 @@ class MService : MediaLibraryService(), CoroutineScope { ): MediaLibrarySession? = mediaSession } -@UnstableApi +@OptIn(UnstableApi::class) class MServiceCallback : MediaLibrarySession.Callback { companion object { const val ROOT = "root" @@ -121,7 +123,7 @@ class MServiceCallback : MediaLibrarySession.Callback { return Futures.immediateFuture( if (item != null) LibraryResult.ofItem(item, null) - else LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + else LibraryResult.ofError(SessionError.ERROR_BAD_VALUE) ) } @@ -147,7 +149,6 @@ class MServiceCallback : MediaLibrarySession.Callback { resolveMediaItems(mediaItems).toMutableList() ) - @OptIn(UnstableApi::class) override fun onPlaybackResumption( mediaSession: MediaSession, controller: MediaSession.ControllerInfo From 72ddd414bb6085feaeba5bae7589a427405c9121 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 4 Oct 2024 20:21:10 +0800 Subject: [PATCH 092/213] =?UTF-8?q?[refactor]=E5=AE=9E=E7=8E=B0=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E5=BC=80=E5=A7=8B=E6=92=AD=E6=94=BE=E5=92=8C=E6=9A=82?= =?UTF-8?q?=E5=81=9C=E6=92=AD=E6=94=BE=E6=97=B6=E7=9A=84=E6=B8=90=E5=85=A5?= =?UTF-8?q?=E6=B8=90=E5=87=BA=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extensions/FadeTransitionAudioSink.kt | 71 +++++++++++++++++++ .../com/lalilu/lplayer/service/MService.kt | 2 + 2 files changed, 73 insertions(+) create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt new file mode 100644 index 000000000..d5de826fb --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt @@ -0,0 +1,71 @@ +package com.lalilu.lplayer.extensions + +import android.content.Context +import androidx.annotation.OptIn +import androidx.dynamicanimation.animation.SpringForce +import androidx.dynamicanimation.animation.springAnimationOf +import androidx.dynamicanimation.animation.withSpringForceProperties +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.ForwardingAudioSink +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(UnstableApi::class) +class FadeTransitionAudioSink( + sink: AudioSink, + val scope: CoroutineScope, +) : ForwardingAudioSink(sink) { + private var volumeOverride = 0f + set(value) { + field = value + super.setVolume((value / 100f).coerceIn(0f..1f)) + } + + private var onFinished: (() -> Unit)? = null + private val animation = springAnimationOf( + getter = { volumeOverride }, + setter = { volumeOverride = it }, + ).withSpringForceProperties { + stiffness = SpringForce.STIFFNESS_LOW + dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY + }.apply { + setStartValue(0f) + setStartVelocity(0f) + addEndListener { _, canceled, _, _ -> + if (!canceled) onFinished?.invoke() + } + } + + override fun setVolume(volume: Float) { + volumeOverride = volume + } + + override fun play() { + scope.launch(Dispatchers.Main) { animation.animateToFinalPosition(100f) } + onFinished = null + super.play() + } + + override fun pause() { + scope.launch(Dispatchers.Main) { animation.animateToFinalPosition(0f) } + onFinished = { super.pause() } + } +} + +@OptIn(UnstableApi::class) +class FadeTransitionRenderersFactory( + context: Context, + val scope: CoroutineScope, +) : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ): AudioSink? { + return super.buildAudioSink(context, enableFloatOutput, enableAudioTrackPlaybackParams) + ?.let { FadeTransitionAudioSink(it, scope) } + } +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index d16437241..f897e9724 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -15,6 +15,7 @@ import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.lalilu.lmedia2.LMedia +import com.lalilu.lplayer.extensions.FadeTransitionRenderersFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -32,6 +33,7 @@ class MService : MediaLibraryService(), CoroutineScope { exoPlayer = ExoPlayer .Builder(this) + .setRenderersFactory(FadeTransitionRenderersFactory(this, this)) .build() mediaSession = MediaLibrarySession From aa76d99179a861ce30f260ce528d2f8e82f4d377 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 4 Oct 2024 20:25:41 +0800 Subject: [PATCH 093/213] =?UTF-8?q?[refactor]=E5=88=9B=E5=BB=BAMNotificati?= =?UTF-8?q?onProvider=EF=BC=8C=E5=AE=9E=E7=8E=B0=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=8F=E6=AD=8C=E8=AF=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/src/main/java/com/lalilu/common/Ext.kt | 8 + lmedia | 2 +- .../lplayer/service/MNotificationProvider.kt | 405 ++++++++++++++++++ .../com/lalilu/lplayer/service/MService.kt | 4 + 4 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 common/src/main/java/com/lalilu/common/Ext.kt create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt diff --git a/common/src/main/java/com/lalilu/common/Ext.kt b/common/src/main/java/com/lalilu/common/Ext.kt new file mode 100644 index 000000000..dab217b74 --- /dev/null +++ b/common/src/main/java/com/lalilu/common/Ext.kt @@ -0,0 +1,8 @@ +package com.lalilu.common + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope + +private val handler by lazy { Handler(Looper.getMainLooper()) } +fun CoroutineScope.post(block: () -> Unit) = handler.post(block) diff --git a/lmedia b/lmedia index 04de31275..fe12b0df3 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 04de312755840402198c5a4e628728d15f87c87d +Subproject commit fe12b0df39e152ee3046a639483eaeac4935292d diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt new file mode 100644 index 000000000..fe3557d87 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt @@ -0,0 +1,405 @@ +package com.lalilu.lplayer.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Bundle +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.Log +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX +import androidx.media3.session.DefaultMediaNotificationProvider.GROUP_KEY +import androidx.media3.session.DefaultMediaNotificationProvider.NotificationIdProvider +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaNotification.Provider.Callback +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaStyleNotificationHelper +import androidx.media3.session.R +import androidx.media3.session.SessionCommand +import com.google.common.collect.ImmutableList +import com.lalilu.common.post +import com.lalilu.lmedia2.lyric.LyricItem +import com.lalilu.lmedia2.lyric.LyricSourceEmbedded +import com.lalilu.lmedia2.lyric.LyricUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Arrays +import kotlin.coroutines.CoroutineContext + +const val FLAG_ALWAYS_SHOW_TICKER = 0x1000000 +const val FLAG_ONLY_UPDATE_TICKER = 0x2000000 + +@UnstableApi +class MNotificationProvider( + val context: Context +) : MediaNotification.Provider, CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() + private val notificationManager: NotificationManager by lazy { + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + private val lyricSource by lazy { LyricSourceEmbedded(context = context) } + private val channelId: String = DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID + private val channelName: String by lazy { getString(DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID) } + private val notificationIdProvider = NotificationIdProvider { session: MediaSession? -> + DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID + } + + override fun createNotification( + mediaSession: MediaSession, + customLayout: ImmutableList, + actionFactory: MediaNotification.ActionFactory, + onNotificationChangedCallback: Callback + ): MediaNotification { + ensureNotificationChannel() + + val customLayoutWithEnabledCommandButtonsOnly = ImmutableList.Builder() + customLayout.asSequence() + .filter { it.isEnabled && it.sessionCommand?.commandCode == SessionCommand.COMMAND_CODE_CUSTOM } + .forEach { customLayoutWithEnabledCommandButtonsOnly.add(it) } + + val player = mediaSession.player + val builder = NotificationCompat.Builder(context, channelId) + val notificationId: Int = notificationIdProvider.getNotificationId(mediaSession) + val mediaStyle = MediaStyleNotificationHelper.MediaStyle(mediaSession) + + val mediaButtons = getMediaButtons( + mediaSession, + player.availableCommands, + customLayoutWithEnabledCommandButtonsOnly.build(), + !Util.shouldShowPlayButton(player, mediaSession.showPlayButtonIfPlaybackIsSuppressed) + ) + + val compactViewIndices: IntArray = + addNotificationActions(mediaSession, mediaButtons, builder, actionFactory) + mediaStyle.setShowActionsInCompactView(*compactViewIndices) + + // Set metadata info in the notification. + if (player.isCommandAvailable(Player.COMMAND_GET_METADATA)) { + val metadata = player.mediaMetadata + val mediaItem = player.currentMediaItem + + builder + .setContentTitle(metadata.title) + .setContentText(metadata.artist) + + loadBitmapIntoNotification( + mediaSession = mediaSession, + metadata = metadata, + notificationId = notificationId, + builder = builder, + onNotificationChangedCallback = onNotificationChangedCallback + ) + + loadLyricIntoNotification( + mediaSession = mediaSession, + mediaItem = mediaItem, + notificationId = notificationId, + builder = builder, + onNotificationChangedCallback = onNotificationChangedCallback + ) + } + + if (player.isCommandAvailable(Player.COMMAND_STOP) || Util.SDK_INT < 21) { + // We must include a cancel intent for pre-L devices. + mediaStyle.setCancelButtonIntent( + actionFactory.createMediaActionPendingIntent( + mediaSession, + Player.COMMAND_STOP.toLong() + ) + ) + } + + val playbackStartTimeMs = getPlaybackStartTimeEpochMs(player) + val displayElapsedTimeWithChronometer = playbackStartTimeMs != C.TIME_UNSET + builder + .setWhen(if (displayElapsedTimeWithChronometer) playbackStartTimeMs else 0L) + .setShowWhen(displayElapsedTimeWithChronometer) + .setUsesChronometer(displayElapsedTimeWithChronometer) + + if (Util.SDK_INT >= 31) { + builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) + } + val smallIconResourceId = R.drawable.media3_notification_small_icon + + val notification: Notification = builder + .setContentIntent(mediaSession.sessionActivity) + .setDeleteIntent( + actionFactory.createMediaActionPendingIntent( + mediaSession, Player.COMMAND_STOP.toLong() + ) + ) + .setOnlyAlertOnce(true) + .setSmallIcon(smallIconResourceId) + .setStyle(mediaStyle) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(false) + .setGroup(GROUP_KEY) + .build() + return MediaNotification(notificationId, notification) + } + + override fun handleCustomCommand( + session: MediaSession, + action: String, + extras: Bundle + ): Boolean { + return false + } + + private var loadLyricJob: Job? = null + private var lyrics: Pair>? = null + private fun loadLyricIntoNotification( + mediaSession: MediaSession, + mediaItem: MediaItem?, + notificationId: Int, + builder: NotificationCompat.Builder, + onNotificationChangedCallback: Callback + ) { + loadLyricJob?.cancel() + if (mediaItem == null) return + + loadLyricJob = launch { + // 加载歌词 + if (lyrics?.first != mediaItem.mediaId) { + lyrics = mediaItem.mediaId to (lyricSource.loadLyric(mediaItem) + ?.let { LyricUtils.parseLrc(it.first, it.second) } + ?: emptyList()) + } + + var lastIndex = -1 + while (isActive) { + val list = lyrics?.second ?: break + val time = withContext(Dispatchers.Main) { mediaSession.player.currentPosition } + + val index = LyricUtils.findPlayingIndex(time, list) + if (lastIndex == index) { + delay(50) + continue + } + + lastIndex = index + val current = list.getOrNull(index) + + if (current != null) { + post { + val text = when (current) { + is LyricItem.SingleLyric -> current.content + is LyricItem.TranslatedLyric -> current.content + else -> "" + } + + builder.setTicker(text) + val notification = builder.build().apply { + flags = flags or FLAG_ALWAYS_SHOW_TICKER or FLAG_ONLY_UPDATE_TICKER + } + + onNotificationChangedCallback.onNotificationChanged( + MediaNotification(notificationId, notification) + ) + } + } + delay(50) + } + } + } + + private var loadBitmapJob: Job? = null + private fun loadBitmapIntoNotification( + mediaSession: MediaSession, + metadata: MediaMetadata, + notificationId: Int, + builder: NotificationCompat.Builder, + onNotificationChangedCallback: Callback + ) { + loadBitmapJob?.cancel() + loadBitmapJob = launch(Dispatchers.IO) { + val bitmapFuture = mediaSession.bitmapLoader + .loadBitmapFromMetadata(metadata) + ?: return@launch + + val result = runCatching { bitmapFuture.await() }.getOrElse { + Log.w("MNotificationProvider", "Failed to load bitmap: ${it.message}") + null + } ?: return@launch + + if (isActive) { + post { + builder.setLargeIcon(result) + onNotificationChangedCallback.onNotificationChanged( + MediaNotification(notificationId, builder.build()) + ) + } + } + } + } + + + private fun ensureNotificationChannel() { + if (Util.SDK_INT < 26 || notificationManager.getNotificationChannel(channelId) != null) { + return + } + + val channel = + NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW) + if (Util.SDK_INT <= 27) { + // API 28+ will automatically hide the app icon 'badge' for notifications using + // Notification.MediaStyle, but we have to manually hide it for APIs 26 (when badges were + // added) and 27. + channel.setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) + } + + protected fun getMediaButtons( + session: MediaSession?, + playerCommands: Player.Commands, + customLayout: ImmutableList, + showPauseButton: Boolean + ): ImmutableList { + val commandButtons = ImmutableList.Builder() + + // Skip to previous action. + if (playerCommands.containsAny( + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM + ) + ) commandButtons.add( + CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .setDisplayName(context.getString(R.string.media3_controls_seek_to_previous_description)) + .setExtras(createCommandButtonExtra()) + .build() + ) + + if (playerCommands.contains(Player.COMMAND_PLAY_PAUSE)) { + if (showPauseButton) commandButtons.add( + CommandButton.Builder(CommandButton.ICON_PAUSE) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setExtras(createCommandButtonExtra()) + .setDisplayName(getString(R.string.media3_controls_pause_description)) + .build() + ) else commandButtons.add( + CommandButton.Builder(CommandButton.ICON_PLAY) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setExtras(createCommandButtonExtra()) + .setDisplayName(getString(R.string.media3_controls_play_description)) + .build() + ) + } + + // Skip to next action. + if (playerCommands.containsAny( + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM + ) + ) commandButtons.add( + CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .setExtras(createCommandButtonExtra()) + .setDisplayName(getString(R.string.media3_controls_seek_to_next_description)) + .build() + ) + + customLayout.asSequence() + .filter { it.isEnabled && it.sessionCommand?.commandCode == SessionCommand.COMMAND_CODE_CUSTOM } + .forEach { commandButtons.add(it) } + + return commandButtons.build() + } + + protected fun addNotificationActions( + mediaSession: MediaSession, + mediaButtons: ImmutableList, + builder: NotificationCompat.Builder, + actionFactory: MediaNotification.ActionFactory + ): IntArray { + var compactViewIndices = IntArray(3) + val defaultCompactViewIndices = IntArray(3) + Arrays.fill(compactViewIndices, C.INDEX_UNSET) + Arrays.fill(defaultCompactViewIndices, C.INDEX_UNSET) + + mediaButtons.forEachIndexed { index, button -> + if (button.sessionCommand != null) { + builder.addAction( + actionFactory.createCustomActionFromCustomCommandButton( + mediaSession, + button + ) + ) + } else { + Assertions.checkState(button.playerCommand != Player.COMMAND_INVALID) + builder.addAction( + actionFactory.createMediaAction( + mediaSession, + IconCompat.createWithResource(context, button.iconResId), + button.displayName, + button.playerCommand + ) + ) + } + + val compactViewIndex = button.extras + .getInt(COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET) + + if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.size) { + // 将当前展开状态下的元素index存储在,收窄状态数组中的自定义index位置处 + compactViewIndices[compactViewIndex] = index + } + + // 记录默认元素的下标至默认数组 + when (button.playerCommand) { + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> defaultCompactViewIndices[0] = index + + Player.COMMAND_PLAY_PAUSE -> defaultCompactViewIndices[1] = index + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> defaultCompactViewIndices[2] = index + } + } + + // 若compactViewIndices[0]为-1,则说明没有设置自定义下标,则使用默认下标 + return if (compactViewIndices[0] == C.INDEX_UNSET) { + defaultCompactViewIndices + } else { + compactViewIndices + }.let { indices -> + val unsetItemIndex = indices.indexOfFirst { it == C.INDEX_UNSET } + + if (unsetItemIndex != -1) indices.copyOf(unsetItemIndex) else indices + } + } + + private fun getString(resId: Int): String = context.getString(resId) + private fun createCommandButtonExtra() = + Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET) } + + private fun getPlaybackStartTimeEpochMs(player: Player): Long { + // Changing "showWhen" causes notification flicker if SDK_INT < 21. + return if ((Util.SDK_INT >= 21 && player.isPlaying + && !player.isPlayingAd + && !player.isCurrentMediaItemDynamic) && player.playbackParameters.speed == 1f + ) { + System.currentTimeMillis() - player.contentPosition + } else { + C.TIME_UNSET + } + } +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index f897e9724..0c7916dcd 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -31,6 +31,10 @@ class MService : MediaLibraryService(), CoroutineScope { override fun onCreate() { super.onCreate() + setMediaNotificationProvider( + MNotificationProvider(this) + ) + exoPlayer = ExoPlayer .Builder(this) .setRenderersFactory(FadeTransitionRenderersFactory(this, this)) From e5cdf40ed9f236cd0a7b627cf4a1141bff98fff4 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 4 Oct 2024 20:30:42 +0800 Subject: [PATCH 094/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84lmedia?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E9=80=BB=E8=BE=91=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/LyricLayout.kt | 54 ++++++--------- .../compose/screen/playing/LyricSentence.kt | 56 +++++++++------ .../compose/screen/playing/PlayingLayout.kt | 68 ++++++++++++------- .../lmusic/utils/extension/Extensions.kt | 38 ++--------- .../main/java/com/lalilu/lplayer/MPlayer.kt | 54 ++++++--------- 5 files changed, 125 insertions(+), 145 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt index ab9b5b3ed..4b021cf33 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt @@ -41,22 +41,17 @@ import androidx.compose.ui.unit.sp import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord +import com.lalilu.lmedia2.lyric.LyricItem +import com.lalilu.lmedia2.lyric.LyricUtils import com.lalilu.lmusic.utils.extension.edgeTransparent import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.isActive import java.io.File +import java.util.WeakHashMap import kotlin.math.abs -data class LyricEntry( - val index: Int, - val time: Long, - val text: String, - val translate: String? = null -) { - val key = "$index:$time" -} /** * 读取字体文件,并将其转换成Compose可用的FontFamily @@ -104,13 +99,19 @@ fun rememberTextSizeFromInt(textSize: () -> Int?): TextUnit { return remember(textSize()) { textSize()?.takeIf { it > 0 }?.sp ?: 26.sp } } -private val EMPTY_SENTENCE_TIPS = LyricEntry( - index = 0, +private val EMPTY_SENTENCE_TIPS = LyricItem.SingleLyric( time = 0, - text = "暂无歌词", - translate = null + content = "暂无歌词", ) +private val indexKeeper = WeakHashMap() +var LyricItem.index: Int + get() = indexKeeper[this] ?: -1 + set(value) = run { indexKeeper[this] = value } + +val LyricItem.key + get() = "${index}:$time" + @OptIn(FlowPreview::class) @Composable fun LyricLayout( @@ -125,9 +126,9 @@ fun LyricLayout( isUserScrollEnable: () -> Boolean = { false }, isTranslationShow: () -> Boolean = { false }, onPositionReset: () -> Unit = {}, - onItemClick: (LyricEntry) -> Unit = {}, - onItemLongClick: (LyricEntry) -> Unit = {}, - lyricEntry: State> = remember { mutableStateOf(emptyList()) }, + onItemClick: (LyricItem) -> Unit = {}, + onItemLongClick: (LyricItem) -> Unit = {}, + lyricEntry: State> = remember { mutableStateOf(emptyList()) }, fontFamily: State = remember { mutableStateOf(null) } ) { val textMeasurer = rememberTextMeasurer() @@ -143,29 +144,12 @@ fun LyricLayout( derivedStateOf { val time = currentTime() val lyricEntryList = lyricEntry.value - if (lyricEntryList.isEmpty()) return@derivedStateOf Int.MAX_VALUE - var left = 0 - var right = lyricEntryList.size - var currentItemIndex = 0 - while (left <= right) { - val middle = (left + right) / 2 - val middleTime = lyricEntryList[middle].time - if (time < middleTime) { - right = middle - 1 - } else { - if (middle + 1 >= lyricEntryList.size || time < lyricEntryList[middle + 1].time) { - currentItemIndex = middle - break - } - left = middle + 1 - } - } - currentItemIndex + LyricUtils.findPlayingIndex(time, lyricEntryList) } } - val currentItem: State = remember { + val currentItem: State = remember { derivedStateOf { currentItemIndex.value .takeIf { it != Int.MAX_VALUE } @@ -234,7 +218,7 @@ fun LyricLayout( itemsIndexedWithRecord( items = lyricEntry.value, key = { _, item -> item.key }, - contentType = { _, _ -> LyricEntry::class } + contentType = { _, _ -> LyricItem::class } ) { index, item -> LyricSentence( lyric = item, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt index 2fa8c4b89..a70d1dc93 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt @@ -33,13 +33,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.lalilu.lmedia2.lyric.LyricItem @OptIn(ExperimentalFoundationApi::class) @Composable fun LyricSentence( modifier: Modifier = Modifier, - lyric: LyricEntry, + lyric: LyricItem, textMeasurer: TextMeasurer, maxWidth: () -> Int = { 1080 }, currentTime: () -> Long = { 0L }, @@ -71,26 +72,41 @@ fun LyricSentence( ) } val (textResult, translateResult) = remember(textAlign, textSize, fontFamily, lyric) { - textMeasurer.measure( - text = lyric.text, - constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize, - textAlign = textAlign, - fontFamily = fontFamily.value - ?: TextStyle.Default.fontFamily - ) - ) to lyric.translate?.let { - textMeasurer.measure( - text = it, - constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize * translationScale, - textAlign = textAlign, - fontFamily = fontFamily.value - ?: TextStyle.Default.fontFamily + when (lyric) { + is LyricItem.SingleLyric -> { + textMeasurer.measure( + text = lyric.content, + constraints = actualConstraints, + style = TextStyle.Default.copy( + fontSize = textSize, + textAlign = textAlign, + fontFamily = fontFamily.value + ?: TextStyle.Default.fontFamily + ) + ) to null + } + + is LyricItem.TranslatedLyric -> { + textMeasurer.measure( + text = lyric.content, + constraints = actualConstraints, + style = TextStyle.Default.copy( + fontSize = textSize, + textAlign = textAlign, + fontFamily = fontFamily.value + ?: TextStyle.Default.fontFamily + ) + ) to textMeasurer.measure( + text = lyric.translated, + constraints = actualConstraints, + style = TextStyle.Default.copy( + fontSize = textSize * translationScale, + textAlign = textAlign, + fontFamily = fontFamily.value + ?: TextStyle.Default.fontFamily + ) ) - ) + } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 6698bbd02..2c32354c0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -20,44 +20,53 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp -import com.dirror.lyricviewx.LyricUtil +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.lalilu.component.base.LocalEnhanceSheetState import com.lalilu.component.extension.hideControl import com.lalilu.component.extension.singleViewModel +import com.lalilu.lmedia2.lyric.LyricItem +import com.lalilu.lmedia2.lyric.LyricSourceEmbedded +import com.lalilu.lmedia2.lyric.LyricUtils import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.koin.compose.koinInject import kotlin.math.pow -@OptIn(ExperimentalCoroutinesApi::class) @Composable fun PlayingLayout( playingVM: PlayingViewModel = singleViewModel(), settingsSp: SettingsSp = koinInject(), ) { + val context = LocalContext.current val haptic = LocalHapticFeedback.current val enhanceSheetState = LocalEnhanceSheetState.current + val lifecycle = LocalLifecycleOwner.current val systemUiController = rememberSystemUiController() val lyricLayoutLazyListState = rememberLazyListState() @@ -86,6 +95,21 @@ fun PlayingLayout( systemUiController.isStatusBarVisible = !hideComponent.value } + val currentPosition = remember { mutableFloatStateOf(0f) } + + LaunchedEffect(Unit) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + withFrameMillis { + val newValue = MPlayer.currentPosition.toFloat() + if (currentPosition.floatValue != newValue) { + currentPosition.floatValue = newValue + } + } + } + } + } + NestedScrollBaseLayout( draggable = draggable, isLyricScrollEnable = isLyricScrollEnable, @@ -122,23 +146,6 @@ fun PlayingLayout( { x -> -2f * (x - 0.5f).pow(2) + 0.5f } } - val flow = remember { - playingVM.lyricRepository.currentLyric - .mapLatest { - LyricUtil - .parseLrc(arrayOf(it?.first, it?.second)) - ?.mapIndexed { index, lyricEntry -> - LyricEntry( - index = index, - time = lyricEntry.time, - text = lyricEntry.text, - translate = lyricEntry.secondText - ) - } - ?: emptyList() - } - } - val lyricEntry = flow.collectAsState(initial = emptyList()) val minToMiddleProgress = remember { derivedStateOf { draggable.progressBetween( @@ -203,6 +210,19 @@ fun PlayingLayout( } ) + val lyricSource = remember { LyricSourceEmbedded(context = context) } + val lyrics = remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(key1 = MPlayer.currentMediaItem) { + launch(Dispatchers.IO) { + MPlayer.currentMediaItem + ?.let { lyricSource.loadLyric(it) } + ?.let { LyricUtils.parseLrc(it.first, it.second) } + ?.mapIndexed { index, lyricItem -> lyricItem.also { it.index = index } } + .let { if (isActive) lyrics.value = it ?: emptyList() } + } + } + LyricLayout( modifier = Modifier .fillMaxSize() @@ -219,7 +239,7 @@ fun PlayingLayout( translationY = additionalOffset + fixOffset alpha = progressIncrease }, - lyricEntry = lyricEntry, + lyricEntry = lyrics, listState = lyricLayoutLazyListState, currentTime = { seekbarTime.longValue }, maxWidth = { constraints.maxWidth }, @@ -279,7 +299,7 @@ fun PlayingLayout( animateColor = { animateColor.value }, onValueChange = { seekbarTime.longValue = it.toLong() }, maxValue = { MPlayer.currentDuration.toFloat() }, - dataValue = { MPlayer.currentPosition.toFloat() }, + dataValue = { currentPosition.floatValue }, onDispatchDragOffset = { enhanceSheetState?.dispatch(it) }, onDragStop = { result -> if (result == -1) enhanceSheetState?.hide() diff --git a/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt b/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt index 7ac89bf21..893fa3371 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt @@ -13,7 +13,10 @@ import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable -import android.graphics.drawable.GradientDrawable.Orientation.* +import android.graphics.drawable.GradientDrawable.Orientation.BOTTOM_TOP +import android.graphics.drawable.GradientDrawable.Orientation.LEFT_RIGHT +import android.graphics.drawable.GradientDrawable.Orientation.RIGHT_LEFT +import android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM import android.net.Uri import android.os.Build import android.provider.MediaStore @@ -25,15 +28,16 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.blankj.utilcode.util.GsonUtils import com.blankj.utilcode.util.LogUtils -import com.dirror.lyricviewx.LyricEntry import com.google.gson.reflect.TypeToken import com.lalilu.R import com.lalilu.lmedia.entity.LSong -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import java.security.SecureRandom import java.security.cert.X509Certificate import javax.net.ssl.HttpsURLConnection @@ -226,34 +230,6 @@ fun List.removeAt(index: Int): List { } } -/** - * 根据当前时间使用二分查找,查找最接近的歌词 - */ -fun findShowLine(list: List?, time: Long): Int { - if (list == null || list.isEmpty()) return 0 - var left = 0 - var right = list.size - while (left <= right) { - val middle = (left + right) / 2 - val middleTime = list[middle].time - if (time < middleTime) { - right = middle - 1 - } else { - if (middle + 1 >= list.size || time < list[middle + 1].time) { - return middle - } - left = middle + 1 - } - } - return 0 -} - -fun List.average(numToCalc: (T) -> Number): Float { - return this.fold(0f) { acc, t -> - acc + numToCalc(t).toFloat() - } / this.size -} - fun calculateExtraLayoutSpace(context: Context, size: Int): LinearLayoutManager { return object : LinearLayoutManager(context) { override fun calculateExtraLayoutSpace( diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index 4725dc4ef..4994ed880 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -18,10 +18,7 @@ import com.lalilu.lplayer.service.MService import com.lalilu.lplayer.service.MServiceCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.guava.await -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext @@ -45,15 +42,18 @@ object MPlayer : CoroutineScope { private set var currentPlaylistMetadata: MediaMetadata? by mutableStateOf(null) private set - var currentPosition: Long by mutableLongStateOf(0L) - private set var currentDuration: Long by mutableLongStateOf(0L) private set - var currentBufferedPosition: Long by mutableLongStateOf(0L) - private set var currentTimelineItems by mutableStateOf>(emptyList()) private set + val currentPosition: Long + get() = runCatching { if (browserFuture.isDone) browserFuture.get()?.currentPosition else null } + .getOrNull() ?: 0L + + val currentBufferedPosition: Long + get() = runCatching { if (browserFuture.isDone) browserFuture.get()?.bufferedPosition else null } + .getOrNull() ?: 0L internal fun init() { launch(Dispatchers.Main) { @@ -122,42 +122,19 @@ object MPlayer : CoroutineScope { } private fun getListener(browser: MediaBrowser) = object : Player.Listener { - private var positionLoopJob: Job? = null override fun onIsPlayingChanged(isPlaying: Boolean) { this@MPlayer.isPlaying = isPlaying - - positionLoopJob?.cancel() - if (isPlaying) { - positionLoopJob = launch(Dispatchers.Main) { - while (isActive) { - currentPosition = browser.contentPosition - currentBufferedPosition = browser.bufferedPosition - delay(50) - } - } - } } override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_READY) { currentDuration = browser.contentDuration.coerceAtLeast(0) } - - currentPosition = browser.contentPosition.coerceAtLeast(0) - currentBufferedPosition = browser.bufferedPosition.coerceAtLeast(0) } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { currentMediaItem = mediaItem - -// if (mediaItem == null) return -// val firstItem = currentTimelineItems.firstOrNull() ?: return -// if (firstItem.mediaId != mediaItem.mediaId) { -// val index = browser.currentMediaItemIndex -// val items = browser.currentTimeline.toMediaItems() -// -// currentTimelineItems = items.drop(index) + items.take(index) -// } + updateItems() } override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { @@ -168,10 +145,9 @@ object MPlayer : CoroutineScope { currentPlaylistMetadata = mediaMetadata } -// override fun onTimelineChanged(timeline: Timeline, reason: Int) { -// currentTimelineItems = timeline.toMediaItems() -// LogUtils.i("onTimelineChanged: ${timeline.windowCount} ${timeline.periodCount}, reason: $reason ${currentTimelineItems.size}") -// } + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + updateItems(timeline) + } override fun onRepeatModeChanged(repeatMode: Int) { super.onRepeatModeChanged(repeatMode) @@ -180,6 +156,14 @@ object MPlayer : CoroutineScope { override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { super.onShuffleModeEnabledChanged(shuffleModeEnabled) } + + fun updateItems( + timeline: Timeline = browser.currentTimeline, + currentIndex: Int = browser.currentMediaItemIndex + ) { + val items = timeline.toMediaItems() + currentTimelineItems = items.drop(currentIndex) + items.take(currentIndex) + } } } From a865d80cf02387f655c4dfc8d3e23ce361c988a7 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 4 Oct 2024 20:34:10 +0800 Subject: [PATCH 095/213] =?UTF-8?q?[refactor]=E5=8E=BB=E9=99=A4=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 5 - app/src/main/AndroidManifest.xml | 20 -- .../main/java/com/lalilu/lmusic/AppModule.kt | 6 - .../lmusic/repository/LyricRepository.kt | 68 ------ .../lalilu/lmusic/service/LMusicNotifier.kt | 218 ------------------ .../lalilu/lmusic/service/LMusicService.kt | 112 --------- .../lmusic/viewmodel/PlayingViewModel.kt | 25 -- .../component/viewmodel/PlayingViewModel.kt | 2 - .../lplayer/notification/BaseNotification.kt | 207 ----------------- .../lalilu/lplayer/notification/Notifier.kt | 11 - .../com/lalilu/lplayer/service/LService.kt | 211 ----------------- 11 files changed, 885 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/repository/LyricRepository.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/service/LMusicService.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/notification/BaseNotification.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/notification/Notifier.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9aed9aa34..fd63aaaa6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -199,11 +199,6 @@ dependencies { // Bitmap的Blur实现库 implementation("com.github.Commit451:NativeStackBlur:1.0.4") - // https://github.com/Moriafly/LyricViewX - // GPL-3.0 License - // 歌词组件 - implementation("com.github.cy745:LyricViewX:7c92c6d19a") - // https://github.com/qinci/EdgeTranslucent // https://github.com/cy745/EdgeTranslucent // Undeclared License diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a550042ac..ec39d89c8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,26 +42,6 @@ android:name="ScopedStorage" android:value="true" /> - - - - - - - - - - - - - - - - - - - - { get() } singleOf(::CoverRepository) - singleOf(::LyricRepository) } val ApiModule = module { diff --git a/app/src/main/java/com/lalilu/lmusic/repository/LyricRepository.kt b/app/src/main/java/com/lalilu/lmusic/repository/LyricRepository.kt deleted file mode 100644 index 94a7ba444..000000000 --- a/app/src/main/java/com/lalilu/lmusic/repository/LyricRepository.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.lalilu.lmusic.repository - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import com.dirror.lyricviewx.LyricUtil -import com.lalilu.common.base.Playable -import com.lalilu.common.base.Sticker -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmedia.repository.LyricSourceFactory -import com.lalilu.lmusic.utils.extension.findShowLine -import com.lalilu.lplayer.LPlayer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext -import kotlin.coroutines.CoroutineContext - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -class LyricRepository( - private val lyricSource: LyricSourceFactory, -) : CoroutineScope { - override val coroutineContext: CoroutineContext = Dispatchers.IO - val runtime = LPlayer.runtime - - @Composable - fun rememberHasLyric(playable: Playable): State { - return remember { mutableStateOf(false) }.also { state -> - LaunchedEffect(playable) { - if (isActive) { - state.value = hasLyric(playable) - } - } - } - } - - suspend fun hasLyric(song: Playable): Boolean = withContext(Dispatchers.IO) { - if (song.sticker.contains(Sticker.HasLyricSticker)) return@withContext true - if (song !is LSong) return@withContext false - lyricSource.hasLyric(song) - } - - val currentLyric: Flow?> = - runtime.info.playingIdFlow.flatMapLatest { id -> - LMedia.getFlow(id) - .mapLatest { it?.let { lyricSource.loadLyric(it) } } - } - - val currentLyricSentence: Flow = currentLyric.mapLatest { pair -> - pair ?: return@mapLatest null - LyricUtil.parseLrc(arrayOf(pair.first, pair.second)) - }.flatMapLatest { lyrics -> - runtime.info.positionFlow.mapLatest { - findShowLine(lyrics, it + 500) - }.distinctUntilChanged() - .mapLatest { lyrics?.getOrNull(it)?.text } - }.debounce(100) -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt b/app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt deleted file mode 100644 index f2251e4a2..000000000 --- a/app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt +++ /dev/null @@ -1,218 +0,0 @@ -package com.lalilu.lmusic.service - -import StatusBarLyric.API.StatusBarLyric -import android.app.Notification -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.os.Build -import android.support.v4.media.session.MediaSessionCompat -import androidx.core.app.NotificationCompat -import androidx.palette.graphics.Palette -import coil3.annotation.ExperimentalCoilApi -import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.request.allowHardware -import coil3.toBitmap -import com.lalilu.R -import com.lalilu.common.getAutomaticColor -import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lmusic.repository.CoverRepository -import com.lalilu.lmusic.repository.LyricRepository -import com.lalilu.lmusic.utils.extension.getMediaId -import com.lalilu.lplayer.LPlayer -import com.lalilu.lplayer.extensions.isPlaying -import com.lalilu.lplayer.notification.BaseNotification -import com.lalilu.lplayer.playback.PlayMode -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext - -@OptIn(ExperimentalCoroutinesApi::class) -class LMusicNotifier constructor( - private val mContext: Context, - private val lyricRepo: LyricRepository, - private val coverRepo: CoverRepository, - private val settingsSp: SettingsSp, - private val statusBarLyric: StatusBarLyric -) : BaseNotification(mContext), CoroutineScope { - override val coroutineContext: CoroutineContext = Dispatchers.Default + SupervisorJob() - - /** - * 创建基础的Notification.Builder,从mediaSession读取基础数据填充 - */ - private val notificationBuilderFlow = MutableStateFlow(null) - private var notificationLoopJob: Job? = null - - @OptIn(ExperimentalCoilApi::class) - override suspend fun getBitmapFromData(data: Any?): Bitmap? { - return mContext.imageLoader.execute( - ImageRequest.Builder(mContext) - .allowHardware(false) - .data(data) - .size(400) - .build() - ).image?.toBitmap() - } - - override fun getColorFromBitmap(bitmap: Bitmap): Int { - return Palette.from(bitmap) - .generate() - .getAutomaticColor() - } - - override fun NotificationCompat.Builder.customActionBtn(playMode: PlayMode): NotificationCompat.Builder { - return addAction( - when (playMode) { - PlayMode.ListRecycle -> mOrderPlayAction - PlayMode.RepeatOne -> mSingleRepeatAction - PlayMode.Shuffle -> mShufflePlayAction - } - ) - } - - init { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(PLAYER_CHANNEL_ID, PLAYER_CHANNEL_NAME) - } - notificationManager.cancelAll() - } - - private val mOrderPlayAction: NotificationCompat.Action = NotificationCompat.Action( - R.drawable.ic_order_play_line, "order_play", - buildServicePendingIntent( - mContext, 1, - Intent(mContext, LMusicService::class.java) - .setAction(LPlayer.ACTION_SET_REPEAT_MODE) - .putExtra(PlayMode.KEY, PlayMode.ListRecycle.next().value) - ) - ) - private val mSingleRepeatAction: NotificationCompat.Action = NotificationCompat.Action( - R.drawable.ic_repeat_one_line, "single_repeat", - buildServicePendingIntent( - mContext, 2, - Intent(mContext, LMusicService::class.java) - .setAction(LPlayer.ACTION_SET_REPEAT_MODE) - .putExtra(PlayMode.KEY, PlayMode.RepeatOne.next().value) - ) - ) - private val mShufflePlayAction: NotificationCompat.Action = NotificationCompat.Action( - R.drawable.ic_shuffle_line, "shuffle_play", - buildServicePendingIntent( - mContext, 3, - Intent(mContext, LMusicService::class.java) - .setAction(LPlayer.ACTION_SET_REPEAT_MODE) - .putExtra(PlayMode.KEY, PlayMode.Shuffle.next().value) - ) - ) - - private fun startLoop(mediaSession: MediaSessionCompat) { - notificationLoopJob?.cancel() - notificationLoopJob = notificationBuilderFlow.flatMapLatest { builder -> - val mediaId = mediaSession.getMediaId() - - coverRepo.fetch(mediaId).mapLatest { - builder?.loadCoverAndPalette(mediaSession, it)?.build() - } - }.combine(lyricRepo.currentLyricSentence) { notification, sentence -> - notification?.setLyricTicker(sentence) - }.combine(settingsSp.enableStatusLyric.flow(true)) { notification, enable -> - notification?.apply { - if (enable == true && mediaSession.isPlaying()) return@apply - - clearLyricTicker() - } - } -// .debounce(50) - .onEach { - if (it == null) { - notificationManager.cancel(NOTIFICATION_PLAYER_ID) - } else { - statusBarLyric.updateLyric(it.tickerText?.toString() ?: "") - notificationManager.notify(NOTIFICATION_PLAYER_ID, it) - } - }.launchIn(this) - } - - private fun stopLoop() { - statusBarLyric.stopLyric() - notificationLoopJob?.cancel() - notificationLoopJob = null - } - - override fun startForeground( - mediaSession: MediaSessionCompat, - callback: (Int, Notification) -> Unit - ) { - val builder = buildMediaNotification( - mediaSession = mediaSession, - channelId = PLAYER_CHANNEL_ID, - smallIcon = R.drawable.ic_launcher_icon - )?.loadCoverAndPalette(mediaSession, null) - ?: return - callback(NOTIFICATION_PLAYER_ID, builder.build()) - notificationBuilderFlow.tryEmit(builder) - startLoop(mediaSession) - } - - override fun stopForeground(callback: () -> Unit) { - stopLoop() - callback() - } - - override fun update(mediaSession: MediaSessionCompat) { - launch { - notificationBuilderFlow.emit( - buildMediaNotification( - mediaSession = mediaSession, - channelId = PLAYER_CHANNEL_ID, - smallIcon = R.drawable.ic_launcher_icon - ) - ) - } - } - - override fun cancel() { - stopLoop() - notificationManager.cancel(NOTIFICATION_PLAYER_ID) - } - - private fun Notification.setLyricTicker(text: String?): Notification = apply { - this.tickerText = text - flags = if (flags and FLAG_ALWAYS_SHOW_TICKER != FLAG_ALWAYS_SHOW_TICKER) { - flags or FLAG_ALWAYS_SHOW_TICKER - } else { - flags or FLAG_ONLY_UPDATE_TICKER - } - } - - private fun Notification.clearLyricTicker() { - tickerText = null - flags = flags and FLAG_ALWAYS_SHOW_TICKER.inv() - flags = flags and FLAG_ONLY_UPDATE_TICKER.inv() - } - - companion object { - const val NOTIFICATION_PLAYER_ID = 7 - const val NOTIFICATION_LOGGER_ID = 8 - - private const val PLAYER_CHANNEL_NAME = "LMusic Player" - private const val LOGGER_CHANNEL_NAME = "LMusic Logger" - - const val PLAYER_CHANNEL_ID = PLAYER_CHANNEL_NAME + "_ID" - const val LOGGER_CHANNEL_ID = PLAYER_CHANNEL_NAME + "_ID" - - const val FLAG_ALWAYS_SHOW_TICKER = 0x1000000 - const val FLAG_ONLY_UPDATE_TICKER = 0x2000000 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/service/LMusicService.kt b/app/src/main/java/com/lalilu/lmusic/service/LMusicService.kt deleted file mode 100644 index 1e0687f8c..000000000 --- a/app/src/main/java/com/lalilu/lmusic/service/LMusicService.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.lalilu.lmusic.service - -import android.content.Intent -import com.lalilu.common.base.Playable -import com.lalilu.lhistory.repository.HistoryRepository -import com.lalilu.lmusic.Config -import com.lalilu.lhistory.entity.HISTORY_TYPE_SONG -import com.lalilu.lhistory.entity.LHistory -import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lmusic.utils.EQHelper -import com.lalilu.component.extension.collectWithLifeCycleOwner -import com.lalilu.lplayer.LPlayer -import com.lalilu.lplayer.extensions.AudioFocusHelper -import com.lalilu.lplayer.playback.PlayMode -import com.lalilu.lplayer.service.LService -import org.koin.android.ext.android.inject - -class LMusicService : LService() { - private val intent: Intent by lazy { Intent(this@LMusicService, LMusicService::class.java) } - override fun getStartIntent(): Intent = intent - - private val historyRepo: HistoryRepository by inject() - private val settingsSp: SettingsSp by inject() - private val eqHelper: EQHelper by inject() - - override fun onCreate() { - super.onCreate() - settingsSp.apply { - volumeControl.flow(true) - .collectWithLifeCycleOwner(this@LMusicService) { - it?.let { playback.setMaxVolume(it) } - } - enableSystemEq.flow(true) - .collectWithLifeCycleOwner(this@LMusicService) { - eqHelper.setSystemEqEnable(it ?: false) - } - playMode.flow(true) - .collectWithLifeCycleOwner(this@LMusicService) { - it?.let { playback.playMode = PlayMode.of(it) } - } - ignoreAudioFocus.flow(true) - .collectWithLifeCycleOwner(this@LMusicService) { - AudioFocusHelper.ignoreAudioFocus = it ?: false - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val extras = intent?.extras - when (intent?.action) { - LPlayer.ACTION_SET_REPEAT_MODE -> { - val playMode = extras?.getInt(PlayMode.KEY)?.takeIf { it in 0..2 } - - playMode?.let { settingsSp.playMode.value = it } - } - } - return super.onStartCommand(intent, flags, startId) - } - - private var lastMediaId: String? = null - private var startTime: Long = 0L - private var duration: Long = 0L - override fun onItemPlay(item: Playable) { - val now = System.currentTimeMillis() - if (startTime > 0) duration += now - startTime - - // 若切歌了或者播放时长超过阈值,更新或删除上一首歌的历史记录 - if (lastMediaId != item.mediaId || duration >= Config.HISTORY_DURATION_THRESHOLD || duration >= item.durationMs) { - if (lastMediaId != null) { - if (duration >= Config.HISTORY_DURATION_THRESHOLD) { - historyRepo.updatePreSavedHistory( - contentId = lastMediaId!!, - duration = duration - ) - } else { - historyRepo.removePreSavedHistory(contentId = lastMediaId!!) - } - } - - // 将当前播放的歌曲预保存添加到历史记录中 - historyRepo.preSaveHistory( - LHistory( - contentId = item.mediaId, - duration = -1L, - startTime = now, - type = HISTORY_TYPE_SONG - ) - ) - duration = 0L - } - - startTime = now - lastMediaId = item.mediaId - } - - override fun onItemPause(item: Playable) { - // 判断当前暂停时的歌曲是否是最近正在播放的歌曲 - if (lastMediaId != item.mediaId) return - - // 将该歌曲目前为止播放的时间加到历史记录中 - if (startTime > 0) { - duration += System.currentTimeMillis() - startTime - startTime = -1L - } - } - - override fun onPlayerCreated(id: Any) { - if (id is Int) { - eqHelper.audioSessionId = id - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt index 7a8cea52a..8ea9f196d 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt @@ -1,25 +1,18 @@ package com.lalilu.lmusic.viewmodel -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.viewModelScope import com.lalilu.common.base.Playable import com.lalilu.component.extension.toState import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lmusic.repository.LyricRepository import com.lalilu.lplayer.LPlayer import com.lalilu.lplayer.extensions.PlayerAction import com.lalilu.lplayer.extensions.QueueAction import com.lalilu.lplaylist.repository.PlaylistRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class PlayingViewModel( val settingsSp: SettingsSp, - val lyricRepository: LyricRepository, playlistRepo: PlaylistRepository ) : IPlayingViewModel() { val playing = LPlayer.runtime.info.playingFlow.toState(viewModelScope) @@ -62,24 +55,6 @@ class PlayingViewModel( return playing.value?.let { compare(it) } ?: false } - override fun requireLyric(item: Playable, callback: (hasLyric: Boolean) -> Unit) { - viewModelScope.launch { - if (isActive) { - val hasLyric = lyricRepository.hasLyric(item) - withContext(Dispatchers.Main) { callback(hasLyric) } - } - } - } - - private val hasLyricList = mutableStateMapOf() - override fun requireHasLyric(item: Playable): SnapshotStateMap { - viewModelScope.launch { - if (!isActive) return@launch - hasLyricList[item.mediaId] = lyricRepository.hasLyric(item) - } - return hasLyricList - } - private val isFavouriteList = playlistRepo.getFavouriteMediaIds() .toState(viewModelScope) diff --git a/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt b/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt index 8fbe9d428..46d010114 100644 --- a/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt +++ b/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt @@ -23,7 +23,5 @@ abstract class IPlayingViewModel : ViewModel() { abstract fun isItemPlaying(item: T, getter: (Playable) -> T): Boolean abstract fun isItemPlaying(compare: (Playable) -> Boolean): Boolean - abstract fun requireLyric(item: Playable, callback: (hasLyric: Boolean) -> Unit) - abstract fun requireHasLyric(item: Playable): SnapshotStateMap abstract fun isFavourite(item: Playable): Boolean } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/notification/BaseNotification.kt b/lplayer/src/main/java/com/lalilu/lplayer/notification/BaseNotification.kt deleted file mode 100644 index 26e73a447..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/notification/BaseNotification.kt +++ /dev/null @@ -1,207 +0,0 @@ -package com.lalilu.lplayer.notification - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.drawable.Drawable -import android.os.Build -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import androidx.annotation.DrawableRes -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.media.session.MediaButtonReceiver -import com.lalilu.lplayer.R -import com.lalilu.lplayer.extensions.isPlaying -import com.lalilu.lplayer.playback.PlayMode -import kotlinx.coroutines.runBlocking - -abstract class BaseNotification constructor( - private val mContext: Context, -) : Notifier { - private var lastBitmap: Pair? = null - private var lastColor: Pair? = null - private val emptyBitmap: Bitmap? by lazy { - ContextCompat.getDrawable(mContext, R.drawable.ic_music_notification_bg_64dp)?.toBitmap() - } - - abstract suspend fun getBitmapFromData(data: Any?): Bitmap? - abstract fun getColorFromBitmap(bitmap: Bitmap): Int - abstract fun NotificationCompat.Builder.customActionBtn(playMode: PlayMode): NotificationCompat.Builder - - - /** - * 加载歌曲封面和提取配色,若已有缓存则直接取用,若无则阻塞获取,需确保调用方不阻塞主要动作 - */ - protected fun NotificationCompat.Builder.loadCoverAndPalette( - mediaSession: MediaSessionCompat?, - data: Any? - ): NotificationCompat.Builder = apply { - var bitmap: Bitmap? = null - var color: Int = Color.TRANSPARENT - - lastBitmap?.takeIf { it.first == data }?.let { bitmap = it.second } - lastColor?.takeIf { it.first == data }?.let { color = it.second } - - if (bitmap == null) { - if (data != null) { - runBlocking { - bitmap = getBitmapFromData(data) ?: return@runBlocking - } - } - - if (bitmap != null) { - color = getColorFromBitmap(bitmap!!) - - if (data != null) { - lastBitmap = data to bitmap!! - lastColor = data to color - } - } else { - bitmap = emptyBitmap - lastColor?.second?.let { color = it } - } - } - - if (bitmap != null) { - if (bitmap != emptyBitmap) { - mediaSession?.setMetadata( - MediaMetadataCompat.Builder(mediaSession.controller.metadata) - .putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) - .build() - ) - } - this@loadCoverAndPalette.setLargeIcon(bitmap) - this@loadCoverAndPalette.color = color - } - } - - fun buildMediaNotification( - mediaSession: MediaSessionCompat, - channelId: String, - @DrawableRes smallIcon: Int - ): NotificationCompat.Builder? { - val style = androidx.media.app.NotificationCompat.MediaStyle() - .setMediaSession(mediaSession.sessionToken) - .setShowActionsInCompactView(0, 2, 3) - .setShowCancelButton(true) - .setCancelButtonIntent(mStopAction.actionIntent) - val controller = mediaSession.controller - val metadata = controller.metadata ?: return null - val description = metadata.description ?: return null - val repeatMode = controller.repeatMode - val shuffleMode = controller.shuffleMode - val isPlaying = mediaSession.isPlaying() - - return NotificationCompat.Builder(mContext, channelId) - .setStyle(style) - .setSmallIcon(smallIcon) - .setDeleteIntent(mStopAction.actionIntent) - .setContentIntent(controller.sessionActivity) - .setContentTitle(description.title) - .setContentText(description.subtitle) - .setSubText(description.description) - .setShowWhen(false) - .setAutoCancel(false) - .setOngoing(false) - .setOnlyAlertOnce(true) - .setCategory(NotificationCompat.CATEGORY_TRANSPORT) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setDeleteIntent(mStopAction.actionIntent) - .customActionBtn(PlayMode.of(repeatMode = repeatMode, shuffleMode = shuffleMode)) - .addAction(mPrevAction) - .addAction(if (isPlaying) mPauseAction else mPlayAction) - .addAction(mNextAction) - .addAction(mStopAction) - } - - protected val notificationManager: NotificationManager by lazy { - ContextCompat.getSystemService( - mContext, NotificationManager::class.java - ) as NotificationManager - } - - private val mPlayAction: NotificationCompat.Action = NotificationCompat.Action( - R.drawable.ic_play_line, "play", - MediaButtonReceiver.buildMediaButtonPendingIntent( - mContext, PlaybackStateCompat.ACTION_PLAY - ) - ) - private val mPauseAction: NotificationCompat.Action = NotificationCompat.Action( - R.drawable.ic_pause_line, "pause", - MediaButtonReceiver.buildMediaButtonPendingIntent( - mContext, PlaybackStateCompat.ACTION_PAUSE - ) - ) - private val mNextAction: NotificationCompat.Action = NotificationCompat.Action( - R.drawable.ic_skip_next_line, "next", - MediaButtonReceiver.buildMediaButtonPendingIntent( - mContext, PlaybackStateCompat.ACTION_SKIP_TO_NEXT - ) - ) - private val mPrevAction: NotificationCompat.Action = NotificationCompat.Action( - R.drawable.ic_skip_previous_line, "previous", - MediaButtonReceiver.buildMediaButtonPendingIntent( - mContext, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - ) - ) - private val mStopAction: NotificationCompat.Action = NotificationCompat.Action( - R.drawable.ic_close_line, "stop", - MediaButtonReceiver.buildMediaButtonPendingIntent( - mContext, PlaybackStateCompat.ACTION_STOP - ) - ) - - fun buildServicePendingIntent( - context: Context, - requestCode: Int, - intent: Intent - ): PendingIntent { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - PendingIntent.getForegroundService( - context, requestCode, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - else PendingIntent.getService( - context, requestCode, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - protected fun createNotificationChannel(channelID: String, channelName: String) { - val channel = NotificationChannel( - channelID, - channelName, - NotificationManager.IMPORTANCE_LOW - ).apply { - description = channelName - importance = NotificationManager.IMPORTANCE_LOW - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - setShowBadge(false) - enableLights(false) - enableVibration(false) - } - notificationManager.createNotificationChannel(channel) - } - - fun Drawable.toBitmap(): Bitmap { - val w = this.intrinsicWidth - val h = this.intrinsicHeight - - val config = Bitmap.Config.ARGB_8888 - val bitmap = Bitmap.createBitmap(w, h, config) - val canvas = Canvas(bitmap) - this.setBounds(0, 0, w, h) - this.draw(canvas) - return bitmap - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/notification/Notifier.kt b/lplayer/src/main/java/com/lalilu/lplayer/notification/Notifier.kt deleted file mode 100644 index 1be60c7f9..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/notification/Notifier.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.lalilu.lplayer.notification - -import android.app.Notification -import android.support.v4.media.session.MediaSessionCompat - -interface Notifier { - fun startForeground(mediaSession: MediaSessionCompat, callback: (Int, Notification) -> Unit) - fun stopForeground(callback: () -> Unit) - fun update(mediaSession: MediaSessionCompat) - fun cancel() -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt deleted file mode 100644 index 22637a269..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt +++ /dev/null @@ -1,211 +0,0 @@ -package com.lalilu.lplayer.service - -import android.app.Notification -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ServiceInfo -import android.media.AudioManager -import android.os.Build -import android.os.Bundle -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.media.MediaBrowserServiceCompat -import androidx.media.session.MediaButtonReceiver -import com.danikula.videocache.HttpProxyCacheServer -import com.lalilu.common.base.Playable -import com.lalilu.lplayer.LPlayer -import com.lalilu.lplayer.extensions.AudioFocusHelper -import com.lalilu.lplayer.notification.Notifier -import com.lalilu.lplayer.playback.PlayMode -import com.lalilu.lplayer.playback.Playback -import com.lalilu.lplayer.playback.impl.LocalPlayer -import com.lalilu.lplayer.runtime.Runtime -import org.koin.android.ext.android.inject - -@Suppress("DEPRECATION") -abstract class LService : MediaBrowserServiceCompat(), LifecycleOwner, Playback.Listener { - override val lifecycle: Lifecycle get() = registry - private val registry by lazy { LifecycleRegistry(this) } - - abstract fun getStartIntent(): Intent - - private val sessionActivityPendingIntent by lazy { - packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> - PendingIntent.getActivity( - this, 0, sessionIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - } - } - - private lateinit var mediaSession: MediaSessionCompat - protected lateinit var playback: Playback - - private val runtime: Runtime = LPlayer.runtime - private val proxy: HttpProxyCacheServer by inject() - private val notifier: Notifier by inject() - private val localPlayer: LocalPlayer by inject() - private val audioFocusHelper: AudioFocusHelper by inject() - private val noisyReceiverIntentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - private val noisyReceiver: BroadcastReceiver by lazy { - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - playback.onPause() - } - } - } - - override fun onCreate() { - super.onCreate() - registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - if (!this::playback.isInitialized) { - runtime.info.getPosition = localPlayer::getPosition - runtime.info.getDuration = localPlayer::getDuration - runtime.info.getBufferedPosition = localPlayer::getBufferedPosition - localPlayer.handleNetUrl = { proxy.getProxyUrl(it) } - playback = LPlayer.playback.apply { - audioFocusHelper = this@LService.audioFocusHelper - playbackListener = this@LService - queue = runtime.queue - player = localPlayer - } - } - - if (!this::mediaSession.isInitialized) { - mediaSession = MediaSessionCompat(this, "LService") - .apply { - setSessionActivity(sessionActivityPendingIntent) - setCallback(playback) - isActive = true - } - } - - sessionToken = mediaSession.sessionToken - registry.handleLifecycleEvent(Lifecycle.Event.ON_START) - } - - override fun onPlayInfoUpdate(item: Playable?, playbackState: Int, position: Long) { - val isPlaying = playback.player?.isPlaying ?: false - - runtime.queue.setCurrentId(id = item?.mediaId) - runtime.info.updateIsPlaying(isPlaying) - runtime.info.updatePosition(startPosition = position, isPlaying = isPlaying) - - mediaSession.setMetadata(item?.provideMediaData()) - mediaSession.setPlaybackState( - PlaybackStateCompat.Builder() - .setActions(LPlayer.MEDIA_DEFAULT_ACTION) - .setState(playbackState, position, 1f) - .build() - ) - - when (playbackState) { - PlaybackStateCompat.STATE_PLAYING -> { - registerReceiver(noisyReceiver, noisyReceiverIntentFilter) - mediaSession.isActive = true - startService() - notifier.startForeground(mediaSession) { id, notification -> - startForeGround(id, notification) - } - } - - PlaybackStateCompat.STATE_PAUSED -> { - kotlin.runCatching { - unregisterReceiver(noisyReceiver) - } - // mediaSession.isActive = false - // stopForeground() - } - - PlaybackStateCompat.STATE_STOPPED -> { - kotlin.runCatching { - unregisterReceiver(noisyReceiver) - } - mediaSession.isActive = false - stopSelf() - notifier.stopForeground { - notifier.cancel() - stopForeground() - } - return - } - } - notifier.update(mediaSession) - } - - override fun onSetPlayMode(playMode: PlayMode) { - mediaSession.setRepeatMode(playMode.repeatMode) - mediaSession.setShuffleMode(playMode.shuffleMode) - notifier.update(mediaSession) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - MediaButtonReceiver.handleIntent(mediaSession, intent) - return START_STICKY - } - - override fun onGetRoot( - clientPackageName: String, - clientUid: Int, - rootHints: Bundle?, - ): BrowserRoot { - return BrowserRoot("MAIN", null) - } - - override fun onLoadChildren( - parentId: String, - result: Result>, - ) { -// val description = MediaDescriptionCompat.Builder() -// .setTitle("") -// .build() -// val mediaItem = MediaBrowserCompat.MediaItem( -// description, -// MediaBrowserCompat.MediaItem.FLAG_BROWSABLE or MediaBrowserCompat.MediaItem.FLAG_PLAYABLE -// ) -// result.sendResult(mutableListOf(mediaItem)) - result.sendResult(mutableListOf()) - } - - override fun onDestroy() { - runtime.info.updatePosition(startPosition = 0, isPlaying = false) - registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - playback.destroy() - localPlayer.destroy() - super.onDestroy() - } - - private fun startService() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(getStartIntent()) - } else { - startService(getStartIntent()) - } - } - - private fun startForeGround(id: Int, notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - id, notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - ) - } else { - startForeground(id, notification) - } - } - - private fun stopForeground() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - this.stopForeground(STOP_FOREGROUND_DETACH) - } else { - this.stopForeground(false) - } - } -} \ No newline at end of file From e475f2a941daddda9e9ce0df396b0e07d21b2e00 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 7 Oct 2024 13:46:23 +0800 Subject: [PATCH 096/213] =?UTF-8?q?[refactor]=E6=9B=B4=E6=96=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E7=89=88=E6=9C=AC=EF=BC=8C=E5=BC=95=E5=85=A5compose?= =?UTF-8?q?=20navigation=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 + .../ui/ComposeNestedScrollRecyclerView.kt | 56 ------------------- component/build.gradle.kts | 4 ++ .../base/songs/SongsSortPanelDialog.kt | 7 ++- .../component/extension/ItemRecorder.kt | 4 +- gradle/libs.versions.toml | 9 +-- 6 files changed, 17 insertions(+), 66 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/ui/ComposeNestedScrollRecyclerView.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd63aaaa6..a0fbf65a5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ plugins { id("com.android.application") kotlin("android") alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) id("com.google.devtools.ksp") id("android.aop") } @@ -177,6 +178,8 @@ dependencies { implementation(libs.room.ktx) implementation(libs.room.runtime) + implementation(libs.kotlin.serialization) + implementation(libs.kotlinx.serialization.json) ksp(libs.room.compiler) // https://github.com/Block-Network/StatusBarApiExample diff --git a/app/src/main/java/com/lalilu/lmusic/ui/ComposeNestedScrollRecyclerView.kt b/app/src/main/java/com/lalilu/lmusic/ui/ComposeNestedScrollRecyclerView.kt deleted file mode 100644 index e028474c1..000000000 --- a/app/src/main/java/com/lalilu/lmusic/ui/ComposeNestedScrollRecyclerView.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.lalilu.lmusic.ui - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import androidx.recyclerview.widget.RecyclerView -import kotlin.math.abs - -/** - * 重写onTouchEvent,修改其计算dy的逻辑,解决RecyclerView嵌入Compose结合NestedScroll时, - * RecyclerView内MotionEvent的getY获取到的值出现异常波动的问题 - */ -class ComposeNestedScrollRecyclerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, -) : RecyclerView(context, attrs) { - private val position = intArrayOf(0, 0) - private var downX = 0 - private var downY = 0 - private var verticalDrag: Boolean? = null - - override fun onInterceptTouchEvent(e: MotionEvent): Boolean { - if (e.actionMasked == MotionEvent.ACTION_DOWN) { - downX = (e.x + 0.5f).toInt() - downY = (e.y + 0.5f).toInt() - getLocationOnScreen(position) - verticalDrag = null - } - - if (e.actionMasked == MotionEvent.ACTION_MOVE) { - val result = super.onInterceptTouchEvent(e) - val currentX = (e.x + 0.5f).toInt() - val currentY = (e.y + 0.5f).toInt() - - // 当开始拖拽时计算其滑动方向并记录 - if (result) { - verticalDrag = abs(currentY - downY) > abs(currentX - downX) - } - return result - } - return super.onInterceptTouchEvent(e) - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(e: MotionEvent): Boolean { - if (verticalDrag == null && e.actionMasked == MotionEvent.ACTION_DOWN) { - verticalDrag = true - } - - if (verticalDrag == true && e.actionMasked == MotionEvent.ACTION_MOVE) { - e.setLocation(e.x, e.rawY - position[1]) - } - return super.onTouchEvent(e) - } -} \ No newline at end of file diff --git a/component/build.gradle.kts b/component/build.gradle.kts index aeaa78225..7b277dc99 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -61,6 +61,10 @@ dependencies { api("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") api("com.github.GIGAMOLE:ComposeFadingEdges:1.0.4") + // https://mvnrepository.com/artifact/org.jetbrains.androidx.navigation/navigation-compose + api("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10") + api("androidx.compose.material3:material3-adaptive-navigation-suite") + // compose // api(platform(libs.compose.bom)) api(platform(libs.compose.bom.alpha)) diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt index db1ce7706..1b9a7ed93 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt @@ -19,8 +19,6 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.SelectableChipColors import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember @@ -35,10 +33,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.cheonjaeung.compose.grid.SimpleGridCells import com.cheonjaeung.compose.grid.VerticalGrid +import com.lalilu.RemixIcon import com.lalilu.component.extension.DialogItem import com.lalilu.component.extension.DialogWrapper import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.closeLine @Composable @@ -119,7 +120,7 @@ private fun SongsSortPanelDialogContent( IconButton(onClick = { onDismiss() }) { Icon( - imageVector = Icons.Default.Close, + imageVector = RemixIcon.System.closeLine, contentDescription = null ) } diff --git a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt index 6a9d10694..e57b42d6f 100644 --- a/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt +++ b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt @@ -1,6 +1,5 @@ package com.lalilu.component.extension -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items @@ -8,7 +7,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf -@OptIn(ExperimentalFoundationApi::class) class LazyListRecordScope internal constructor( var recorder: ItemRecorder, ) { @@ -18,7 +16,7 @@ class LazyListRecordScope internal constructor( fun stickyHeaderWithRecord( key: Any? = null, contentType: Any? = null, - content: @Composable LazyItemScope.() -> Unit + content: @Composable LazyItemScope.(Int) -> Unit ) { lazyListScope?.let { scope -> recorder.record(key) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cdbbf9c2..5fff1b2bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -compile_version = "34" +compile_version = "35" min_sdk_version = "21" agp_version = "8.5.0" @@ -10,7 +10,7 @@ ksp_version = "2.0.0-1.0.22" koin_version = "3.5.6" koin_ksp_version = "1.3.1" -compose_bom_alpha_version = "2024.08.00-alpha01" +compose_bom_alpha_version = "2024.09.03" compose_bom_version = "2024.06.00" accompanist_version = "0.32.0" voyager = "1.1.0-beta02" @@ -45,10 +45,10 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.re kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines_version" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines_version" } -#kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json_version" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.3" } # compose -compose-bom-alpha = { module = "dev.chrisbanes.compose:compose-bom", version.ref = "compose_bom_alpha_version" } +compose-bom-alpha = { module = "androidx.compose:compose-bom-alpha", version.ref = "compose_bom_alpha_version" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom_version" } compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-util = { module = "androidx.compose.ui:ui-util" } @@ -125,6 +125,7 @@ krouter-plugin = { module = "com.github.cy745.KRouter:plugin", version.ref = "kr application = { id = "com.android.application", version.ref = "agp_version" } library = { id = "com.android.library", version.ref = "agp_version" } kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin_version" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin_version" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp_version" } flyjingfish-aop = { id = "io.github.FlyJingFish.AndroidAop.android-aop", version.ref = "flyjingfish-aop" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin_version" } From 00b2165cb09c4b5bd47818bbc4a6655fbed20b4e Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 20 Oct 2024 20:57:07 +0800 Subject: [PATCH 097/213] =?UTF-8?q?[refactor]=E5=8E=BB=E9=99=A4Playable?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=8E=BB=E9=99=A4?= =?UTF-8?q?=E5=8E=9F=E6=9C=89LPlayer=E4=B8=BAMPlayer=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 7 + .../main/java/com/lalilu/lmusic/AppModule.kt | 44 ++- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 46 +-- .../java/com/lalilu/lmusic/MainActivity.kt | 23 +- .../new_screen/detail/SongDetailContent.kt | 5 +- .../new_screen/detail/SongDetailScreen.kt | 6 +- .../presenter/DetailScreenPresenter.kt | 35 -- .../compose/screen/detail/SongPlayAction.kt | 4 +- .../compose/screen/playing/LyricLayout.kt | 4 +- .../compose/screen/playing/LyricSentence.kt | 3 +- .../compose/screen/playing/PlayingLayout.kt | 8 +- .../screen/playing/PlayingLayoutExpended.kt | 21 +- .../screen/playing/PlayingSmartCard.kt | 11 +- .../compose/screen/playing/PlaylistLayout.kt | 4 - .../compose/screen/playing/SeekbarLayout.kt | 17 +- .../screen/songs/SongsScreenContent.kt | 12 +- .../lalilu/lmusic/extension/DailyRecommend.kt | 2 +- .../lalilu/lmusic/extension/HistoryPanel.kt | 13 +- .../lalilu/lmusic/extension/LatestPanel.kt | 7 +- .../com/lalilu/lmusic/extension/SleepTimer.kt | 3 +- .../lalilu/lmusic/utils/MaxFreshRateUtils.kt | 24 ++ .../lmusic/utils/coil/keyer/SongCoverKeyer.kt | 7 - .../lmusic/viewmodel/PlayingViewModel.kt | 28 +- .../java/com/lalilu/common/base/Playable.kt | 30 -- .../lalilu/component/base/songs/SongsSM.kt | 11 +- .../com/lalilu/component/card/SongCard.kt | 14 +- .../component/viewmodel/PlayingViewModel.kt | 7 - gradle/libs.versions.toml | 12 +- .../com/lalilu/lalbum/screen/AlbumsScreen.kt | 11 +- .../screen/ArtistDetailScreenContent.kt | 24 +- .../screen/artists/ArtistsScreenContent.kt | 12 +- .../lartist/viewModel/ArtistDetailSM.kt | 7 +- .../com/lalilu/lartist/viewModel/ArtistsSM.kt | 4 +- lmedia | 2 +- .../main/java/com/lalilu/lplayer/LPlayer.kt | 42 -- .../main/java/com/lalilu/lplayer/MPlayer.kt | 5 + .../lplayer/extensions/AudioFocusHelper.kt | 83 ---- .../lalilu/lplayer/extensions/Extensions.kt | 140 ------- .../{playback => extensions}/PlayMode.kt | 2 +- .../lalilu/lplayer/extensions/QueueAction.kt | 3 - .../com/lalilu/lplayer/playback/PlayQueue.kt | 181 --------- .../com/lalilu/lplayer/playback/Playback.kt | 31 -- .../com/lalilu/lplayer/playback/Player.kt | 52 --- .../lplayer/playback/impl/LMediaPlayer.kt | 45 --- .../lplayer/playback/impl/LocalPlayer.kt | 298 -------------- .../lplayer/playback/impl/MixPlayback.kt | 372 ------------------ .../com/lalilu/lplayer/runtime/Runtime.kt | 63 --- .../com/lalilu/lplayer/service/LController.kt | 61 --- .../com/lalilu/lplayer/service/LRuntime.kt | 52 --- .../lplayer/service/MNotificationProvider.kt | 6 +- .../com/lalilu/lplayer/service/MService.kt | 2 +- .../com/lalilu/lplaylist/PlaylistActions.kt | 10 +- .../screen/detail/PlaylistDetailScreen.kt | 6 +- 53 files changed, 196 insertions(+), 1726 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/utils/MaxFreshRateUtils.kt delete mode 100644 common/src/main/java/com/lalilu/common/base/Playable.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/LPlayer.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/extensions/AudioFocusHelper.kt rename lplayer/src/main/java/com/lalilu/lplayer/{playback => extensions}/PlayMode.kt (97%) delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/playback/PlayQueue.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/playback/Playback.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/playback/Player.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/playback/impl/LMediaPlayer.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/playback/impl/LocalPlayer.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/playback/impl/MixPlayback.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/runtime/Runtime.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/service/LController.kt delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/service/LRuntime.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ec39d89c8..92e6a8ad7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,6 +47,7 @@ android:configChanges="orientation|screenSize" android:exported="true" android:launchMode="singleTop" + android:resizeableActivity="true" android:windowSoftInputMode="adjustNothing"> @@ -76,5 +77,11 @@ android:launchMode="singleTop"> + + \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index 66d5d6296..757172d12 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -1,10 +1,12 @@ package com.lalilu.lmusic import StatusBarLyric.API.StatusBarLyric +import android.app.Application import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.ViewModelStoreOwner import coil3.ImageLoader +import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.transitionFactory import coil3.util.DebugLogger @@ -28,7 +30,6 @@ import com.lalilu.lmusic.utils.coil.fetcher.LAlbumFetcher import com.lalilu.lmusic.utils.coil.fetcher.LSongFetcher import com.lalilu.lmusic.utils.coil.fetcher.MediaItemFetcher import com.lalilu.lmusic.utils.coil.keyer.MediaItemKeyer -import com.lalilu.lmusic.utils.coil.keyer.PlayableKeyer import com.lalilu.lmusic.utils.coil.keyer.SongCoverKeyer import com.lalilu.lmusic.utils.coil.mapper.LSongMapper import com.lalilu.lmusic.utils.extension.toBitmap @@ -44,6 +45,7 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +import org.koin.core.annotation.Single import org.koin.core.module.dsl.singleOf import org.koin.dsl.module import retrofit2.Retrofit @@ -54,6 +56,28 @@ import java.net.URLDecoder @ComponentScan("com.lalilu.lmusic") object MainModule +@Single(createdAtStart = true) +fun provideImageLoaderFactory( + context: Application, + client: OkHttpClient, +): SingletonImageLoader.Factory { + return SingletonImageLoader.Factory { + ImageLoader.Builder(context) + .components { + add(OkHttpNetworkFetcherFactory(client)) + add(SongCoverKeyer()) + add(LSongMapper()) + add(MediaItemKeyer()) + add(MediaItemFetcher.MediaItemFetcherFactory()) + add(LSongFetcher.SongFactory()) + add(LAlbumFetcher.AlbumFactory()) + } + .transitionFactory(CrossfadeTransitionFactory()) + .logger(DebugLogger()) + .build() + } +} + val AppModule = module { single { androidApplication() as ViewModelStoreOwner } single { @@ -80,22 +104,6 @@ val AppModule = module { false ) } - single { - ImageLoader.Builder(androidApplication()) - .components { - add(OkHttpNetworkFetcherFactory(get())) - add(SongCoverKeyer()) - add(PlayableKeyer()) - add(LSongMapper()) - add(MediaItemKeyer()) - add(MediaItemFetcher.MediaItemFetcherFactory()) - add(LSongFetcher.SongFactory()) - add(LAlbumFetcher.AlbumFactory()) - } - .transitionFactory(CrossfadeTransitionFactory()) - .logger(DebugLogger()) - .build() - } } val ViewModelModule = module { @@ -136,7 +144,7 @@ val FilterModule = module { ) val durationFilter = Filter( flow = settingSp.durationFilter.flow(true), - getter = LSong::durationMs::get, + getter = { it.metadata.duration }, targetClass = LSong::class.java, ignoreRule = { flowValue, getterValue -> getterValue <= (flowValue ?: 15) diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index f14c8eac2..4665afaf3 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -3,8 +3,6 @@ package com.lalilu.lmusic import android.app.Application import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner -import coil3.ImageLoader -import coil3.PlatformContext import coil3.SingletonImageLoader import com.blankj.utilcode.util.LogUtils import com.lalilu.component.ComponentModule @@ -16,42 +14,29 @@ import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.indexer.FilterGroup import com.lalilu.lmedia.indexer.FilterProvider import com.lalilu.lmusic.utils.extension.ignoreSSLVerification -import com.lalilu.lplayer.LPlayer import com.lalilu.lplaylist.PlaylistModule import com.lalilu.lplaylist.PlaylistModule2 import com.zhangke.krouter.KRouter import com.zhangke.krouter.generated.KRouterInjectMap import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext -import org.koin.core.context.startKoin +import org.koin.androix.startup.KoinStartup.onKoinStartup +import org.koin.java.KoinJavaComponent import org.koin.ksp.generated.module import java.io.File -class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, ViewModelStoreOwner { + +@Suppress("OPT_IN_USAGE") +class LMusicApp : Application(), FilterProvider, ViewModelStoreOwner { override val viewModelStore: ViewModelStore = ViewModelStore() - private val imageLoader: ImageLoader by inject() private val filterGroup: FilterGroup by inject() - override fun newImageLoader(context: PlatformContext): ImageLoader = imageLoader override fun newFilterGroup(): FilterGroup = filterGroup - override fun onCreate() { - super.onCreate() - + init { KRouter.init(KRouterInjectMap::getMap) - SingletonImageLoader - .setSafe(this) - - LogUtils.getConfig() - .setLog2FileSwitch(true) - .setFileExtension(".log") - .setSaveDays(7) - .setStackDeep(3) - .setDir(File("${cacheDir}/log")) - - ignoreSSLVerification() - startKoin { + onKoinStartup { androidContext(this@LMusicApp) modules( MainModule.module, @@ -67,9 +52,24 @@ class LMusicApp : Application(), SingletonImageLoader.Factory, FilterProvider, V ArtistModule.module, AlbumModule, DictionaryModule, - LPlayer.module, LMedia.module ) } } + + override fun onCreate() { + super.onCreate() + + SingletonImageLoader + .setSafe(KoinJavaComponent.get(SingletonImageLoader.Factory::class.java)) + + LogUtils.getConfig() + .setLog2FileSwitch(true) + .setFileExtension(".log") + .setSaveDays(7) + .setStackDeep(3) + .setDir(File("${cacheDir}/log")) + + ignoreSSLVerification() + } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/MainActivity.kt b/app/src/main/java/com/lalilu/lmusic/MainActivity.kt index 6426e8835..4d871eb32 100644 --- a/app/src/main/java/com/lalilu/lmusic/MainActivity.kt +++ b/app/src/main/java/com/lalilu/lmusic/MainActivity.kt @@ -2,10 +2,8 @@ package com.lalilu.lmusic import android.content.pm.PackageManager import android.media.AudioManager -import android.os.Build import android.os.Bundle import android.view.MotionEvent -import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.addCallback import androidx.activity.compose.setContent @@ -14,16 +12,15 @@ import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import com.blankj.utilcode.util.ActivityUtils import com.lalilu.common.SystemUiUtil import com.lalilu.component.extension.collectWithLifeCycleOwner -import com.lalilu.lmedia.LMedia import com.lalilu.lmusic.Config.REQUIRE_PERMISSIONS import com.lalilu.lmusic.compose.App import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.helper.LastTouchTimeHelper import com.lalilu.lmusic.utils.dynamicUpdateStatusBarColor +import com.lalilu.lmusic.utils.setToMaxFreshRate import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { @@ -41,21 +38,6 @@ class MainActivity : ComponentActivity() { return } - // 优先最高帧率运行 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val params: WindowManager.LayoutParams = window.attributes - val supportedMode = ContextCompat - .getDisplayOrDefault(this) - .supportedModes - .maxBy { it.refreshRate } - - supportedMode?.let { - params.preferredRefreshRate = it.refreshRate - params.preferredDisplayModeId = it.modeId - window.attributes = params - } - } - // 深色模式控制 settingsSp.darkModeOption.flow(true) .collectWithLifeCycleOwner(this) { @@ -68,8 +50,6 @@ class MainActivity : ComponentActivity() { ) } - LMedia.initialize(this) - // 注册返回键事件回调 onBackPressedDispatcher.addCallback { this@MainActivity.moveTaskToBack(false) } @@ -77,6 +57,7 @@ class MainActivity : ComponentActivity() { SystemUiUtil.immersiveCutout(window) setContent { App.Content(activity = this) } + setToMaxFreshRate() dynamicUpdateStatusBarColor() volumeControlStream = AudioManager.STREAM_MUSIC diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt index ca385adc2..1ad3bf2bb 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt @@ -42,7 +42,8 @@ import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.entity.Metadata private val lSong = LSong( - id = "inceptos", name = "Kim Serrano", metadata = Metadata( + id = "inceptos", + metadata = Metadata( title = "maluisset", album = "honestatis", artist = "persius", @@ -206,7 +207,7 @@ fun SongDetailContent( Text( modifier = Modifier.layoutId("subTitle"), - text = song.subTitle, + text = song.name, color = MaterialTheme.colors.onBackground.copy(0.6f), fontWeight = FontWeight.Medium, fontSize = 12.sp, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index 0412859a5..d25ce2093 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -55,11 +55,11 @@ data class SongDetailScreen( onAction = { val song = LMedia.get(id = mediaId) ?: return@Static - QueueAction.AddToNext(song.mediaId).action() + QueueAction.AddToNext(song.id).action() DynamicTipsItem.Static( - title = song.title, + title = song.metadata.title, subTitle = "下一首播放", - imageData = song.imageSource + imageData = song ).show() } ), diff --git a/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt b/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt index eec683336..4c04159a7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt @@ -2,16 +2,11 @@ package com.lalilu.lmusic.compose.presenter import android.annotation.SuppressLint import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import com.lalilu.common.base.Playable import com.lalilu.component.base.UiAction import com.lalilu.component.base.UiState -import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplaylist.repository.PlaylistRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -28,11 +23,6 @@ data class DetailScreenLikeBtnState( val onAction: (action: UiAction) -> Unit ) : UiState -data class DetailScreenIsPlayingState( - val isPlaying: Boolean, - val onAction: (action: UiAction) -> Unit -) : UiState - @SuppressLint("ComposableNaming") @Composable fun DetailScreenLikeBtnPresenter( @@ -53,29 +43,4 @@ fun DetailScreenLikeBtnPresenter( } } } -} - -@SuppressLint("ComposableNaming") -@Composable -fun DetailScreenIsPlayingPresenter( - mediaId: String, - playingVM: PlayingViewModel = koinInject() -): DetailScreenIsPlayingState { - val isPlaying = remember { - derivedStateOf { playingVM.isItemPlaying(mediaId, Playable::mediaId) } - } - - SideEffect { - println("isPlaying: ${isPlaying}") - } - - return DetailScreenIsPlayingState(isPlaying = isPlaying.value) { - when (it) { - DetailScreenAction.PlayPause -> playingVM.play( - mediaId = mediaId, - addToNext = true, - playOrPause = true - ) - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt index da5bebc93..44e1feb13 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt @@ -23,10 +23,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.R import com.lalilu.RemixIcon -import com.lalilu.common.base.Playable import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.lalilu.lplayer.MPlayer import com.lalilu.remixicon.Media import com.lalilu.remixicon.media.pauseLine import com.lalilu.remixicon.media.playLine @@ -55,7 +55,7 @@ fun provideSongPlayAction(mediaId: String): ScreenAction.Dynamic { AnimatedContent( modifier = Modifier .fillMaxHeight(), - targetState = playingVM.isItemPlaying(mediaId, Playable::mediaId), + targetState = MPlayer.isItemPlaying(mediaId), transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "" ) { isPlaying -> diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt index 4b021cf33..c4b2d0303 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt @@ -41,8 +41,8 @@ import androidx.compose.ui.unit.sp import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord -import com.lalilu.lmedia2.lyric.LyricItem -import com.lalilu.lmedia2.lyric.LyricUtils +import com.lalilu.lmedia.lyric.LyricItem +import com.lalilu.lmedia.lyric.LyricUtils import com.lalilu.lmusic.utils.extension.edgeTransparent import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt index a70d1dc93..ca25a6a27 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt @@ -33,10 +33,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.lalilu.lmedia2.lyric.LyricItem +import com.lalilu.lmedia.lyric.LyricItem -@OptIn(ExperimentalFoundationApi::class) @Composable fun LyricSentence( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 2c32354c0..389e92645 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -43,9 +43,9 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.lalilu.component.base.LocalEnhanceSheetState import com.lalilu.component.extension.hideControl import com.lalilu.component.extension.singleViewModel -import com.lalilu.lmedia2.lyric.LyricItem -import com.lalilu.lmedia2.lyric.LyricSourceEmbedded -import com.lalilu.lmedia2.lyric.LyricUtils +import com.lalilu.lmedia.lyric.LyricItem +import com.lalilu.lmedia.lyric.LyricSourceEmbedded +import com.lalilu.lmedia.lyric.LyricUtils import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar import com.lalilu.lmusic.datastore.SettingsSp @@ -125,7 +125,7 @@ fun PlayingLayout( .padding(bottom = 10.dp) ) { PlayingToolbar( - isItemPlaying = { mediaId -> playingVM.isItemPlaying { it.mediaId == mediaId } }, + isItemPlaying = { mediaId -> MPlayer.isItemPlaying(mediaId) }, isUserTouchEnable = { draggable.state.value == DragAnchor.Min || draggable.state.value == DragAnchor.Max }, isExtraVisible = { draggable.state.value == DragAnchor.Max }, onClick = { scrollToTopEvent.value = System.currentTimeMillis() }, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt index 2ee8cab1e..d7c45c97e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt @@ -19,8 +19,7 @@ import androidx.compose.material.IconToggleButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,25 +30,25 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.media3.common.MediaItem import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import coil3.request.transformations import com.lalilu.R -import com.lalilu.common.base.Playable import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.utils.coil.BlurTransformation import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction -import com.lalilu.lplayer.playback.PlayMode +import com.lalilu.lplayer.extensions.PlayMode @Composable fun PlayingLayoutExpended( modifier: Modifier = Modifier, playingVM: PlayingViewModel = singleViewModel(), ) { - val currentPlaying by playingVM.playing + val currentPlaying = MPlayer.currentMediaItem val context = LocalContext.current val data = remember(currentPlaying) { ImageRequest.Builder(context) @@ -121,7 +120,7 @@ fun PlayingLayoutExpended( @Composable fun SongDetailPanel( - playable: Playable?, + playable: MediaItem?, ) { if (playable == null) { Text( @@ -136,17 +135,17 @@ fun SongDetailPanel( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = playable.title, + text = playable.mediaMetadata.title.toString(), color = Color.White, fontSize = 24.sp ) Text( - text = playable.subTitle, + text = playable.mediaMetadata.subtitle.toString(), color = Color.White, fontSize = 16.sp ) Text( - text = playable.subTitle, + text = playable.mediaMetadata.subtitle.toString(), color = Color.White, fontSize = 12.sp ) @@ -158,7 +157,7 @@ fun SongDetailPanel( fun ControlPanel( playingVM: PlayingViewModel = singleViewModel(), ) { - val isPlaying = LPlayer.runtime.info.isPlayingFlow.collectAsState(false) + val isPlaying = remember { derivedStateOf { MPlayer.isPlaying } } var playMode by playingVM.settingsSp.playMode Row( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt index 7cc65ccc1..7a59a1f91 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt @@ -16,7 +16,6 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -25,15 +24,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -import com.lalilu.component.extension.singleViewModel -import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.lalilu.lplayer.MPlayer @Composable fun PlayingSmartCard( modifier: Modifier = Modifier, - playingVM: PlayingViewModel = singleViewModel(), ) { - val currentPlaying by playingVM.playing + val currentPlaying = MPlayer.currentMediaItem Surface(modifier) { AnimatedContent( @@ -69,7 +66,7 @@ fun PlayingSmartCard( iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(30.dp) ), - text = playing?.title ?: "Unknown", + text = playing?.mediaMetadata?.title?.toString() ?: "Unknown", fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onBackground, fontSize = 14.sp, @@ -82,7 +79,7 @@ fun PlayingSmartCard( iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(30.dp) ), - text = playing?.subTitle ?: "Unknown", + text = playing?.mediaMetadata?.subtitle?.toString() ?: "Unknown", fontWeight = FontWeight.Medium, color = MaterialTheme.colors.onBackground.copy(0.6f), fontSize = 10.sp, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index ee2f6560a..62c8f76f9 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -1,7 +1,6 @@ package com.lalilu.lmusic.compose.screen.playing import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -37,7 +36,6 @@ import androidx.media3.common.MediaItem import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback import coil3.compose.AsyncImage -import com.lalilu.common.base.Playable import com.lalilu.component.navigation.AppRouter import com.lalilu.lplayer.extensions.PlayerAction @@ -145,7 +143,6 @@ fun PlaylistLayout( items( items = actualItems, key = { it.key }, - contentType = { Playable::class.java } ) { item -> MediaCard( modifier = Modifier.animateItem(), @@ -163,7 +160,6 @@ fun PlaylistLayout( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun MediaCard( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt index f59b66b6a..f70275b98 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt @@ -22,13 +22,11 @@ import androidx.lifecycle.lifecycleScope import com.lalilu.R import com.lalilu.common.HapticUtils import com.lalilu.component.extension.DynamicTipsItem -import com.lalilu.component.extension.collectWithLifeCycleOwner import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.utils.extension.durationToTime import com.lalilu.lmusic.utils.extension.getActivity -import com.lalilu.lplayer.LPlayer import com.lalilu.lplayer.extensions.PlayerAction -import com.lalilu.lplayer.playback.PlayMode +import com.lalilu.lplayer.extensions.PlayMode import com.lalilu.ui.CLICK_PART_LEFT import com.lalilu.ui.CLICK_PART_MIDDLE import com.lalilu.ui.CLICK_PART_RIGHT @@ -146,13 +144,12 @@ fun BoxScope.SeekbarLayout( }) } - LPlayer.runtime.info.durationFlow.collectWithLifeCycleOwner(activity) { - maxValue = it.takeIf { it > 0f }?.toFloat() ?: 0f - } - - LPlayer.runtime.info.positionFlow.collectWithLifeCycleOwner(activity) { - updateValue(it.toFloat()) - } +// LPlayer.runtime.info.durationFlow.collectWithLifeCycleOwner(activity) { +// maxValue = it.takeIf { it > 0f }?.toFloat() ?: 0f +// } +// LPlayer.runtime.info.positionFlow.collectWithLifeCycleOwner(activity) { +// updateValue(it.toFloat()) +// } snapshotFlow { animateColor.value } .onEach { thumbColor = it.toArgb() } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index 6e011e6e0..074e7a879 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -30,7 +30,6 @@ import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig import com.gigamole.composefadingedges.fill.FadingEdgesFillType import com.gigamole.composefadingedges.verticalFadingEdges -import com.lalilu.common.base.Playable import com.lalilu.component.base.smartBarPadding import com.lalilu.component.base.songs.SongsSM import com.lalilu.component.base.songs.SongsScreenEvent @@ -40,6 +39,7 @@ import com.lalilu.component.card.SongCard import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.flow.collectLatest @@ -48,8 +48,8 @@ import kotlinx.coroutines.flow.collectLatest internal fun SongsScreenContent( songsSM: SongsSM, isSelecting: () -> Boolean = { false }, - isSelected: (Playable) -> Boolean = { false }, - onSelect: (Playable) -> Unit = {}, + isSelected: (LSong) -> Boolean = { false }, + onSelect: (LSong) -> Unit = {}, onClickGroup: (GroupIdentity) -> Unit = {} ) { val density = LocalDensity.current @@ -157,7 +157,7 @@ internal fun SongsScreenContent( itemsWithRecord( items = list, - key = { it.mediaId }, + key = { it.id }, contentType = { it::class.java } ) { SongCard( @@ -167,7 +167,7 @@ internal fun SongsScreenContent( if (isSelecting()) { onSelect(it) } else { - PlayerAction.PlayById(it.mediaId).action() + PlayerAction.PlayById(it.id).action() } }, onLongClick = { @@ -177,7 +177,7 @@ internal fun SongsScreenContent( onSelect(it) } else { AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) + .with("mediaId", it.id) .jump() } }, diff --git a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index 6244657ea..aad5c2ece 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -47,7 +47,7 @@ object DailyRecommend : LazyGridContent { modifier = Modifier.padding(vertical = 8.dp), title = "每日推荐", onClick = { - val ids = libraryVM.dailyRecommends.value.map { it.mediaId } + val ids = libraryVM.dailyRecommends.value.map { it.id } AppRouter.intent(NavIntent.Push(SongsScreen(mediaIds = ids))) } ) { diff --git a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt index 62a004499..4d8761769 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt @@ -14,14 +14,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import com.lalilu.common.base.Playable import com.lalilu.component.LazyGridContent import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.card.SongCard import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.entity.LSong import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.HistoryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.lalilu.lplayer.MPlayer import org.koin.compose.koinInject object HistoryPanel : LazyGridContent { @@ -55,7 +56,7 @@ object HistoryPanel : LazyGridContent { items( items = items, - key = { it.mediaId }, + key = { it.id }, contentType = { "History_item" }, span = { if (widthSizeClass == WindowWidthSizeClass.Expanded) GridItemSpan(maxLineSpan / 2) @@ -67,11 +68,11 @@ object HistoryPanel : LazyGridContent { .animateItem() .padding(bottom = 5.dp), song = { item }, - isPlaying = { playingVM.isItemPlaying { it.mediaId == item.id } }, + isPlaying = { MPlayer.isItemPlaying(item.id) }, onClick = { playingVM.play( - mediaId = item.mediaId, - mediaIds = items.map(Playable::mediaId), + mediaId = item.id, + mediaIds = items.map(LSong::id), playOrPause = true ) }, @@ -79,7 +80,7 @@ object HistoryPanel : LazyGridContent { haptic.performHapticFeedback(HapticFeedbackType.LongPress) AppRouter.route("/pages/songs/detail") - .with("mediaId", item.mediaId) + .with("mediaId", item.id) .jump() } ) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt index 2b3c9b163..bc57421dc 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt @@ -10,14 +10,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.lalilu.common.base.Playable import com.lalilu.component.LazyGridContent import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.entity.LSong import com.lalilu.lmusic.compose.component.card.RecommendCard import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.LibraryViewModel import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.lalilu.lplayer.MPlayer import org.koin.compose.koinInject @@ -68,10 +69,10 @@ object LatestPanel : LazyGridContent { modifier = Modifier.animateItem(), onClick = { AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) + .with("mediaId", it.id) .jump() }, - isPlaying = { playingVM.isItemPlaying(it.id, Playable::mediaId) }, + isPlaying = { MPlayer.isItemPlaying(it.id) }, onClickButton = { playingVM.play( mediaId = it.id, diff --git a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt index ef8af0c8e..cc0bedb33 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt @@ -54,7 +54,6 @@ import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.extension.enableFor import com.lalilu.component.settings.SettingSwitcher import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lplayer.LPlayer import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -155,7 +154,7 @@ fun SleepTimer( defaultSecondToCountDown = defaultSecondToCountDown, onActionBtnLongClick = { if (isRunning.value) { - LPlayer.controller.doAction(PlayerAction.PauseWhenCompletion(true)) + PlayerAction.PauseWhenCompletion(true).action() SleepTimerContext.stop() } else { val millisSecond = defaultSecondToCountDown.value.toLong() diff --git a/app/src/main/java/com/lalilu/lmusic/utils/MaxFreshRateUtils.kt b/app/src/main/java/com/lalilu/lmusic/utils/MaxFreshRateUtils.kt new file mode 100644 index 000000000..b34437600 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/utils/MaxFreshRateUtils.kt @@ -0,0 +1,24 @@ +package com.lalilu.lmusic.utils + +import android.os.Build +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.core.content.ContextCompat + + +fun ComponentActivity.setToMaxFreshRate() { + // 优先最高帧率运行 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val params: WindowManager.LayoutParams = window.attributes + val supportedMode = ContextCompat + .getDisplayOrDefault(this) + .supportedModes + .maxBy { it.refreshRate } + + supportedMode?.let { + params.preferredRefreshRate = it.refreshRate + params.preferredDisplayModeId = it.modeId + window.attributes = params + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt index 3e1f713e1..ff8a708f5 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt @@ -3,7 +3,6 @@ package com.lalilu.lmusic.utils.coil.keyer import androidx.media3.common.MediaItem import coil3.key.Keyer import coil3.request.Options -import com.lalilu.common.base.Playable import com.lalilu.lmedia.entity.Item class SongCoverKeyer : Keyer { @@ -12,12 +11,6 @@ class SongCoverKeyer : Keyer { } } -class PlayableKeyer : Keyer { - override fun key(data: Playable, options: Options): String { - return "${data::class.simpleName}_${data.mediaId}" - } -} - class MediaItemKeyer : Keyer { override fun key(data: MediaItem, options: Options): String { return data.mediaId diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt index 8ea9f196d..bbf94d03c 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt @@ -1,23 +1,16 @@ package com.lalilu.lmusic.viewmodel import androidx.lifecycle.viewModelScope -import com.lalilu.common.base.Playable -import com.lalilu.component.extension.toState import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction import com.lalilu.lplayer.extensions.QueueAction -import com.lalilu.lplaylist.repository.PlaylistRepository import kotlinx.coroutines.launch class PlayingViewModel( - val settingsSp: SettingsSp, - playlistRepo: PlaylistRepository + val settingsSp: SettingsSp ) : IPlayingViewModel() { - val playing = LPlayer.runtime.info.playingFlow.toState(viewModelScope) - val isPlaying = LPlayer.runtime.info.isPlayingFlow.toState(false, viewModelScope) - /** * 综合播放操作 * @@ -39,26 +32,11 @@ class PlayingViewModel( if (addToNext) { QueueAction.AddToNext(mediaId).action() } - if (mediaId == LPlayer.runtime.queue.getCurrentId() && playOrPause) { + if (mediaId == MPlayer.currentMediaItem?.mediaId && playOrPause) { PlayerAction.PlayOrPause.action() } else { PlayerAction.PlayById(mediaId).action() } } } - - override fun isItemPlaying(item: T, getter: (Playable) -> T): Boolean = - isItemPlaying { item == getter(it) } - - override fun isItemPlaying(compare: (Playable) -> Boolean): Boolean { - if (!isPlaying.value) return false - return playing.value?.let { compare(it) } ?: false - } - - private val isFavouriteList = playlistRepo.getFavouriteMediaIds() - .toState(viewModelScope) - - override fun isFavourite(item: Playable): Boolean { - return isFavouriteList.value?.contains(item.mediaId) ?: false - } } \ No newline at end of file diff --git a/common/src/main/java/com/lalilu/common/base/Playable.kt b/common/src/main/java/com/lalilu/common/base/Playable.kt deleted file mode 100644 index 8c28fcc44..000000000 --- a/common/src/main/java/com/lalilu/common/base/Playable.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.lalilu.common.base - -import android.net.Uri -import android.support.v4.media.MediaMetadataCompat - -/** - * 通用可播放元素 - * - * 为了实现播放功能和各种元素之间解耦合,定义了可播放元素接口, - * 实现该接口的元素,可以被播放器播放,并且可以被各种元素引用, - * 通过使用id进行区分,将该元素区分为不同的元素 - */ -interface Playable { - val mediaId: String - val title: String - val subTitle: String - val durationMs: Long - - val targetUri: Uri - val imageSource: Any? - val sticker: List - - fun provideMediaData(): MediaMetadataCompat = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId) - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subTitle) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs) - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, targetUri.toString()) - .build() -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt index 653080706..b3278efb8 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt @@ -9,21 +9,20 @@ import androidx.compose.runtime.snapshotFlow import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.lalilu.common.base.BaseSp -import com.lalilu.common.base.Playable import com.lalilu.common.ext.requestFor import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.ItemSelector import com.lalilu.component.extension.toState import com.lalilu.component.viewmodel.SongsSp import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.FullTextMatchable import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.Searchable import com.lalilu.lmedia.extension.SortDynamicAction import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lmedia.extension.Sortable -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -72,7 +71,7 @@ class SongsSM( fun action(action: SongsScreenAction) = screenModelScope.launch { when (action) { SongsScreenAction.LocaleToPlayingItem -> { - val mediaId = LPlayer.runtime.info.playingIdFlow.value + val mediaId = MPlayer.currentMediaItem?.mediaId ?: return@launch eventFlow.emit(SongsScreenEvent.ScrollToItem(mediaId)) @@ -106,11 +105,11 @@ class SongsSM( defaultValue = emptyMap(), scope = screenModelScope, ) - val selector = ItemSelector() + val selector = ItemSelector() val recorder = ItemRecorder() } -class ItemSearcher( +class ItemSearcher( sourceFlow: Flow> ) { val keywordState = mutableStateOf("") diff --git a/component/src/main/java/com/lalilu/component/card/SongCard.kt b/component/src/main/java/com/lalilu/component/card/SongCard.kt index 6035a71a7..92b30c978 100644 --- a/component/src/main/java/com/lalilu/component/card/SongCard.kt +++ b/component/src/main/java/com/lalilu/component/card/SongCard.kt @@ -50,20 +50,20 @@ import coil3.request.ImageRequest import coil3.request.crossfade import coil3.request.error import coil3.request.placeholder -import com.lalilu.common.base.Playable import com.lalilu.common.base.Sticker import com.lalilu.component.R import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.extension.dayNightTextColorFilter import com.lalilu.component.extension.durationMsToString import com.lalilu.component.extension.mimeTypeToIcon +import com.lalilu.lmedia.entity.LSong @Composable fun SongCard( modifier: Modifier = Modifier, dragModifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - song: () -> Playable, + song: () -> LSong, onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, @@ -81,10 +81,10 @@ fun SongCard( modifier = modifier, dragModifier = dragModifier, interactionSource = interactionSource, - title = { item.title }, - subTitle = { item.subTitle }, - duration = { item.durationMs }, - sticker = { item.sticker }, + title = { item.metadata.title }, + subTitle = { item.metadata.artist }, + duration = { item.metadata.duration }, + sticker = { emptyList() }, imageData = { item }, onClick = onClick, onLongClick = onLongClick, @@ -100,7 +100,6 @@ fun SongCard( } -@OptIn(ExperimentalFoundationApi::class) @Composable fun SongCard( modifier: Modifier = Modifier, @@ -253,7 +252,6 @@ fun SongCardContent( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun SongCardImage( modifier: Modifier = Modifier, diff --git a/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt b/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt index 46d010114..f001ecc88 100644 --- a/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt +++ b/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt @@ -1,8 +1,6 @@ package com.lalilu.component.viewmodel -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.ViewModel -import com.lalilu.common.base.Playable abstract class IPlayingViewModel : ViewModel() { /** @@ -19,9 +17,4 @@ abstract class IPlayingViewModel : ViewModel() { playOrPause: Boolean = false, addToNext: Boolean = false, ) - - abstract fun isItemPlaying(item: T, getter: (Playable) -> T): Boolean - abstract fun isItemPlaying(compare: (Playable) -> Boolean): Boolean - - abstract fun isFavourite(item: Playable): Boolean } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5fff1b2bb..13c31b6e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,12 +8,12 @@ coroutines_version = "1.8.1" ksp_version = "2.0.0-1.0.22" #serialization_json_version = "1.6.0" -koin_version = "3.5.6" +koin_version = "4.0.0" koin_ksp_version = "1.3.1" -compose_bom_alpha_version = "2024.09.03" -compose_bom_version = "2024.06.00" +compose_bom_alpha_version = "2024.10.00" +compose_bom_version = "2024.10.00" accompanist_version = "0.32.0" -voyager = "1.1.0-beta02" +voyager = "1.1.0-beta03" lottie-compose = "5.2.0" kotlinpoet = "1.14.2" @@ -25,7 +25,7 @@ appcompat = "1.7.0" core-ktx = "1.13.1" palette-ktx = "1.0.0" dynamicanimation-ktx = "1.0.0-alpha03" -startup-runtime = "1.2.0-alpha02" +startup-runtime = "1.2.0" constraintlayout = "2.1.4" coordinatorlayout = "1.2.0" gridlayout = "1.0.0" @@ -77,6 +77,7 @@ accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist- # koin koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin_version" } koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin_version" } +koin-startup = { module = "io.insert-koin:koin-androidx-startup", version.ref = "koin_version" } koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin_ksp_version" } koin-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin_ksp_version" } @@ -166,5 +167,6 @@ coil3 = [ koin = [ "koin-android", "koin-compose", + "koin-startup", "koin-annotations" ] \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index cd62c6e3d..29283f157 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -44,8 +44,7 @@ import com.lalilu.lalbum.R import com.lalilu.lalbum.component.AlbumCard import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LAlbum -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmedia.extension.Sortable +import com.lalilu.lplayer.MPlayer import com.lalilu.remixicon.Media import com.lalilu.remixicon.media.albumFill import com.zhangke.krouter.annotation.Destination @@ -139,13 +138,7 @@ private fun AlbumsScreen( ) { item -> AlbumCard( album = { item }, - isPlaying = { - playingVM.isItemPlaying { playing -> - playing.let { it as? LSong } - ?.let { it.album?.id == item.id } - ?: false - } - }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, showTitle = { albumsSM.showTitle.value }, onClick = { AppRouter.intent( diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt index 31a26395d..7b8d26d6a 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt @@ -29,7 +29,6 @@ import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig import com.gigamole.composefadingedges.fill.FadingEdgesFillType import com.gigamole.composefadingedges.verticalFadingEdges -import com.lalilu.common.base.Playable import com.lalilu.component.base.songs.SongsScreenStickyHeader import com.lalilu.component.card.SongCard import com.lalilu.component.extension.rememberLazyListAnimateScroller @@ -39,15 +38,17 @@ import com.lalilu.component.navigation.NavIntent import com.lalilu.lartist.component.ArtistCard import com.lalilu.lartist.viewModel.ArtistDetailSM import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction @Composable internal fun ArtistDetailScreenContent( artistDetailSM: ArtistDetailSM, isSelecting: () -> Boolean = { false }, - isSelected: (Playable) -> Boolean = { false }, - onSelect: (Playable) -> Unit = {}, + isSelected: (LSong) -> Boolean = { false }, + onSelect: (LSong) -> Unit = {}, onClickGroup: (GroupIdentity) -> Unit = {} ) { val listState = rememberLazyListState() @@ -137,7 +138,7 @@ internal fun ArtistDetailScreenContent( itemsWithRecord( items = list, - key = { it.mediaId }, + key = { it.id }, contentType = { it::class.java } ) { SongCard( @@ -147,7 +148,7 @@ internal fun ArtistDetailScreenContent( if (isSelecting()) { onSelect(it) } else { - PlayerAction.PlayById(it.mediaId).action() + PlayerAction.PlayById(it.id).action() } }, onLongClick = { @@ -157,7 +158,7 @@ internal fun ArtistDetailScreenContent( onSelect(it) } else { AppRouter.route("/pages/songs/detail") - .with("mediaId", it.mediaId) + .with("mediaId", it.id) .jump() } }, @@ -195,15 +196,8 @@ internal fun ArtistDetailScreenContent( title = item.name, subTitle = "#$index", songCount = item.songs.size.toLong(), - imageSource = { item.songs.firstOrNull()?.imageSource }, - isPlaying = { - false -// playingVM.isItemPlaying { playing -> -// playing.let { it as? LSong } -// ?.let { song -> song.artists.any { it.name == item.name } } -// ?: false -// } - }, + imageSource = { item.songs.firstOrNull() }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, onClick = { AppRouter.intent(NavIntent.Push(ArtistDetailScreen(item.id))) } ) } diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt index 653d7ca4f..cc31cd7a5 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt @@ -40,8 +40,8 @@ import com.lalilu.lartist.screen.ArtistDetailScreen import com.lalilu.lartist.viewModel.ArtistsSM import com.lalilu.lartist.viewModel.ArtistsScreenEvent import com.lalilu.lmedia.entity.LArtist -import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lplayer.MPlayer import kotlinx.coroutines.flow.collectLatest import org.koin.compose.koinInject @@ -172,14 +172,8 @@ internal fun ArtistsScreenContent( subTitle = "#$index", isSelected = { isSelected(item) }, songCount = item.songs.size.toLong(), - imageSource = { item.songs.firstOrNull()?.imageSource }, - isPlaying = { - playingVM.isItemPlaying { playing -> - playing.let { it as? LSong } - ?.let { song -> song.artists.any { it.name == item.name } } - ?: false - } - }, + imageSource = { item.songs.firstOrNull() }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, onClick = { if (isSelecting()) { onSelect(item) diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt index 59b6e085e..837abe021 100644 --- a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt @@ -3,7 +3,6 @@ package com.lalilu.lartist.viewModel import androidx.compose.runtime.mutableStateOf import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import com.lalilu.common.base.Playable import com.lalilu.component.base.songs.ItemSearcher import com.lalilu.component.base.songs.ItemSorter import com.lalilu.component.extension.ItemRecorder @@ -15,7 +14,7 @@ import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -62,7 +61,7 @@ internal class ArtistDetailSM( scope = screenModelScope, ) - val selector = ItemSelector() + val selector = ItemSelector() val recorder = ItemRecorder() private val _eventFlow = MutableSharedFlow() @@ -72,7 +71,7 @@ internal class ArtistDetailSM( when (action) { ArtistDetailScreenAction.LocaleToPlayingItem -> { // 获取正在播放的元素ID - val mediaId = LPlayer.runtime.info.playingIdFlow.value + val mediaId = MPlayer.currentMediaItem?.mediaId ?: return@launch // 获取该元素 diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt index 196484c1d..294731395 100644 --- a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt @@ -14,7 +14,7 @@ import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmedia.extension.ListAction import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -64,7 +64,7 @@ internal class ArtistsSM : ScreenModel { when (action) { ArtistsScreenAction.LocaleToPlayingItem -> { // 获取正在播放的元素ID - val mediaId = LPlayer.runtime.info.playingIdFlow.value + val mediaId = MPlayer.currentMediaItem?.mediaId ?: return@launch // 获取该元素 diff --git a/lmedia b/lmedia index fe12b0df3..94feeb64c 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit fe12b0df39e152ee3046a639483eaeac4935292d +Subproject commit 94feeb64c480d5fc0b40e5fd9a15a47f213c5763 diff --git a/lplayer/src/main/java/com/lalilu/lplayer/LPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/LPlayer.kt deleted file mode 100644 index 1a588370d..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/LPlayer.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lalilu.lplayer - -import android.support.v4.media.session.PlaybackStateCompat -import com.danikula.videocache.HttpProxyCacheServer -import com.lalilu.common.base.Playable -import com.lalilu.lplayer.extensions.AudioFocusHelper -import com.lalilu.lplayer.playback.PlayMode -import com.lalilu.lplayer.playback.Playback -import com.lalilu.lplayer.playback.impl.LocalPlayer -import com.lalilu.lplayer.playback.impl.MixPlayback -import com.lalilu.lplayer.runtime.Runtime -import com.lalilu.lplayer.service.LController -import com.lalilu.lplayer.service.LRuntime -import org.koin.android.ext.koin.androidApplication -import org.koin.dsl.module - - -object LPlayer { - - const val ACTION_SET_REPEAT_MODE = "ACTION_SET_REPEAT_MODE" - - const val MEDIA_DEFAULT_ACTION = PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_STOP or - PlaybackStateCompat.ACTION_SEEK_TO or - PlaybackStateCompat.ACTION_SET_REPEAT_MODE or - PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE - - val playback: Playback by lazy { MixPlayback() } - val runtime: Runtime by lazy { LRuntime { playback.playMode == PlayMode.ListRecycle } } - val controller: LController by lazy { LController(playback, runtime.queue) } - - val module = module { - single { HttpProxyCacheServer(androidApplication()) } - single { LocalPlayer(androidApplication()) } - single { AudioFocusHelper(androidApplication()) } - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index 4994ed880..ba9fc1aca 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -55,6 +55,11 @@ object MPlayer : CoroutineScope { get() = runCatching { if (browserFuture.isDone) browserFuture.get()?.bufferedPosition else null } .getOrNull() ?: 0L + fun isItemPlaying(mediaId: String): Boolean { + if (!isPlaying) return false + return currentMediaItem?.mediaId == mediaId + } + internal fun init() { launch(Dispatchers.Main) { val browser = browserFuture.await() diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/AudioFocusHelper.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/AudioFocusHelper.kt deleted file mode 100644 index 5e1366dd3..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/AudioFocusHelper.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.lalilu.lplayer.extensions - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.os.Build - -@Suppress("DEPRECATION") -class AudioFocusHelper(context: Context) : AudioManager.OnAudioFocusChangeListener { - companion object { - var ignoreAudioFocus: Boolean = false - } - - private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - private var resumeOnGain: Boolean = false - private var focusRequest: AudioFocusRequest? = null - var onPlay: () -> Unit = {} - var onPause: () -> Unit = {} - var isPlaying: () -> Boolean = { false } - - - override fun onAudioFocusChange(focusChange: Int) { - when (focusChange) { - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, - AudioManager.AUDIOFOCUS_GAIN -> { - if (resumeOnGain) { - if (!ignoreAudioFocus) { - onPlay() - } - } - } - - AudioManager.AUDIOFOCUS_LOSS -> { - resumeOnGain = false - if (!ignoreAudioFocus) { - onPause() - } - } - - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - resumeOnGain = isPlaying() - if (!ignoreAudioFocus) { - onPause() - } - } - } - } - - fun abandon() { - resumeOnGain = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - focusRequest?.let { audioManager.abandonAudioFocusRequest(it) } - } else { - audioManager.abandonAudioFocus(this) - } - } - - fun request(): Int { - resumeOnGain = false - if (ignoreAudioFocus) return AudioManager.AUDIOFOCUS_REQUEST_GRANTED - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - .setAcceptsDelayedFocusGain(true) - .setOnAudioFocusChangeListener(this) - .build() - audioManager.requestAudioFocus(focusRequest!!) - } else { - audioManager.requestAudioFocus( - this, - AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN - ) - } - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt index 794bb8dc5..0b0f21e2b 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt @@ -1,147 +1,7 @@ package com.lalilu.lplayer.extensions -import android.content.Context -import android.media.MediaPlayer -import android.net.Uri -import android.os.CountDownTimer import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat -import android.util.LruCache -import androidx.annotation.IntRange -import com.blankj.utilcode.util.LogUtils -import java.net.URLDecoder - -object PlayerVolumeHelper { - private var maxVolume: Float = 1f - private val timers = LruCache(5) - private val nowVolumes = LruCache(10) - - fun getMaxVolume(): Float = maxVolume - fun getNowVolume(id: Int): Float { - val volume = nowVolumes.get(id) ?: maxVolume.also { nowVolumes.put(id, it) } - return minOf(volume, maxVolume) - } - - fun updateMaxVolume(maxVolume: Float) { - this.maxVolume = maxVolume - } - - fun updateNowVolume(id: Int, volume: Float) { - nowVolumes.put(id, volume) - } - - fun cancelTimer(id: Int) { - timers[id]?.cancel() - } - - fun addTimer(id: Int, timer: CountDownTimer) { - timers.put(id, timer) - } -} - -fun MediaPlayer.safeIsPlaying(): Boolean { - return runCatching { isPlaying }.getOrNull() ?: false -} - -fun MediaPlayer.safeCheck(): Boolean { - return runCatching { currentPosition >= 0 }.getOrNull() ?: false -} - -fun MediaPlayer.setMaxVolume(@IntRange(from = 0, to = 100) volume: Int) { - val maxVolume = (volume / 100f).coerceIn(0f, 1f) - val sessionId = audioSessionId - - PlayerVolumeHelper.updateMaxVolume(maxVolume) - PlayerVolumeHelper.updateNowVolume(sessionId, maxVolume) - val temp = PlayerVolumeHelper.getNowVolume(sessionId) - setVolume(temp, temp) -} - -fun MediaPlayer.fadeStart( - duration: Long = 500L, - onFinished: MediaPlayer.() -> Unit = {}, -) = synchronized(this) { - val sessionId = audioSessionId - PlayerVolumeHelper.cancelTimer(sessionId) - - val startValue = PlayerVolumeHelper.getNowVolume(sessionId) - setVolume(startValue, startValue) - - if (!safeCheck()) return@synchronized - - // 当前未播放,则开始播放 - if (!safeIsPlaying()) start() - - val timer = object : CountDownTimer(duration, duration / 10L) { - override fun onTick(millisUntilFinished: Long) { - val maxVolume = PlayerVolumeHelper.getMaxVolume() - val fraction = 1f - (millisUntilFinished * 1.0f / duration) - val volume = lerp(startValue, maxVolume, fraction).coerceIn(0f, maxVolume) - - PlayerVolumeHelper.updateNowVolume(sessionId, volume) - val temp = PlayerVolumeHelper.getNowVolume(sessionId) - - runCatching { setVolume(temp, temp) }.getOrElse { cancel() } - } - - override fun onFinish() { - val maxVolume = PlayerVolumeHelper.getMaxVolume() - PlayerVolumeHelper.updateNowVolume(sessionId, maxVolume) - runCatching { setVolume(maxVolume, maxVolume) }.getOrElse { cancel() } - onFinished() - } - } - timer.start() - PlayerVolumeHelper.addTimer(sessionId, timer) -} - -fun MediaPlayer.fadePause( - duration: Long = 500L, - onFinished: MediaPlayer.() -> Unit = {}, -) = synchronized(this) { - val sessionId = audioSessionId - PlayerVolumeHelper.cancelTimer(sessionId) - val startValue = PlayerVolumeHelper.getNowVolume(sessionId) - - if (!safeCheck()) return@synchronized - - val timer = object : CountDownTimer(duration, duration / 10L) { - override fun onTick(millisUntilFinished: Long) { - val maxVolume = PlayerVolumeHelper.getMaxVolume() - val fraction = 1f - (millisUntilFinished * 1.0f / duration) - val volume = lerp(startValue, 0f, fraction).coerceIn(0f, maxVolume) - - PlayerVolumeHelper.updateNowVolume(sessionId, volume) - val temp = PlayerVolumeHelper.getNowVolume(sessionId) - - runCatching { setVolume(temp, temp) }.getOrElse { cancel() } - } - - override fun onFinish() { - PlayerVolumeHelper.updateNowVolume(sessionId, 0f) - runCatching { setVolume(0f, 0f) }.getOrElse { cancel() } - if (safeIsPlaying()) pause() - onFinished() - } - } - timer.start() - PlayerVolumeHelper.addTimer(sessionId, timer) -} - -fun MediaPlayer.loadSource(context: Context, uri: Uri, handleNetUrl: (String) -> String = { it }) { - if (uri.scheme == "content" || uri.scheme == "file") { - setDataSource(context, uri) - } else { - // url 的长度可能会超长导致异常 - val url = URLDecoder.decode(uri.toString(), "UTF-8") - val proxyUrl = handleNetUrl(url) - - if (url != proxyUrl) { - LogUtils.i("MediaPlayer: cacheProxy", "url: $url, proxyUrl: $proxyUrl") - } - setDataSource(proxyUrl) - } -} fun MediaSessionCompat.isPlaying(): Boolean { return PlaybackStateCompat.STATE_PLAYING == controller.playbackState?.state diff --git a/lplayer/src/main/java/com/lalilu/lplayer/playback/PlayMode.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt similarity index 97% rename from lplayer/src/main/java/com/lalilu/lplayer/playback/PlayMode.kt rename to lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt index 678202d22..030f644ed 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/playback/PlayMode.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt @@ -1,4 +1,4 @@ -package com.lalilu.lplayer.playback +package com.lalilu.lplayer.extensions import android.support.v4.media.session.PlaybackStateCompat.REPEAT_MODE_ALL import android.support.v4.media.session.PlaybackStateCompat.REPEAT_MODE_ONE diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt index 27416da96..1e226b8af 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt @@ -1,10 +1,7 @@ package com.lalilu.lplayer.extensions -import com.lalilu.lplayer.LPlayer - sealed class QueueAction : Action { override fun action() { - LPlayer.controller.doAction(this) } data object Clear : QueueAction() diff --git a/lplayer/src/main/java/com/lalilu/lplayer/playback/PlayQueue.kt b/lplayer/src/main/java/com/lalilu/lplayer/playback/PlayQueue.kt deleted file mode 100644 index ba656c9ca..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/playback/PlayQueue.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.lalilu.lplayer.playback - -import android.net.Uri -import com.lalilu.lplayer.extensions.add -import com.lalilu.lplayer.extensions.move - -interface PlayQueue { - fun isListLooping(): Boolean - fun getById(id: String): T? - fun getUriFromItem(item: T): Uri - - fun getIds(): List - fun getCurrentId(): String? - fun getSize(): Int = getIds().size - fun indexOf(id: String): Int = getIds().indexOf(id) - fun getOrNull(index: Int): String? = getIds().getOrNull(index) - - fun getCurrentIndex(): Int = getCurrentId() - ?.let { indexOf(it) } ?: -1 - - fun getCurrent(): T? = getOrNull(getCurrentIndex()) - ?.let { getById(it) } - - fun getNextIndex(): Int = (getCurrentIndex() + 1) - .let { - if (getSize() == 0) return@let it - if (isListLooping()) it % getSize() else it - } - - fun getPreviousIndex(): Int = (getCurrentIndex() - 1) - .let { - if (getSize() == 0) return@let it - if (isListLooping()) it % getSize() else it - } - - fun getNextId(): String? = getOrNull(getNextIndex()) - fun getPreviousId(): String? = getOrNull(getPreviousIndex()) - fun getNext(): T? = getNextId()?.let { getById(it) } - fun getPrevious(): T? = getPreviousId()?.let { getById(it) } - - fun getShuffle(): T? -} - -sealed class QueueEvent { - data object Updated : QueueEvent() - data class Removed(val id: String) : QueueEvent() - data class Added(val id: String) : QueueEvent() - data class Moved(val from: Int, val to: Int) : QueueEvent() - - fun interface OnQueueEventListener { - fun onQueueEvent(event: QueueEvent) - } -} - -interface UpdatableQueue : PlayQueue { - fun setIds(ids: List) { - onQueueEvent(QueueEvent.Updated) - } - - fun setCurrentId(id: String?) - - fun addToNext(id: String): Boolean { - val itemIndex = indexOf(id) - // 该元素已存在于列表中,则返回添加失败 - if (itemIndex != -1) return false - - val nextIndex = getNextIndex() - // 该元素已经处于下一个位置,则返回移动失败 - if (itemIndex == nextIndex) return false - - setIds(getIds().add(nextIndex, id)) - onQueueEvent(QueueEvent.Added(id)) - return true - } - - fun addToPrevious(id: String): Boolean { - val itemIndex = indexOf(id) - // 该元素已存在于列表中,则返回添加失败 - if (itemIndex != -1) return false - - val previousIndex = getPreviousIndex() - // 该元素已经处于上一个位置,则返回移动失败 - if (itemIndex == previousIndex) return false - - setIds(getIds().add(previousIndex, id)) - onQueueEvent(QueueEvent.Added(id)) - return true - } - - fun moveToNext(id: String): Boolean { - val itemIndex = indexOf(id) - // 该元素不存在与列表中,则返回移动失败 - if (itemIndex == -1) return false - - val nextIndex = getNextIndex() - // 该元素已经处于下一个位置,则返回移动失败 - if (itemIndex == nextIndex) return false - - moveByIndex(itemIndex, nextIndex) - return true - } - - fun moveToPrevious(id: String): Boolean { - val itemIndex = indexOf(id) - // 该元素不存在与列表中,则返回移动失败 - if (itemIndex == -1) return false - - val previousIndex = getPreviousIndex() - // 该元素已经处于上一个位置,则返回移动失败 - if (itemIndex == previousIndex) return false - - moveByIndex(itemIndex, previousIndex) - return true - } - - fun remove(id: String) { - val list = getIds().toMutableList() - - list.indexOf(id) - .takeIf { it >= 0 } - ?.let { list.removeAt(it) } - ?.let { - setIds(list) - onQueueEvent(QueueEvent.Removed(it)) - } - } - - fun moveByIndex(from: Int, to: Int) { - if (from == to) return - - val list = getIds() - .takeIf { from in it.indices } - ?.move(from, to) - ?: return - - setIds(list) - onQueueEvent(QueueEvent.Moved(from, to)) - } - - fun onQueueEvent(event: QueueEvent) {} - fun setOnQueueEventListener(listener: QueueEvent.OnQueueEventListener) {} -} - -abstract class BaseQueue : UpdatableQueue { - private var onQueueEventListener: QueueEvent.OnQueueEventListener? = null - - override fun setOnQueueEventListener(listener: QueueEvent.OnQueueEventListener) { - onQueueEventListener = listener - } - - override fun onQueueEvent(event: QueueEvent) { - onQueueEventListener?.onQueueEvent(event) - } - - override fun getShuffle(): T? { - val items = getIds() - val playingId = getCurrentId() - val playingIndex = items.indexOf(playingId) - val endIndex = playingIndex + 20 - var targetIndex: Int? = null - var retryCount = 5 - - if (items.size <= 20 * 2) { - while (true) { - targetIndex = items.indices.randomOrNull() ?: break - if (targetIndex != playingIndex || retryCount-- <= 0) break - } - } else { - var targetRange = items.indices - playingIndex.rangeTo(endIndex) - - if (endIndex >= items.size) { - targetRange = targetRange - 0.rangeTo(endIndex - items.size) - } - - targetIndex = targetRange.randomOrNull() - } - - targetIndex ?: return null - return getById(items[targetIndex]) - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/playback/Playback.kt b/lplayer/src/main/java/com/lalilu/lplayer/playback/Playback.kt deleted file mode 100644 index 095d10768..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/playback/Playback.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.lalilu.lplayer.playback - -import android.support.v4.media.session.MediaSessionCompat -import com.lalilu.lplayer.extensions.AudioFocusHelper -import com.lalilu.lplayer.extensions.PlayerAction - -abstract class Playback : MediaSessionCompat.Callback() { - abstract var audioFocusHelper: AudioFocusHelper? - abstract var playbackListener: Listener? - abstract var queue: UpdatableQueue? - abstract var player: Player? - abstract var playMode: PlayMode - - abstract fun pauseWhenCompletion() - abstract fun cancelPauseWhenCompletion() - - abstract fun readyToUse(): Boolean - abstract fun changeToPlayer(changeTo: Player) - abstract fun setMaxVolume(volume: Int) - abstract fun preloadNextItem() - abstract fun destroy() - abstract fun handleCustomAction(action: PlayerAction.CustomAction) - - interface Listener { - fun onPlayInfoUpdate(item: T?, playbackState: Int, position: Long) - fun onSetPlayMode(playMode: PlayMode) - fun onItemPlay(item: T) - fun onItemPause(item: T) - fun onPlayerCreated(id: Any) - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/playback/Player.kt b/lplayer/src/main/java/com/lalilu/lplayer/playback/Player.kt deleted file mode 100644 index 4f82e7133..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/playback/Player.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.lalilu.lplayer.playback - -import android.net.Uri - -interface Player { - var listener: Listener? - val isPrepared: Boolean - val isPlaying: Boolean - val isStopped: Boolean - var couldPlayNow: () -> Boolean - var handleNetUrl: (String) -> String - - fun play() - fun pause() - fun stop() - fun seekTo(durationMs: Number) - fun destroy() - - /** - * 加载歌曲文件 - */ - fun load(uri: Uri, startWhenReady: Boolean, startPosition: Long) - fun preloadNext(uri: Uri) - fun confirmPreloadNext() // 确认当前歌曲播放完成,可以播放下一首 - fun resetPreloadNext() // 重置预加载的下一首歌曲 - - fun getPosition(): Long - fun getDuration(): Long - fun getBufferedPosition(): Long - - fun getVolume(): Int - fun getMaxVolume(): Int - fun setMaxVolume(volume: Int) - - fun interface Listener { - fun onPlayerEvent(event: PlayerEvent) - } -} - -sealed class PlayerEvent { - data object OnPlay : PlayerEvent() // 开始加载 - data object OnStart : PlayerEvent() // 开始播放 - data object OnPause : PlayerEvent() // 暂停播放 - data object OnStop : PlayerEvent() // 停止播放 - data object OnPrepared : PlayerEvent() // 加载完成 - data object OnNextPrepared : PlayerEvent() // 预加载完成 - - data class OnCompletion(val nextItemReady: Boolean) : PlayerEvent() - data class OnCreated(val playerId: Any) : PlayerEvent() - data class OnError(val throwable: Exception) : PlayerEvent() - data class OnSeekTo(val newDurationMs: Number) : PlayerEvent() -} diff --git a/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/LMediaPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/LMediaPlayer.kt deleted file mode 100644 index 6dc1e8f32..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/LMediaPlayer.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.lalilu.lplayer.playback.impl - -import android.content.Context -import android.media.MediaPlayer -import android.os.Build -import androidx.annotation.RequiresApi - -/** - * 为MediaPlayer添加isPrepared参数,方便判断是否已经prepare - */ -internal class LMediaPlayer : MediaPlayer { - constructor() : super() - - @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - constructor(context: Context) : super(context) - - var isPrepared: Boolean = false - private set - - private var listener: OnPreparedListener? = null - private val listenerWrapper = OnPreparedListener { - isPrepared = true - listener?.onPrepared(it) - } - - override fun setOnPreparedListener(listener: OnPreparedListener?) { - this.listener = listener - - if (listener == null) { - super.setOnPreparedListener(null) - } else { - super.setOnPreparedListener(listenerWrapper) - } - } - - override fun reset() { - isPrepared = false - super.reset() - } - - override fun stop() { - isPrepared = false - super.stop() - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/LocalPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/LocalPlayer.kt deleted file mode 100644 index 53c10a303..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/LocalPlayer.kt +++ /dev/null @@ -1,298 +0,0 @@ -package com.lalilu.lplayer.playback.impl - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioManager -import android.media.MediaPlayer -import android.net.Uri -import android.os.Build -import com.blankj.utilcode.util.LogUtils -import com.lalilu.lplayer.extensions.PlayerVolumeHelper -import com.lalilu.lplayer.extensions.fadePause -import com.lalilu.lplayer.extensions.fadeStart -import com.lalilu.lplayer.extensions.loadSource -import com.lalilu.lplayer.extensions.safeIsPlaying -import com.lalilu.lplayer.extensions.setMaxVolume -import com.lalilu.lplayer.playback.Player -import com.lalilu.lplayer.playback.PlayerEvent -import java.io.IOException - - -class LocalPlayer( - private val context: Context -) : Player, Player.Listener, - MediaPlayer.OnPreparedListener, - MediaPlayer.OnCompletionListener, - MediaPlayer.OnErrorListener, - MediaPlayer.OnBufferingUpdateListener { - override var listener: Player.Listener? = null - override val isPlaying: Boolean get() = player?.safeIsPlaying() == true - override val isPrepared: Boolean get() = player?.isPrepared == true - override val isStopped: Boolean get() = player?.safeIsPlaying() != true - - override var couldPlayNow: () -> Boolean = { true } - override var handleNetUrl: (String) -> String = { it } - private var startWhenReady: Boolean = false - private var startPosition: Long = 0L - private var bufferedPercent: Float = 0f - - private var nextLoadedUri: Uri? = null - private var player: LMediaPlayer? = null // 正在播放时操作用的player - private var nextPlayer: LMediaPlayer? = null // 准备好播放下一首的player - private var preloadingPlayer: LMediaPlayer? = null // 正在预加载的player - private val recyclePool = mutableListOf() // MediaPlayer复用池 - private val recyclePoolMaxSize = 3 // MediaPlayer复用池的最大容量,超出的部分将释放 - - private fun newPlayer(): LMediaPlayer { - val player = if (Build.VERSION.SDK_INT >= 34) LMediaPlayer(context) else LMediaPlayer() - player.setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .setLegacyStreamType(AudioManager.STREAM_MUSIC) - .build() - ) - return player - } - - /** - * 获取可用的MediaPlayer - */ - private fun requireUsablePlayer(): LMediaPlayer { - while (recyclePool.size > recyclePoolMaxSize) { - recyclePool.removeLastOrNull()?.let { - it.reset() - it.release() - } - } - return recyclePool.removeFirstOrNull() ?: newPlayer() - } - - /** - * 回收MediaPlayer,超出最大容量则释放不再需要该MediaPlayer了 - */ - private fun recycleMediaPlayer(player: LMediaPlayer) { - if (player.safeIsPlaying()) player.stop() - unbindPlayer(player) - player.reset() - - if (recyclePool.size > recyclePoolMaxSize) { - player.release() - } else { - recyclePool.add(player) - } - } - - private fun bindPlayer(player: MediaPlayer) { - player.setOnPreparedListener(this@LocalPlayer) - player.setOnCompletionListener(this@LocalPlayer) - player.setOnBufferingUpdateListener(this@LocalPlayer) - player.setOnErrorListener(this@LocalPlayer) - onPlayerEvent(PlayerEvent.OnCreated(player.audioSessionId)) - } - - private fun unbindPlayer(player: MediaPlayer) { - player.setOnBufferingUpdateListener(null) - player.setOnCompletionListener(null) - player.setOnBufferingUpdateListener(null) - player.setOnErrorListener(null) - } - - override fun load(uri: Uri, startWhenReady: Boolean, startPosition: Long) { - try { - this.startWhenReady = startWhenReady - this.startPosition = startPosition - - // 暂停后回收旧的MediaPlayer - player?.fadePause(duration = 800L) fadePause@{ - recycleMediaPlayer(this@fadePause as LMediaPlayer) - } - - // 若当前准备播放的uri与预加载的一致,则使用预加载完成的player进行播放 - if (uri == nextLoadedUri && nextPlayer != null) { - player = nextPlayer?.also { bindPlayer(it) } - nextPlayer = null - nextLoadedUri = null - onPrepared(player) - return - } else { - resetPreloadNext() - } - - // 正常创建或使用回收的MediaPlayer进行加载后播放逻辑 - player = requireUsablePlayer().also { bindPlayer(it) } - player?.reset() - player?.loadSource(context, uri, handleNetUrl) - player?.prepareAsync() - } catch (e: IOException) { - LogUtils.e("播放失败:歌曲文件异常: ${e.message}") - onPlayerEvent(PlayerEvent.OnError(e)) - onPlayerEvent(PlayerEvent.OnStop) - } catch (e: Exception) { - LogUtils.e("播放失败:未知异常: ${e.message}") - onPlayerEvent(PlayerEvent.OnError(e)) - onPlayerEvent(PlayerEvent.OnStop) - } - } - - override fun play() { - if (isPrepared) { - // 判断当前是否可以播放 - if (!couldPlayNow()) return - - if (!isPlaying) { - player?.fadeStart() - onPlayerEvent(PlayerEvent.OnStart) - } - } - onPlayerEvent(PlayerEvent.OnPlay) - } - - override fun pause() { - player?.fadePause() - onPlayerEvent(PlayerEvent.OnPause) - } - - override fun stop() { - player?.apply { - if (safeIsPlaying()) stop() - reset() - release() - } - player = null - onPlayerEvent(PlayerEvent.OnStop) - } - - override fun seekTo(durationMs: Number) { - if (player?.isPrepared != true) { - LogUtils.e("Not prepared, can't do seekTo action.") - return - } - - player?.seekTo(durationMs.toInt()) - onPlayerEvent(PlayerEvent.OnSeekTo(durationMs)) - } - - override fun destroy() { - stop() - listener = null - } - - override fun preloadNext(uri: Uri) { - // 若预加载已成功且无参数变化则不重新进行预加载 - if (nextLoadedUri == uri && nextPlayer != null) return - - // 获取可用的MediaPlayer - preloadingPlayer = requireUsablePlayer() - - // 异步加载数据 - preloadingPlayer!!.apply { - setOnPreparedListener { - // 若回调的MediaPlayer与preloadingPlayer不同,则说明新的调用创建了新的预加载MediaPlayer,需回收该MediaPlayer - if (preloadingPlayer != it) { - recycleMediaPlayer(it as LMediaPlayer) - return@setOnPreparedListener - } - - // 成功后将转移至nextPlayer,标记为待播放 - nextPlayer = it - nextLoadedUri = uri - preloadingPlayer = null - player?.setNextMediaPlayer(it) - onPlayerEvent(PlayerEvent.OnNextPrepared) - } - setOnErrorListener { mp, what, extra -> - LogUtils.e("预加载异常:$what $extra", uri) - recycleMediaPlayer(mp as LMediaPlayer) - false - } - reset() - loadSource(context, uri, handleNetUrl) - prepareAsync() - } - } - - override fun confirmPreloadNext() { - // 回收当前使用的MediaPlayer - player?.let(::recycleMediaPlayer) - - // 将nextPlayer转移至当前播放的player - player = nextPlayer?.also { bindPlayer(it) } - nextPlayer = null - nextLoadedUri = null - - // 为MediaPlayer设置音量 - PlayerVolumeHelper.getMaxVolume() - .let { player?.setVolume(it, it) } - } - - override fun resetPreloadNext() { - player?.setNextMediaPlayer(null) - - // 取消播放该预加载元素,此时将已经加载好的Player回收 - nextPlayer?.let(::recycleMediaPlayer) - nextPlayer = null - nextLoadedUri = null - } - - override fun getPosition(): Long { - if (player?.isPrepared != true) return 0L - return runCatching { player?.currentPosition?.toLong() }.getOrNull() ?: 0L - } - - override fun getDuration(): Long { - if (player?.isPrepared != true) return 0L - return runCatching { player?.duration?.toLong() }.getOrNull() ?: 0L - } - - override fun getBufferedPosition(): Long { - if (player?.isPrepared != true) return 0L - return (getDuration() * bufferedPercent).toLong() - } - - override fun getVolume(): Int { - val audioSessionId = player?.audioSessionId ?: 0 - return PlayerVolumeHelper.getNowVolume(audioSessionId).toInt() - } - - override fun getMaxVolume(): Int { - return PlayerVolumeHelper.getMaxVolume().toInt() - } - - override fun setMaxVolume(volume: Int) { - player?.setMaxVolume(volume) - } - - override fun onPrepared(mp: MediaPlayer?) { - onPlayerEvent(PlayerEvent.OnPrepared) - - // 开始播放时跳转指定position - if (startPosition > 0L) { - player?.seekTo(startPosition.toInt()) - startPosition = 0L - } - - // 是否缓冲完成就开始播放 - if (startWhenReady) { - play() - } - } - - override fun onCompletion(mp: MediaPlayer?) { - val readyForNext = nextPlayer != null && nextLoadedUri != null - onPlayerEvent(PlayerEvent.OnCompletion(readyForNext)) - } - - override fun onError(mp: MediaPlayer?, what: Int, extra: Int): Boolean { - LogUtils.e("播放异常:$what $extra") - return false - } - - override fun onPlayerEvent(event: PlayerEvent) { - listener?.onPlayerEvent(event) - } - - override fun onBufferingUpdate(mp: MediaPlayer?, percent: Int) { - bufferedPercent = percent / 100f - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/MixPlayback.kt b/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/MixPlayback.kt deleted file mode 100644 index 96461e395..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/playback/impl/MixPlayback.kt +++ /dev/null @@ -1,372 +0,0 @@ -package com.lalilu.lplayer.playback.impl - -import android.media.AudioManager -import android.net.Uri -import android.os.Bundle -import android.support.v4.media.session.PlaybackStateCompat -import com.lalilu.common.base.Playable -import com.lalilu.lplayer.extensions.AudioFocusHelper -import com.lalilu.lplayer.extensions.PlayerAction -import com.lalilu.lplayer.playback.PlayMode -import com.lalilu.lplayer.playback.Playback -import com.lalilu.lplayer.playback.Player -import com.lalilu.lplayer.playback.PlayerEvent -import com.lalilu.lplayer.playback.QueueEvent -import com.lalilu.lplayer.playback.UpdatableQueue - -class MixPlayback : Playback(), Playback.Listener, Player.Listener, - QueueEvent.OnQueueEventListener { - override var playbackListener: Listener? = null - override var queue: UpdatableQueue? = null - set(value) { - field = value - value ?: return - value.setOnQueueEventListener(this) - } - - override var audioFocusHelper: AudioFocusHelper? = null - set(value) { - field = value - value ?: return - value.onPlay = ::onPlay - value.onPause = ::onPause - value.isPlaying = { player?.isPlaying ?: false } - } - - override var player: Player? = null - set(value) { - field = value - value ?: return - value.listener = this - value.couldPlayNow = { - audioFocusHelper == null || audioFocusHelper?.request() == AudioManager.AUDIOFOCUS_REQUEST_GRANTED - } - } - override var playMode: PlayMode = PlayMode.ListRecycle - set(value) { - field = value - onSetPlayMode(value) - } - - private var doPauseWhenComplete: Boolean = false - - override fun pauseWhenCompletion() { - doPauseWhenComplete = true - } - - override fun cancelPauseWhenCompletion() { - doPauseWhenComplete = false - } - - override fun readyToUse(): Boolean { - return queue != null && player != null - } - - override fun onSetShuffleMode(shuffleMode: Int) { - playMode = PlayMode.of(playMode.repeatMode, shuffleMode) - } - - override fun onSetRepeatMode(repeatMode: Int) { - playMode = PlayMode.of(repeatMode, playMode.shuffleMode) - } - - override fun changeToPlayer(changeTo: Player) { - if (player == changeTo) return - - player?.takeIf { !it.isStopped }?.let { - it.listener = null - it.stop() - } - player = changeTo - changeTo.listener = this - } - - override fun setMaxVolume(volume: Int) { - player?.setMaxVolume(volume) - } - - override fun onPause() { - player?.pause() - } - - override fun onPlay() { - if (player?.isPrepared == true) { - player?.play() - } else { - val item = queue?.getCurrent() ?: return - val uri = queue?.getUriFromItem(item) ?: return - - onPlayInfoUpdate(item, PlaybackStateCompat.STATE_BUFFERING, 0L) - onPlayFromUri(uri, null) - } - } - - override fun onPlayFromUri(uri: Uri, extras: Bundle?) { - player?.load(uri, true, 0L) - } - - override fun onPlayFromMediaId(mediaId: String, extras: Bundle?) { - val item = queue?.getById(mediaId) ?: return - val uri = queue?.getUriFromItem(item) ?: return - - onPlayInfoUpdate(item, PlaybackStateCompat.STATE_BUFFERING, 0L) - onPlayFromUri(uri, extras) - } - - override fun onSkipToNext() { - val next = when (playMode) { - PlayMode.ListRecycle -> queue?.getNext() - PlayMode.RepeatOne -> queue?.getNext() - PlayMode.Shuffle -> queue?.getShuffle() - } ?: return - val uri = queue?.getUriFromItem(next) ?: return - - if (playMode == PlayMode.Shuffle) { - queue?.moveToPrevious(id = next.mediaId) - } - - onPlayInfoUpdate(next, PlaybackStateCompat.STATE_SKIPPING_TO_NEXT, 0L) - onPlayFromUri(uri, null) - } - - override fun onSkipToPrevious() { - val previous = when (playMode) { - PlayMode.ListRecycle -> queue?.getPrevious() - PlayMode.RepeatOne -> queue?.getPrevious() - PlayMode.Shuffle -> queue?.getNext() - } ?: return - val uri = queue?.getUriFromItem(previous) ?: return - - onPlayInfoUpdate(previous, PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS, 0L) - onPlayFromUri(uri, null) - } - - override fun onSeekTo(pos: Long) { - // TODO 存在正在加载的情况下触发重新加载的可能,需要增加一个正在加载的标志位,在加载完成前不触发重新加载 - if (player?.isPrepared == true) { - player?.seekTo(pos) - } else { - val item = queue?.getCurrent() ?: return - val uri = queue?.getUriFromItem(item) ?: return - - onPlayInfoUpdate(item, PlaybackStateCompat.STATE_BUFFERING, 0L) - player?.load(uri, true, pos) - } - } - - override fun onStop() { - player?.stop() - } - - private var tempNextItem: Playable? = null - - override fun preloadNextItem() { - tempNextItem = when { - playMode == PlayMode.ListRecycle -> queue?.getNext() - playMode == PlayMode.RepeatOne -> queue?.getCurrent() - playMode == PlayMode.Shuffle && tempNextItem == null -> queue?.getShuffle() - else -> tempNextItem - } ?: return - val uri = queue?.getUriFromItem(tempNextItem!!) ?: return - player?.preloadNext(uri) - } - - override fun destroy() { - playbackListener = null - queue = null - player = null - } - - override fun onCustomAction(action: String?, extras: Bundle?) { - action ?: return - - val customAction = PlayerAction.of(action) ?: return - handleCustomAction(customAction) - } - - override fun handleCustomAction(action: PlayerAction.CustomAction) { - when (action) { - PlayerAction.PlayOrPause -> { - player ?: return - if (player!!.isPlaying) onPause() else onPlay() - } - - PlayerAction.ReloadAndPlay -> { - player ?: return - val item = queue?.getCurrent() ?: return - val uri = queue?.getUriFromItem(item) ?: return - - onPlayInfoUpdate(item, PlaybackStateCompat.STATE_BUFFERING, 0L) - onPlayFromUri(uri, null) - } - } - } - - override fun onPlayerEvent(event: PlayerEvent) { - when (event) { - PlayerEvent.OnPlay -> { - onPlayInfoUpdate( - item = queue?.getCurrent(), - playbackState = PlaybackStateCompat.STATE_PLAYING, - position = player?.getPosition() ?: 0L - ) - } - - PlayerEvent.OnStart -> { - val current = queue?.getCurrent() - current?.let { onItemPlay(it) } - onPlayInfoUpdate( - current, - PlaybackStateCompat.STATE_PLAYING, - player?.getPosition() ?: 0L - ) - preloadNextItem() - } - - PlayerEvent.OnPause -> { - val current = queue?.getCurrent() - current?.let { onItemPause(it) } - - onPlayInfoUpdate( - item = current, - playbackState = PlaybackStateCompat.STATE_PAUSED, - position = player?.getPosition() ?: 0L - ) - } - - PlayerEvent.OnStop -> { - audioFocusHelper?.abandon() - - onPlayInfoUpdate( - item = queue?.getCurrent(), - playbackState = PlaybackStateCompat.STATE_STOPPED, - position = 0L - ) - } - - is PlayerEvent.OnCompletion -> { - if (doPauseWhenComplete) { - doPauseWhenComplete = false - player?.resetPreloadNext() - audioFocusHelper?.abandon() - return - } - - val current = queue?.getCurrent() - val currentUri = current?.let { queue?.getUriFromItem(it) } - val isPreloadedCurrent = current?.mediaId == tempNextItem?.mediaId - - // 若Player未完成预加载,即无法直接播放下一首,则进行Playback的切换下一首流程 - if (!event.nextItemReady || tempNextItem == null) { - player?.resetPreloadNext() - // 若当前处于单曲播放模式,且预加载的歌曲非当前歌曲则需要重新加载并播放 - if (playMode == PlayMode.RepeatOne - && !isPreloadedCurrent - && current != null - && currentUri != null - ) { - onPlayInfoUpdate(current, PlaybackStateCompat.STATE_BUFFERING, 0L) - onPlayFromUri(currentUri, null) - } else { - // 否则切换下一首 - onSkipToNext() - } - return - } - - // 非单曲循环模式但预加载的元素却是当前正在播放元素 - if (playMode != PlayMode.RepeatOne && isPreloadedCurrent) { - player?.resetPreloadNext() - onSkipToNext() - return - } - - // 若当前播放模式为随机播放,将该预加载的元素移动至对应位置 - if (playMode == PlayMode.Shuffle) { - queue?.moveToPrevious(id = tempNextItem!!.mediaId) - } - - // 播放已成功预加载的元素 - player?.confirmPreloadNext() - onItemPlay(tempNextItem!!) - onPlayInfoUpdate( - item = tempNextItem, - playbackState = PlaybackStateCompat.STATE_PLAYING, - position = player?.getPosition() ?: 0L - ) - tempNextItem = null - preloadNextItem() - } - - is PlayerEvent.OnSeekTo -> { - onPlayInfoUpdate( - queue?.getCurrent(), - PlaybackStateCompat.STATE_PLAYING, - event.newDurationMs.toLong() - ) - } - - is PlayerEvent.OnCreated -> { - onPlayerCreated(event.playerId) - } - - PlayerEvent.OnPrepared -> {} - PlayerEvent.OnNextPrepared -> {} - is PlayerEvent.OnError -> {} - } - } - - override fun onPlayInfoUpdate(item: Playable?, playbackState: Int, position: Long) { - playbackListener?.onPlayInfoUpdate(item, playbackState, position) - } - - override fun onSetPlayMode(playMode: PlayMode) { - playbackListener?.onSetPlayMode(playMode) - } - - override fun onPlayerCreated(id: Any) { - playbackListener?.onPlayerCreated(id) - } - - override fun onItemPlay(item: Playable) { - playbackListener?.onItemPlay(item) - } - - override fun onItemPause(item: Playable) { - playbackListener?.onItemPause(item) - } - - override fun onQueueEvent(event: QueueEvent) { - when (event) { - QueueEvent.Updated -> { - // 更新队列后,检查预加载的元素是否还在队列中,若否则重新进行预加载 - val exist = tempNextItem?.mediaId - ?.let { queue?.indexOf(it) } - ?.let { it >= 0 } - ?: false - - if (!exist) { - tempNextItem = null - player?.resetPreloadNext() - preloadNextItem() - } - } - - // TODO 列表发生移动时下一个元素与预加载元素不一样时需要重新进行处理 - is QueueEvent.Added -> {} - is QueueEvent.Moved -> {} - - is QueueEvent.Removed -> { - // 若队列中删除的是已预加载的下一个元素,则去除该元素,重新尝试进行预加载操作 - if (event.id == tempNextItem?.mediaId) { - tempNextItem = null - player?.resetPreloadNext() - preloadNextItem() - } - if (event.id == queue?.getCurrentId()) { - player?.stop() - } - } - } - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/runtime/Runtime.kt b/lplayer/src/main/java/com/lalilu/lplayer/runtime/Runtime.kt deleted file mode 100644 index de6b09f60..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/runtime/Runtime.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.lalilu.lplayer.runtime - -import com.lalilu.lplayer.playback.UpdatableQueue -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import java.util.Timer -import kotlin.concurrent.schedule - -interface Runtime { - val info: RuntimeInfo - val queue: UpdatableQueue - var source: ItemSource? -} - -@OptIn(ExperimentalCoroutinesApi::class) -class RuntimeInfo(source: Flow?>) { - val playingIdFlow: MutableStateFlow = MutableStateFlow(null) - val idsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) - - val playingFlow: Flow = source.flatMapLatest { - it?.flowMapId(playingIdFlow) ?: flowOf(null) - } - val listFlow: Flow> = source.flatMapLatest { - it?.flowMapIds(idsFlow) ?: flowOf(emptyList()) - } - - val isPlayingFlow: MutableStateFlow = MutableStateFlow(false) - val positionFlow: MutableStateFlow = MutableStateFlow(0) - val durationFlow: MutableStateFlow = MutableStateFlow(0) - val bufferedPositionFlow: MutableStateFlow = MutableStateFlow(0) - - var getPosition: () -> Long = { 0L } - var getDuration: () -> Long = { 0L } - var getBufferedPosition: () -> Long = { 0L } - private var timer: Timer? = null - - fun updateIsPlaying(isPlaying: Boolean) { - isPlayingFlow.value = isPlaying - } - - fun updatePosition(startPosition: Long, isPlaying: Boolean) { - timer?.cancel() - positionFlow.value = startPosition - - if (!isPlaying) return - timer = Timer().apply { - schedule(0, 50L) { - positionFlow.value = getPosition() - durationFlow.value = getDuration() - bufferedPositionFlow.value = getBufferedPosition() - } - } - } -} - -interface ItemSource { - fun getById(id: String): T? - fun flowMapId(idFlow: Flow): Flow - fun flowMapIds(idsFlow: Flow>): Flow> -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/LController.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/LController.kt deleted file mode 100644 index 790368aac..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/LController.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.lalilu.lplayer.service - -import com.lalilu.common.base.Playable -import com.lalilu.lplayer.extensions.PlayerAction -import com.lalilu.lplayer.extensions.QueueAction -import com.lalilu.lplayer.playback.Playback -import com.lalilu.lplayer.playback.UpdatableQueue - -class LController( - private val playback: Playback, - private val queue: UpdatableQueue, -) { - fun doAction(action: PlayerAction): Boolean { - if (!playback.readyToUse()) return false - - playback.apply { - when (action) { - PlayerAction.Play -> onPlay() - PlayerAction.Pause -> onPause() - PlayerAction.SkipToNext -> onSkipToNext() - PlayerAction.SkipToPrevious -> onSkipToPrevious() - is PlayerAction.PlayById -> onPlayFromMediaId(action.mediaId, null) - is PlayerAction.SeekTo -> onSeekTo(action.positionMs) - is PlayerAction.CustomAction -> onCustomAction(action.name, null) - is PlayerAction.PauseWhenCompletion -> if (action.cancel) cancelPauseWhenCompletion() else pauseWhenCompletion() - else -> return false - } - } - return true - } - - fun doAction(action: QueueAction): Boolean { - when (action) { - QueueAction.Clear -> queue.setIds(emptyList()) - QueueAction.Shuffle -> queue.setIds(queue.getIds().shuffled()) - is QueueAction.Remove -> { - val mediaId = action.id - if (queue.getCurrentId() == mediaId) { - playback.onSkipToNext() - } - queue.remove(mediaId) - } - - is QueueAction.AddToNext -> { - val mediaId = action.id - if (mediaId == queue.getCurrentId()) return false - - if (queue.moveToNext(mediaId)) { - return true - } else if (queue.addToNext(mediaId)) { - return true - } - return false - } - - is QueueAction.UpdatePlaying -> queue.setCurrentId(action.id) - is QueueAction.UpdateList -> queue.setIds(action.ids) - } - return true - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/LRuntime.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/LRuntime.kt deleted file mode 100644 index 96574369d..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/LRuntime.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.lalilu.lplayer.service - -import android.net.Uri -import com.lalilu.common.base.Playable -import com.lalilu.lplayer.playback.BaseQueue -import com.lalilu.lplayer.playback.UpdatableQueue -import com.lalilu.lplayer.runtime.ItemSource -import com.lalilu.lplayer.runtime.Runtime -import com.lalilu.lplayer.runtime.RuntimeInfo -import kotlinx.coroutines.flow.MutableStateFlow - -class LRuntime internal constructor( - isListLooping: () -> Boolean = { false }, -) : Runtime { - private val sourceFlow = MutableStateFlow?>(null) - - override var source: ItemSource? = null - set(value) { - field = value - sourceFlow.tryEmit(value) - } - override val info: RuntimeInfo by lazy { - RuntimeInfo(source = sourceFlow) - } - override val queue: UpdatableQueue by lazy { - RuntimeQueueWithInfo( - info = info, source = { source }, - isListLoopingFunc = isListLooping - ) - } - - private class RuntimeQueueWithInfo( - private val info: RuntimeInfo, - private val source: () -> ItemSource?, - private val isListLoopingFunc: () -> Boolean = { true }, - ) : BaseQueue() { - - override fun isListLooping(): Boolean = isListLoopingFunc() - override fun getIds(): List = info.idsFlow.value - override fun getCurrentId(): String? = info.playingIdFlow.value - override fun getUriFromItem(item: Playable): Uri = item.targetUri - override fun getById(id: String): Playable? = source()?.getById(id) - override fun setIds(ids: List) { - info.idsFlow.value = ids - super.setIds(ids) - } - - override fun setCurrentId(id: String?) { - info.playingIdFlow.value = id - } - } -} diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt index fe3557d87..b23eb0805 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt @@ -28,9 +28,9 @@ import androidx.media3.session.R import androidx.media3.session.SessionCommand import com.google.common.collect.ImmutableList import com.lalilu.common.post -import com.lalilu.lmedia2.lyric.LyricItem -import com.lalilu.lmedia2.lyric.LyricSourceEmbedded -import com.lalilu.lmedia2.lyric.LyricUtils +import com.lalilu.lmedia.lyric.LyricItem +import com.lalilu.lmedia.lyric.LyricSourceEmbedded +import com.lalilu.lmedia.lyric.LyricUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index 0c7916dcd..29093f083 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -14,7 +14,7 @@ import androidx.media3.session.SessionError import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture -import com.lalilu.lmedia2.LMedia +import com.lalilu.lmedia.LMedia import com.lalilu.lplayer.extensions.FadeTransitionRenderersFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt index d5ffebdeb..82bad6c96 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt @@ -4,10 +4,10 @@ import androidx.compose.material.MaterialTheme import androidx.compose.ui.graphics.Color import com.blankj.utilcode.util.ToastUtils import com.lalilu.RemixIcon -import com.lalilu.common.base.Playable import com.lalilu.common.ext.requestFor import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.entity.LSong import com.lalilu.lplaylist.repository.PlaylistRepository import com.lalilu.remixicon.HealthAndMedical import com.lalilu.remixicon.Media @@ -19,7 +19,7 @@ import org.koin.core.annotation.Named @Factory(binds = [ScreenAction::class]) @Named("add_to_playlist_action") fun provideAddToPlaylistAction( - selectedItems: () -> Collection + selectedItems: () -> Collection ): ScreenAction.Static = ScreenAction.Static( title = { "添加到歌单" }, icon = { RemixIcon.Media.playListAddLine }, @@ -28,7 +28,7 @@ fun provideAddToPlaylistAction( val items = selectedItems() AppRouter.route("/playlist/add") - .with("mediaIds", items.map { it.mediaId }) + .with("mediaIds", items.map { it.id }) .jump() } ) @@ -36,13 +36,13 @@ fun provideAddToPlaylistAction( @Factory(binds = [ScreenAction::class]) @Named("add_to_favourite_action") fun provideAddToFavouriteAction( - selectedItems: () -> Collection + selectedItems: () -> Collection ): ScreenAction.Static = ScreenAction.Static( title = { "添加到我喜欢" }, icon = { RemixIcon.HealthAndMedical.heart3Line }, color = { MaterialTheme.colors.primary }, onAction = { - val items = selectedItems().map { it.mediaId } + val items = selectedItems().map { it.id } val playlistRepo = requestFor() playlistRepo?.let { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt index 93126c63b..f6af26dbb 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ToastUtils -import com.lalilu.common.base.Playable import com.lalilu.common.toCachedFlow import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory @@ -15,6 +14,7 @@ import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.extension.SelectAction import com.lalilu.component.viewmodel.IPlayingViewModel +import com.lalilu.lmedia.entity.LSong import com.lalilu.lplaylist.R import com.lalilu.lplaylist.repository.PlaylistRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -63,8 +63,8 @@ class PlaylistDetailScreenModel( icon = componentR.drawable.ic_delete_bin_6_line, color = Color.Red ) { selector -> - val mediaIds = selector.selected.value.filterIsInstance(Playable::class.java) - .map { it.mediaId } + val mediaIds = selector.selected.value.filterIsInstance() + .map { it.id } playlistRepo.removeMediaIdsFromPlaylist(mediaIds, playlistId.value) ToastUtils.showShort("Removed from playlist") From 309e852fff62105937a6c8d2c6f100985840c33b Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 27 Oct 2024 22:36:43 +0800 Subject: [PATCH 098/213] =?UTF-8?q?[refactor]=E5=8E=BB=E9=99=A4=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E5=AD=90=E9=A1=B5=E9=9D=A2=E5=92=8C=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/component/extension/DialogHost.kt | 19 ++-- .../lalilu/component/navigation/AppRouter.kt | 35 +------- .../component/navigation/HostNavigator.kt | 8 +- .../navigation/ListDetailContainer.kt | 87 ------------------- .../component/navigation/NavigationContext.kt | 45 +--------- .../navigation/NavigationSmartBar.kt | 3 +- .../component/navigation/NestedNavigator.kt | 34 -------- 7 files changed, 21 insertions(+), 210 deletions(-) delete mode 100644 component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt delete mode 100644 component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt diff --git a/component/src/main/java/com/lalilu/component/extension/DialogHost.kt b/component/src/main/java/com/lalilu/component/extension/DialogHost.kt index 28e447a8c..7af242ce0 100644 --- a/component/src/main/java/com/lalilu/component/extension/DialogHost.kt +++ b/component/src/main/java/com/lalilu/component/extension/DialogHost.kt @@ -18,7 +18,6 @@ import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -64,7 +63,11 @@ interface DialogHost { fun push(dialogItem: DialogItem) @Composable - fun register(isVisible: MutableState, dialogItem: DialogItem) + fun register( + isVisible: () -> Boolean, + onDismiss: () -> Unit, + dialogItem: DialogItem + ) } interface DialogContext { @@ -84,17 +87,21 @@ object DialogWrapper : DialogHost, DialogContext { } @Composable - override fun register(isVisible: MutableState, dialogItem: DialogItem) { + override fun register( + isVisible: () -> Boolean, + onDismiss: () -> Unit, + dialogItem: DialogItem + ) { LaunchedEffect(Unit) { snapshotFlow { this@DialogWrapper.dialogItem } .collectLatest { - if (it != null || !isVisible.value) return@collectLatest - isVisible.value = false + if (it != null || !isVisible()) return@collectLatest + onDismiss() } } LaunchedEffect(Unit) { - snapshotFlow { isVisible.value } + snapshotFlow { isVisible() } .collectLatest { visible -> if (visible) { this@DialogWrapper.dialogItem = dialogItem diff --git a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt index 41ab1801c..205d718bf 100644 --- a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -4,7 +4,6 @@ import android.util.Log import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator import com.lalilu.component.base.TabScreen -import com.lalilu.component.base.screen.ScreenType import com.zhangke.krouter.KRouter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -54,37 +53,12 @@ val DefaultInterceptorForTabScreen = NavInterceptor { navigator, intent -> } } -val DefaultInterceptorForListScreen = NavInterceptor { _, intent -> - fun transform(screen: Screen): Screen = - if (screen is ScreenType.List) ListDetailContainer(screen) else screen - - when (intent) { - is NavIntent.Jump -> intent.copy(transform(intent.screen)) - is NavIntent.Push -> intent.copy(transform(intent.screen)) - is NavIntent.Replace -> intent.copy(transform(intent.screen)) - else -> intent - } -} - val DefaultHandler = NavHandler { navigator, intent -> - val screen = when (intent) { - is NavIntent.Push -> intent.screen - is NavIntent.Replace -> intent.screen - is NavIntent.Jump -> intent.screen - else -> null - } - - val actualNavigator = if (screen is ScreenType.Detail) { - navigator.nestedNavigatorInLastScreen() ?: navigator - } else { - navigator - } - when (intent) { - NavIntent.Pop -> actualNavigator.pop() - is NavIntent.Push -> actualNavigator.push(intent.screen) - is NavIntent.Replace -> actualNavigator.replace(intent.screen) - is NavIntent.Jump -> actualNavigator.push(intent.screen) + NavIntent.Pop -> navigator.pop() + is NavIntent.Push -> navigator.push(intent.screen) + is NavIntent.Replace -> navigator.replace(intent.screen) + is NavIntent.Jump -> navigator.push(intent.screen) NavIntent.None -> {} } } @@ -95,7 +69,6 @@ object AppRouter : CoroutineScope { private var handler: NavHandler = DefaultHandler private val interceptors = mutableListOf( DefaultInterceptorForTabScreen, - DefaultInterceptorForListScreen, ) suspend fun bind( diff --git a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt index e588d5571..f89aa860e 100644 --- a/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt +++ b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt @@ -42,13 +42,9 @@ fun HostNavigator( else -> false } ) { - val actualNavigator = navigator.nestedNavigatorInLastScreen() - ?.takeIf { it.canPop } - ?: navigator - when (enhanceSheetState) { - is EnhanceBottomSheetState -> actualNavigator.pop() - is EnhanceModalSheetState -> if (!actualNavigator.pop()) enhanceSheetState.hide() + is EnhanceBottomSheetState -> navigator.pop() + is EnhanceModalSheetState -> if (!navigator.pop()) enhanceSheetState.hide() else -> {} } } diff --git a/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt b/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt deleted file mode 100644 index 23efe5dd1..000000000 --- a/component/src/main/java/com/lalilu/component/navigation/ListDetailContainer.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.lalilu.component.navigation - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import com.lalilu.component.base.LocalWindowSize -import com.lalilu.component.base.screen.ScreenType - -class ListDetailContainer( - private val listScreen: Screen -) : Screen, ScreenType.ListHost { - override val key: ScreenKey = "${super.key}:${listScreen.key}" - - @Composable - override fun Content() { - NestedNavigator( - startScreen = listScreen, - ) { navigator -> - val windowSizeClass = LocalWindowSize.current - val isPad = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded - - val listContent = remember(navigator) { - movableContentOf { screen -> - (screen ?: navigator.items.firstOrNull { it is ScreenType.List })?.let { - navigator.saveableState(key = "list", screen = it) { - it.Content() - } - } - } - } - - val detailContent = remember(navigator) { - movableContentOf { isPad -> - CustomTransition( - navigator = navigator, - content = { - when { - isPad && it is ScreenType.List -> EmptyScreen.Content() - !isPad && it is ScreenType.List -> listContent(it) - else -> navigator.saveableState( - screen = it, - key = "transition", - content = { it.Content() } - ) - } - } - ) - } - } - - if (isPad) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(360.dp), - content = { listContent(null) } - ) - - Box( - modifier = Modifier - .fillMaxHeight() - .weight(1f), - content = { detailContent(true) } - ) - } - } else { - detailContent(false) - } - } - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt index 3e494d08c..e6a8f6c35 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt @@ -1,58 +1,15 @@ package com.lalilu.component.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator -import com.lalilu.component.base.screen.ScreenType - -private val screenNavigatorMap = mutableStateMapOf() - -fun Navigator.nestedNavigatorInLastScreen(): Navigator? { - return items.lastOrNull() - ?.takeIf { it is ScreenType.ListHost } - ?.let { screenNavigatorMap[it] } -} - -@Composable -fun Navigator.currentScreen(): State { - return remember(this) { - derivedStateOf { - var screen = lastItemOrNull - - // 若该页面存在指向嵌套页面的路由 - while (screenNavigatorMap[screen] != null) { - val temp = screenNavigatorMap[screen] - ?.lastItemOrNull - - if (temp is ScreenType.Empty) break - else screen = temp - } - - screen - } - } -} @Composable fun Navigator.previousScreen(): State { return remember(this) { - derivedStateOf { - val screens = items - .flatMap { screenNavigatorMap[it]?.items ?: listOf(it) } - - screens.getOrNull(screens.size - 2) - } + derivedStateOf { items.getOrNull(items.size - 2) } } } - -@Composable -fun RegisterNavigator(screen: Screen, navigator: Navigator) { - LaunchedEffect(screen, navigator) { - screenNavigatorMap[screen] = navigator - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt index 102d93f5a..301568fc0 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -38,8 +38,7 @@ fun NavigationSmartBar( modifier: Modifier = Modifier, ) { val currentScreen = LocalNavigator.current - ?.currentScreen() - ?.value + ?.lastItemOrNull val previousScreen = LocalNavigator.current ?.previousScreen() diff --git a/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt deleted file mode 100644 index fbe8309d4..000000000 --- a/component/src/main/java/com/lalilu/component/navigation/NestedNavigator.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.lalilu.component.navigation - -import androidx.compose.runtime.Composable -import cafe.adriel.voyager.core.annotation.InternalVoyagerApi -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.CurrentScreen -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.navigator.NavigatorContent -import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior -import cafe.adriel.voyager.navigator.OnBackPressed -import cafe.adriel.voyager.navigator.compositionUniqueId - -@OptIn(InternalVoyagerApi::class) -@Composable -fun Screen.NestedNavigator( - startScreen: Screen, - disposeBehavior: NavigatorDisposeBehavior = NavigatorDisposeBehavior(disposeSteps = false), - onBackPressed: OnBackPressed = null, - key: String = compositionUniqueId(), - content: NavigatorContent = { CurrentScreen() } -) { - Navigator( - screen = startScreen, - disposeBehavior = disposeBehavior, - onBackPressed = onBackPressed, - key = key, - ) { navigator -> - RegisterNavigator( - screen = this, - navigator = navigator - ) - content(navigator) - } -} \ No newline at end of file From 264e85ce3bae8f6072f0368b858f9ce05e0ad713 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 27 Oct 2024 23:44:42 +0800 Subject: [PATCH 099/213] =?UTF-8?q?[refactor]=E6=9B=BF=E6=8D=A2ScreenModel?= =?UTF-8?q?=E6=88=90ViewModel=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E7=9A=84MVI=E6=9E=B6=E6=9E=84=EF=BC=8C=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E9=80=82=E9=85=8D=EF=BC=8C=E5=8E=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A0=E7=94=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/new_screen/SearchLyricScreen.kt | 4 +- .../compose/new_screen/search/SearchScreen.kt | 5 +- .../compose/screen/songs/SongsScreen.kt | 149 ++++++++------- .../screen/songs/SongsScreenContent.kt | 73 +++++++- .../com/lalilu/lmusic/viewmodel/SongsVM.kt | 137 ++++++++++++++ common/src/main/java/com/lalilu/common/MVI.kt | 51 +++++ .../com/lalilu/component/base/CustomScreen.kt | 19 +- .../component/base/screen/ScreenBarFactory.kt | 19 +- .../base/songs/SongsHeaderJumperDialog.kt | 5 +- .../lalilu/component/base/songs/SongsSM.kt | 174 ------------------ .../base/songs/SongsSearcherPanel.kt | 12 +- .../base/songs/SongsSelectorPanel.kt | 12 +- .../base/songs/SongsSortPanelDialog.kt | 5 +- .../lalilu/component/extension/ComposeExt.kt | 68 ++++++- .../component/navigation/NavigateCommonBar.kt | 3 +- .../lartist/screen/ArtistDetailScreen.kt | 150 ++++++++------- .../screen/ArtistDetailScreenContent.kt | 53 +++++- .../lartist/screen/artists/ArtistsScreen.kt | 84 +++++---- .../screen/artists/ArtistsScreenContent.kt | 27 +-- .../lartist/viewModel/ArtistDetailSM.kt | 101 ---------- .../lartist/viewModel/ArtistDetailVM.kt | 148 +++++++++++++++ .../com/lalilu/lartist/viewModel/ArtistsSM.kt | 94 ---------- .../com/lalilu/lartist/viewModel/ArtistsVM.kt | 139 ++++++++++++++ .../lalilu/lplaylist/screen/PlaylistScreen.kt | 3 +- 24 files changed, 909 insertions(+), 626 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt create mode 100644 common/src/main/java/com/lalilu/common/MVI.kt delete mode 100644 component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt delete mode 100644 lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt create mode 100644 lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt delete mode 100644 lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt create mode 100644 lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt index 0f7a81eb4..4359db10a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchLyricScreen.kt @@ -69,6 +69,7 @@ data class SearchLyricScreen( @Composable override fun Content() { val state = SearchLyricPresenter.presentState() + val visible = remember { mutableStateOf(true) } LaunchedEffect(Unit) { if (state.mediaId != mediaId) { @@ -80,7 +81,8 @@ data class SearchLyricScreen( } RegisterContent( - isVisible = remember { mutableStateOf(true) }, + isVisible = { visible.value }, + onDismiss = { visible.value = false }, onBackPressed = null ) { SearchInputBar( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt index 475694134..1f9d8b5a3 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt @@ -55,8 +55,11 @@ object SearchScreen : Screen, TabScreen, ScreenBarFactory { @Composable override fun Content() { + val visible = remember { mutableStateOf(true) } + RegisterContent( - isVisible = remember { mutableStateOf(true) }, + isVisible = { visible.value }, + onDismiss = { visible.value = false }, onBackPressed = null, content = { SearchBar() } ) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index a24840845..1cb05e20e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -1,10 +1,10 @@ package com.lalilu.lmusic.compose.screen.songs import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R import com.lalilu.RemixIcon @@ -16,13 +16,14 @@ import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType import com.lalilu.component.base.songs.SongsHeaderJumperDialog -import com.lalilu.component.base.songs.SongsSM -import com.lalilu.component.base.songs.SongsScreenAction import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper -import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.component.extension.getViewModel +import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.lmusic.viewmodel.SongsAction +import com.lalilu.lmusic.viewmodel.SongsVM import com.lalilu.remixicon.Design import com.lalilu.remixicon.Editor import com.lalilu.remixicon.Media @@ -53,112 +54,118 @@ data class SongsScreen( } @Composable - override fun provideScreenActions(): List = remember { - listOf( - ScreenAction.Static( - title = { stringResource(id = R.string.screen_action_sort) }, - icon = { RemixIcon.Editor.sortDesc }, - color = { Color(0xFF1793FF) }, - onAction = { songsSM?.action(SongsScreenAction.ToggleSortPanel) } - ), - ScreenAction.Static( - title = { "选择" }, - icon = { RemixIcon.Design.editBoxLine }, - color = { Color(0xFF009673) }, - onAction = { songsSM?.selector?.isSelecting?.value = true } - ), - ScreenAction.Static( - title = { "搜索" }, - subTitle = { - val isSearching = songsSM?.searcher?.isSearching + override fun provideScreenActions(): List { + val vm = getViewModel() + val state by vm.state - if (isSearching?.value == true) "搜索中: ${songsSM?.searcher?.keywordState?.value}" - else null - }, - icon = { RemixIcon.System.menuSearchLine }, - color = { Color(0xFF8BC34A) }, - dotColor = { - val isSearching = songsSM?.searcher?.isSearching - - if (isSearching?.value == true) Color.Red - else null - }, - onAction = { - songsSM?.showSearcherPanel?.value = true - DialogWrapper.dismiss() - } - ), - ScreenAction.Static( - title = { stringResource(id = R.string.screen_action_locate_playing_item) }, - icon = { RemixIcon.Design.focus3Line }, - color = { Color(0xFF8700FF) }, - onAction = { songsSM?.action(SongsScreenAction.LocaleToPlayingItem) } - ), - ) + return remember { + listOf( + ScreenAction.Static( + title = { stringResource(id = R.string.screen_action_sort) }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, + onAction = { vm.intent(SongsAction.ToggleSortPanel) } + ), + ScreenAction.Static( + title = { "选择" }, + icon = { RemixIcon.Design.editBoxLine }, + color = { Color(0xFF009673) }, + onAction = { vm.selector.isSelecting.value = true } + ), + ScreenAction.Static( + title = { "搜索" }, + subTitle = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) "搜索中: $keyword" else null + }, + icon = { RemixIcon.System.menuSearchLine }, + color = { Color(0xFF8BC34A) }, + dotColor = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) Color.Red else null + }, + onAction = { + vm.intent(SongsAction.ToggleSearcherPanel) + DialogWrapper.dismiss() + } + ), + ScreenAction.Static( + title = { stringResource(id = R.string.screen_action_locate_playing_item) }, + icon = { RemixIcon.Design.focus3Line }, + color = { Color(0xFF8700FF) }, + onAction = { vm.intent(SongsAction.LocaleToPlayingItem) } + ), + ) + } } - @Transient - private var songsSM: SongsSM? = null - @Composable override fun Content() { - val sm = rememberScreenModel { SongsSM(mediaIds) } - .also { songsSM = it } + val vm = registerAndGetViewModel(parameters = { parametersOf(mediaIds) }) + val songs by vm.songs + val state by vm.state SongsSortPanelDialog( - isVisible = sm.showSortPanel, - supportSortActions = sm.supportSortActions, - isSortActionSelected = { sm.sorter.isSortActionSelected(it) }, - onSelectSortAction = { sm.sorter.selectSortAction(it) } + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(SongsAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(SongsAction.SelectSortAction(it)) } ) SongsHeaderJumperDialog( - isVisible = sm.showJumperDialog, - items = { sm.recorder.list().filterIsInstance() }, - onSelectItem = { sm.action(SongsScreenAction.LocaleToGroupItem(it)) } + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(SongsAction.HideJumperDialog) }, + items = { songs.keys }, + onSelectItem = { vm.intent(SongsAction.LocaleToGroupItem(it)) } ) SongsSearcherPanel( - isVisible = sm.showSearcherPanel, - keyword = { sm.searcher.keywordState.value }, - onUpdateKeyword = { sm.searcher.keywordState.value = it } + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(SongsAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(SongsAction.SearchFor(it)) } ) SongsSelectorPanel( - isVisible = sm.selector.isSelecting, + isVisible = { vm.selector.isSelecting.value }, + onDismiss = { vm.selector.isSelecting.value = false }, screenActions = listOfNotNull( ScreenAction.Static( title = { "全选" }, color = { Color(0xFF00ACF0) }, icon = { RemixIcon.System.checkboxMultipleLine }, onAction = { - val songs = sm.songs.value.values.flatten() - sm.selector.selectAll(songs) + val list = songs.values.flatten() + vm.selector.selectAll(list) } ), ScreenAction.Static( title = { "取消全选" }, icon = { RemixIcon.System.checkboxMultipleBlankLine }, color = { Color(0xFFFF5100) }, - onAction = { sm.selector.clear() } + onAction = { vm.selector.clear() } ), requestFor( qualifier = named("add_to_favourite_action"), - parameters = { parametersOf(sm.selector::selected) } + parameters = { parametersOf(vm.selector::selected) } ), requestFor( qualifier = named("add_to_playlist_action"), - parameters = { parametersOf(sm.selector::selected) } + parameters = { parametersOf(vm.selector::selected) } ) ) ) SongsScreenContent( - songsSM = sm, - isSelecting = { sm.selector.isSelecting.value }, - isSelected = { sm.selector.isSelected(it) }, - onSelect = { sm.selector.onSelect(it) }, - onClickGroup = { sm.showJumperDialog.value = true } + songs = songs, + recorder = vm.recorder, + eventFlow = vm.eventFlow(), + keys = { vm.recorder.list().filterNotNull() }, + isSelecting = { vm.selector.isSelecting.value }, + isSelected = { vm.selector.isSelected(it) }, + onSelect = { vm.selector.onSelect(it) }, + onClickGroup = { vm.intent(SongsAction.ToggleJumperDialog) } ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index 074e7a879..e0d80b6ac 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -1,5 +1,6 @@ package com.lalilu.lmusic.compose.screen.songs +import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -16,13 +17,13 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.gigamole.composefadingedges.FadingEdgesGravity @@ -30,23 +31,32 @@ import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig import com.gigamole.composefadingedges.fill.FadingEdgesFillType import com.gigamole.composefadingedges.verticalFadingEdges +import com.lalilu.common.base.SourceType import com.lalilu.component.base.smartBarPadding -import com.lalilu.component.base.songs.SongsSM -import com.lalilu.component.base.songs.SongsScreenEvent import com.lalilu.component.base.songs.SongsScreenScrollBar import com.lalilu.component.base.songs.SongsScreenStickyHeader import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.entity.FileInfo import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.entity.Metadata import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmusic.LMusicTheme +import com.lalilu.lmusic.viewmodel.SongsEvent import com.lalilu.lplayer.extensions.PlayerAction +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collectLatest @Composable internal fun SongsScreenContent( - songsSM: SongsSM, + recorder: ItemRecorder = ItemRecorder(), + eventFlow: SharedFlow = MutableSharedFlow(), + keys: () -> Collection = { emptyList() }, + songs: Map> = emptyMap(), isSelecting: () -> Boolean = { false }, isSelected: (LSong) -> Boolean = { false }, onSelect: (LSong) -> Unit = {}, @@ -56,16 +66,15 @@ internal fun SongsScreenContent( val hapticFeedback = LocalHapticFeedback.current val listState: LazyListState = rememberLazyListState() val statusBar = WindowInsets.statusBars - val songs by songsSM.songs val scroller = rememberLazyListAnimateScroller( listState = listState, - keys = { songsSM.recorder.list().filterNotNull() } + keys = keys ) LaunchedEffect(Unit) { - songsSM.event().collectLatest { event -> + eventFlow.collectLatest { event -> when (event) { - is SongsScreenEvent.ScrollToItem -> { + is SongsEvent.ScrollToItem -> { scroller.animateTo( key = event.key, isStickyHeader = { it.contentType == "group" }, @@ -113,7 +122,7 @@ internal fun SongsScreenContent( ), state = listState, ) { - startRecord(songsSM.recorder) { + startRecord(recorder) { itemWithRecord(key = "全部歌曲") { val count = remember(songs) { songs.values.flatten().size } @@ -190,4 +199,48 @@ internal fun SongsScreenContent( smartBarPadding() } } -} \ No newline at end of file +} + +@Preview(showBackground = true) +@Composable +private fun SongsScreenContentPreview(modifier: Modifier = Modifier) { + LMusicTheme { + SongsScreenContent( + songs = mapOf( + GroupIdentity.None to emptyList(), + GroupIdentity.FirstLetter("A") to buildList { + repeat(20) { add(testItem(it)) } + } + ) + ) + } +} + +private fun testItem(id: Int) = LSong( + id = "$id", + metadata = Metadata( + title = "Test", + album = "album", + artist = "artist", + albumArtist = "albumArtist", + composer = "composer", + lyricist = "lyricist", + comment = "comment", + genre = "genre", + track = "track", + disc = "disc", + date = "date", + duration = 100000, + dateAdded = 0, + dateModified = 0 + ), + fileInfo = FileInfo( + mimeType = "audio/mp3", + directoryPath = "directoryPath", + pathStr = "pathStr", + fileName = "fileName", + size = 1000 + ), + uri = Uri.EMPTY, + sourceType = SourceType.Local +) \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt new file mode 100644 index 000000000..586a82f01 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt @@ -0,0 +1,137 @@ +package com.lalilu.lmusic.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.LogUtils +import com.lalilu.common.MviWithIntent +import com.lalilu.common.ext.requestFor +import com.lalilu.common.mviImplWithIntent +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortDynamicAction +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lplayer.MPlayer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import org.koin.core.qualifier.named + + +data class SongsState( + // initialize values + val mediaIds: List = emptyList(), + + // control flags + val showSortPanel: Boolean = false, + val showJumperDialog: Boolean = false, + val showSearcherPanel: Boolean = false, + + // control params + val searchKeyWord: String = "", + val selectedSortAction: ListAction = SortStaticAction.Normal, +) { + @OptIn(ExperimentalCoroutinesApi::class) + fun getSongsFlow(): Flow>> { + val source = if (mediaIds.isEmpty()) LMedia.getFlow() + else LMedia.flowMapBy(mediaIds) + + val keywords: List = when { + searchKeyWord.isBlank() -> emptyList() + searchKeyWord.contains(' ') -> searchKeyWord.split(' ') + else -> listOf(searchKeyWord) + } + + val searchResult = source.mapLatest { flow -> + flow.filter { item -> keywords.all { item.getMatchStr().contains(it) } } + } + + return when (selectedSortAction) { + is SortStaticAction -> searchResult.mapLatest { + selectedSortAction.doSort(it, false) + } + + is SortDynamicAction -> selectedSortAction.doSort(searchResult, false) + else -> flowOf(emptyMap()) + } + } +} + +sealed interface SongsEvent { + data class ScrollToItem(val key: Any) : SongsEvent +} + +sealed interface SongsAction { + data object ToggleSortPanel : SongsAction + data object ToggleSearcherPanel : SongsAction + data object ToggleJumperDialog : SongsAction + + data object HideSortPanel : SongsAction + data object HideSearcherPanel : SongsAction + data object HideJumperDialog : SongsAction + + data object LocaleToPlayingItem : SongsAction + data class LocaleToGroupItem(val item: GroupIdentity) : SongsAction + data class SearchFor(val keyword: String) : SongsAction + data class SelectSortAction(val action: ListAction) : SongsAction +} + +@KoinViewModel +class SongsVM( + private val mediaIds: List, +) : ViewModel(), + MviWithIntent by mviImplWithIntent(SongsState(mediaIds)) { + val selector = ItemSelector() + val recorder = ItemRecorder() + + @OptIn(ExperimentalCoroutinesApi::class) + val songs = stateFlow().flatMapLatest { it.getSongsFlow() } + .toState(emptyMap(), viewModelScope) + val state = stateFlow().toState(SongsState(), viewModelScope) + + val supportSortActions: Set = + setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.AddTime, + SortStaticAction.Shuffle, + SortStaticAction.Duration, + requestFor(named("sort_rule_play_count")), + requestFor(named("sort_rule_last_play_time")), + ).filterNotNull() + .toSet() + + override fun intent(intent: SongsAction) = viewModelScope.launch { + when (intent) { + SongsAction.ToggleJumperDialog -> reduce { it.copy(showJumperDialog = !it.showJumperDialog) } + SongsAction.ToggleSearcherPanel -> reduce { it.copy(showSearcherPanel = !it.showSearcherPanel) } + SongsAction.ToggleSortPanel -> reduce { it.copy(showSortPanel = !it.showSortPanel) } + SongsAction.HideSortPanel -> reduce { it.copy(showSortPanel = false) } + SongsAction.HideSearcherPanel -> reduce { it.copy(showSearcherPanel = false) } + SongsAction.HideJumperDialog -> reduce { it.copy(showJumperDialog = false) } + is SongsAction.SearchFor -> reduce { it.copy(searchKeyWord = intent.keyword) } + is SongsAction.SelectSortAction -> reduce { it.copy(selectedSortAction = intent.action) } + is SongsAction.LocaleToGroupItem -> postEvent { SongsEvent.ScrollToItem(intent.item) } + is SongsAction.LocaleToPlayingItem -> { + val mediaId = MPlayer.currentMediaItem?.mediaId ?: run { + LogUtils.e("can not find playing item's mediaId") + return@launch + } + postEvent { SongsEvent.ScrollToItem(mediaId) } + } + + else -> { + LogUtils.i("Not implemented action: $intent") + } + } + } +} + diff --git a/common/src/main/java/com/lalilu/common/MVI.kt b/common/src/main/java/com/lalilu/common/MVI.kt new file mode 100644 index 000000000..b513d7578 --- /dev/null +++ b/common/src/main/java/com/lalilu/common/MVI.kt @@ -0,0 +1,51 @@ +package com.lalilu.common + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +interface Mvi { + suspend fun reduce(action: suspend (State) -> State) + suspend fun postEvent(event: Event) + suspend fun postEvent(action: suspend () -> Event) = postEvent(action()) + fun stateFlow(): StateFlow + fun eventFlow(): SharedFlow +} + +interface MviWithIntent : Mvi { + fun intent(intent: Intent): Any +} + +fun mviImpl( + defaultValue: State +): Mvi { + return object : Mvi { + private val stateFlow: MutableStateFlow = MutableStateFlow(defaultValue) + private val eventFlow: MutableSharedFlow = MutableSharedFlow() + + override suspend fun reduce(action: suspend (State) -> State) = + stateFlow.emit(action(stateFlow.value)) + + override suspend fun postEvent(event: Event) = eventFlow.emit(event) + override fun stateFlow(): StateFlow = stateFlow + override fun eventFlow(): SharedFlow = eventFlow + } +} + +fun mviImplWithIntent( + defaultValue: State +): MviWithIntent { + return object : MviWithIntent { + private val stateFlow: MutableStateFlow = MutableStateFlow(defaultValue) + private val eventFlow: MutableSharedFlow = MutableSharedFlow() + + override suspend fun reduce(action: suspend (State) -> State) = + stateFlow.emit(action(stateFlow.value)) + + override suspend fun postEvent(event: Event) = eventFlow.emit(event) + override fun stateFlow(): StateFlow = stateFlow + override fun eventFlow(): SharedFlow = eventFlow + override fun intent(intent: Intent): Any = Unit + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt index 800dd0008..6cc551f24 100644 --- a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt +++ b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt @@ -3,7 +3,6 @@ package com.lalilu.component.base import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -41,23 +40,9 @@ sealed interface ScreenAction { } data class ScreenBarComponent( - val state: MutableState, - val key: String = state.hashCode().toString(), + val key: String, val content: @Composable () -> Unit -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ScreenBarComponent - - return key == other.key - } - - override fun hashCode(): Int { - return key.hashCode() - } -} +) interface CustomScreen : Screen { fun getScreenInfo(): ScreenInfo? = null diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt index 889ad87fa..f2f899b23 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt @@ -3,7 +3,7 @@ package com.lalilu.component.base.screen import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.currentCompositeKeyHash import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -34,28 +34,31 @@ interface ScreenBarFactory { @Composable fun RegisterContent( - isVisible: MutableState, + isVisible: () -> Boolean, + onDismiss: () -> Unit, onBackPressed: (() -> Unit)?, content: @Composable () -> Unit ) { - LaunchedEffect(isVisible.value) { - if (isVisible.value) { + val key = currentCompositeKeyHash + + LaunchedEffect(isVisible()) { + if (isVisible()) { stack.stack += ScreenBarComponent( - state = isVisible, + key = key.toString(), content = { content.invoke() if (onBackPressed != null) { BackHandler { - isVisible.value = false + onDismiss() onBackPressed() } } } ) } else { - val key = isVisible.hashCode().toString() - stack.stack = stack.stack.filter { it.key != key } + stack.stack = stack.stack + .filter { it.key != key.toString() } } } } diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt index d0da4c533..128052ff1 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt @@ -18,7 +18,6 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,7 +32,8 @@ import com.lalilu.lmedia.extension.GroupIdentity @Composable fun SongsHeaderJumperDialog( - isVisible: MutableState, + isVisible: () -> Boolean, + onDismiss: () -> Unit, items: () -> Collection, onSelectItem: (item: GroupIdentity) -> Unit = {} ) { @@ -48,6 +48,7 @@ fun SongsHeaderJumperDialog( DialogWrapper.register( isVisible = isVisible, + onDismiss = onDismiss, dialogItem = dialog ) } diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt deleted file mode 100644 index b3278efb8..000000000 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSM.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.lalilu.component.base.songs - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope -import com.lalilu.common.base.BaseSp -import com.lalilu.common.ext.requestFor -import com.lalilu.component.extension.ItemRecorder -import com.lalilu.component.extension.ItemSelector -import com.lalilu.component.extension.toState -import com.lalilu.component.viewmodel.SongsSp -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmedia.extension.GroupIdentity -import com.lalilu.lmedia.extension.ListAction -import com.lalilu.lmedia.extension.Searchable -import com.lalilu.lmedia.extension.SortDynamicAction -import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lmedia.extension.Sortable -import com.lalilu.lplayer.MPlayer -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch -import org.koin.core.qualifier.named -import org.koin.java.KoinJavaComponent.inject - -sealed interface SongsScreenAction { - data object ToggleSortPanel : SongsScreenAction - data object LocaleToPlayingItem : SongsScreenAction - data class LocaleToGroupItem(val item: GroupIdentity) : SongsScreenAction - data class SearchFor(val keyword: String) : SongsScreenAction -} - -sealed interface SongsScreenEvent { - data class ScrollToItem(val key: Any) : SongsScreenEvent -} - -class SongsSM( - private val mediaIds: List, -) : ScreenModel { - // 持久化元素的状态 - val showSortPanel = mutableStateOf(false) - val showJumperDialog = mutableStateOf(false) - val showSearcherPanel = mutableStateOf(false) - val supportSortActions = setOf( - SortStaticAction.Normal, - SortStaticAction.Title, - SortStaticAction.AddTime, - SortStaticAction.Shuffle, - SortStaticAction.Duration, - requestFor(named("sort_rule_play_count")), - requestFor(named("sort_rule_last_play_time")), - ).filterNotNull() - .toSet() - - // 事件流 - private val eventFlow = MutableSharedFlow() - fun event(): SharedFlow = eventFlow - fun action(action: SongsScreenAction) = screenModelScope.launch { - when (action) { - SongsScreenAction.LocaleToPlayingItem -> { - val mediaId = MPlayer.currentMediaItem?.mediaId - ?: return@launch - - eventFlow.emit(SongsScreenEvent.ScrollToItem(mediaId)) - } - - SongsScreenAction.ToggleSortPanel -> { - showSortPanel.value = !showSortPanel.value - } - - is SongsScreenAction.SearchFor -> { - searcher.keywordState.value = action.keyword - } - - is SongsScreenAction.LocaleToGroupItem -> { - eventFlow.emit(SongsScreenEvent.ScrollToItem(action.item)) - } - - else -> {} - } - } - - // 数据流 - private fun flow(): Flow> { - return if (mediaIds.isEmpty()) LMedia.getFlow() - else LMedia.flowMapBy(mediaIds) - } - - val searcher = ItemSearcher(flow()) - val sorter = ItemSorter(searcher.output, supportSortActions) - val songs = sorter.output.toState( - defaultValue = emptyMap(), - scope = screenModelScope, - ) - val selector = ItemSelector() - val recorder = ItemRecorder() -} - -class ItemSearcher( - sourceFlow: Flow> -) { - val keywordState = mutableStateOf("") - private val keywordFlow = snapshotFlow { keywordState.value }.map { - when { - it.isBlank() -> emptyList() - it.contains(' ') -> it.split(' ') - else -> listOf(it) - } - } - - val output: Flow> = sourceFlow.combine(keywordFlow) { source, keywords -> - source.filter { item -> keywords.all { item.getMatchStr().contains(it) } } - } - - val isSearching: State - @Composable get() = remember { - derivedStateOf { keywordState.value.isNotBlank() } - } -} - -@OptIn(ExperimentalCoroutinesApi::class) -class ItemSorter( - sourceFlow: Flow>, - private val supportSortActions: Set, -) { - private val baseSp: BaseSp by inject(SongsSp::class.java) - private val sortActionKey = baseSp.obtain("SONGS_SORT_RULE_KEY", "") - - private val sortActionFlow = sortActionKey - .flow(true) - .mapLatest { key -> - supportSortActions.findInstance { it::class.java.name == key } - ?: SortStaticAction.Normal - } - - val output = sortActionFlow.flatMapLatest { action -> - when (action) { - is SortStaticAction -> sourceFlow.mapLatest { action.doSort(it, false) } - is SortDynamicAction -> action.doSort(sourceFlow, false) - else -> flowOf(emptyMap()) - } - } - - fun selectSortAction(action: ListAction) { - sortActionKey.value = action::class.java.name - } - - fun isSortActionSelected(action: ListAction): Boolean { - // 初次启动时若key值为空,则默认为Normal - if (sortActionKey.value.isBlank()) { - return action::class.java == SortStaticAction.Normal::class.java - } - - return sortActionKey.value == action::class.java.name - } -} - -inline fun Collection.findInstance(check: (T) -> Boolean): T? { - return this.filterIsInstance(T::class.java) - .firstOrNull(check) -} diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt index 85eb7bc30..d2cd7ee5f 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt @@ -26,7 +26,6 @@ import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,16 +48,21 @@ import com.lalilu.remixicon.system.closeLine @Composable fun ScreenBarFactory.SongsSearcherPanel( - isVisible: MutableState, + isVisible: () -> Boolean, + onDismiss: () -> Unit, keyword: () -> String, onUpdateKeyword: (String) -> Unit ) { - RegisterContent(isVisible = isVisible, onBackPressed = { }) { + RegisterContent( + isVisible = isVisible, + onDismiss = onDismiss, + onBackPressed = { } + ) { SongsSearcherPanelContent( modifier = Modifier, keyword = keyword, onUpdateKeyword = onUpdateKeyword, - onBackPress = { isVisible.value = false } + onBackPress = { onDismiss() } ) } } diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt index 3cd61eaef..51cdb2cfa 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt @@ -1,7 +1,6 @@ package com.lalilu.component.base.songs import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -16,14 +15,19 @@ import com.lalilu.remixicon.system.closeLine @Composable fun ScreenBarFactory.SongsSelectorPanel( - isVisible: MutableState, + isVisible: () -> Boolean, + onDismiss: () -> Unit, screenActions: List? = null, ) { - RegisterContent(isVisible = isVisible, onBackPressed = { }) { + RegisterContent( + isVisible = isVisible, + onDismiss = onDismiss, + onBackPressed = { } + ) { SongsSelectorPanelContent( modifier = Modifier, screenActions = screenActions, - onBackPress = { isVisible.value = false } + onBackPress = { onDismiss() } ) } } diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt index 1b9a7ed93..c9be229c9 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt @@ -20,7 +20,6 @@ import androidx.compose.material.SelectableChipColors import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,7 +43,8 @@ import com.lalilu.remixicon.system.closeLine @Composable fun SongsSortPanelDialog( - isVisible: MutableState, + isVisible: () -> Boolean, + onDismiss: () -> Unit, supportSortActions: Set, isSortActionSelected: (ListAction) -> Boolean = { false }, onSelectSortAction: (ListAction) -> Unit @@ -62,6 +62,7 @@ fun SongsSortPanelDialog( DialogWrapper.register( isVisible = isVisible, + onDismiss = onDismiss, dialogItem = dialog ) } diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt index fde8ced53..957c23de4 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt @@ -12,11 +12,20 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.contentColorFor import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration @@ -25,13 +34,22 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.lalilu.common.SystemUiUtil import com.lalilu.component.R import com.lalilu.component.base.LocalWindowSize import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import org.koin.compose.currentKoinScope import org.koin.compose.koinInject +import org.koin.core.parameter.ParametersDefinition +import org.koin.core.qualifier.Qualifier +import org.koin.core.scope.Scope +import org.koin.viewmodel.defaultExtras +import java.lang.ref.WeakReference import kotlin.math.roundToInt @Composable @@ -272,4 +290,48 @@ fun rememberIsPadLandScape(): State { @Composable inline fun singleViewModel(): T = - koinViewModel(viewModelStoreOwner = koinInject()) \ No newline at end of file + koinViewModel(viewModelStoreOwner = koinInject()) + +val registerMap = mutableMapOf, WeakReference>() + +@Composable +inline fun registerAndGetViewModel( + qualifier: Qualifier? = null, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + key: String? = null, + extras: CreationExtras = defaultExtras(viewModelStoreOwner), + scope: Scope = currentKoinScope(), + noinline parameters: ParametersDefinition? = null, +): T { + return koinViewModel( + qualifier = qualifier, + viewModelStoreOwner = viewModelStoreOwner, + key = key, + extras = extras, + scope = scope, + parameters = parameters + ).also { registerMap[T::class.java] = WeakReference(viewModelStoreOwner) } +} + +@Composable +inline fun getViewModel( + qualifier: Qualifier? = null, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(registerMap[T::class.java]?.get()) { + "No Registered ViewModelStoreOwner was provided via registerMap for ${T::class.java}" + }, + key: String? = null, + extras: CreationExtras = defaultExtras(viewModelStoreOwner), + scope: Scope = currentKoinScope(), + noinline parameters: ParametersDefinition? = null, +): T { + return koinViewModel( + qualifier = qualifier, + viewModelStoreOwner = viewModelStoreOwner, + key = key, + extras = extras, + scope = scope, + parameters = parameters + ) +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt index 26deb61bc..9dfdac2e0 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt @@ -398,7 +398,8 @@ private fun MoreActionPanelDialog( } DialogWrapper.register( - isVisible = isVisible, + isVisible = { isVisible.value }, + onDismiss = { isVisible.value = false }, dialogItem = dialog ) } diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt index 5936020cb..a1b5d4af4 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt @@ -1,10 +1,10 @@ package com.lalilu.lartist.screen import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import com.lalilu.RemixIcon @@ -20,10 +20,11 @@ import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.extension.getViewModel +import com.lalilu.component.extension.registerAndGetViewModel import com.lalilu.lartist.R -import com.lalilu.lartist.viewModel.ArtistDetailSM -import com.lalilu.lartist.viewModel.ArtistDetailScreenAction -import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lartist.viewModel.ArtistDetailAction +import com.lalilu.lartist.viewModel.ArtistDetailVM import com.lalilu.remixicon.Design import com.lalilu.remixicon.Editor import com.lalilu.remixicon.System @@ -43,7 +44,6 @@ data class ArtistDetailScreen( private val artistName: String ) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory, ScreenType.List { override val key: ScreenKey = "ARTIST_DETAIL_$artistName" - private var artistDetailSM: ArtistDetailSM? = null @Composable override fun provideScreenInfo(): ScreenInfo = remember { @@ -53,109 +53,117 @@ data class ArtistDetailScreen( } @Composable - override fun provideScreenActions(): List = remember { - listOf( - ScreenAction.Static( - title = { "排序" }, - icon = { RemixIcon.Editor.sortDesc }, - color = { Color(0xFF1793FF) }, - onAction = { artistDetailSM?.showSortPanel?.value = true } - ), - ScreenAction.Static( - title = { "选择" }, - icon = { RemixIcon.Design.editBoxLine }, - color = { Color(0xFF009673) }, - onAction = { artistDetailSM?.selector?.isSelecting?.value = true } - ), - ScreenAction.Static( - title = { "搜索" }, - subTitle = { - val isSearching = artistDetailSM?.searcher?.isSearching + override fun provideScreenActions(): List { + val vm = getViewModel() + val state by vm.state - if (isSearching?.value == true) "搜索中: ${artistDetailSM?.searcher?.keywordState?.value}" - else null - }, - icon = { RemixIcon.System.menuSearchLine }, - color = { Color(0xFF8BC34A) }, - dotColor = { - val isSearching = artistDetailSM?.searcher?.isSearching - - if (isSearching?.value == true) Color.Red - else null - }, - onAction = { - artistDetailSM?.showSearcherPanel?.value = true - DialogWrapper.dismiss() - } - ), - ScreenAction.Static( - title = { "定位至当前播放歌曲" }, - icon = { RemixIcon.Design.focus3Line }, - color = { Color(0xFF8700FF) }, - onAction = { artistDetailSM?.doAction(ArtistDetailScreenAction.LocaleToPlayingItem) } - ), - ) + return remember { + listOf( + ScreenAction.Static( + title = { "排序" }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, + onAction = { vm.intent(ArtistDetailAction.ToggleSortPanel) } + ), + ScreenAction.Static( + title = { "选择" }, + icon = { RemixIcon.Design.editBoxLine }, + color = { Color(0xFF009673) }, + onAction = { vm.selector.isSelecting.value = true } + ), + ScreenAction.Static( + title = { "搜索" }, + subTitle = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) "搜索中: $keyword" else null + }, + icon = { RemixIcon.System.menuSearchLine }, + color = { Color(0xFF8BC34A) }, + dotColor = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) Color.Red else null + }, + onAction = { + vm.intent(ArtistDetailAction.ToggleSearcherPanel) + DialogWrapper.dismiss() + } + ), + ScreenAction.Static( + title = { "定位至当前播放歌曲" }, + icon = { RemixIcon.Design.focus3Line }, + color = { Color(0xFF8700FF) }, + onAction = { vm.intent(ArtistDetailAction.LocaleToPlayingItem) } + ), + ) + } } @Composable override fun Content() { - val sm = rememberScreenModel { ArtistDetailSM(artistName) } - .also { artistDetailSM = it } + val vm = registerAndGetViewModel(parameters = { parametersOf(artistName) }) + val songs by vm.songs + val state by vm.state + val artist by vm.artist SongsSortPanelDialog( - isVisible = sm.showSortPanel, - supportSortActions = sm.supportSortActions, - isSortActionSelected = { sm.sorter.isSortActionSelected(it) }, - onSelectSortAction = { sm.sorter.selectSortAction(it) } + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(ArtistDetailAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(ArtistDetailAction.SelectSortAction(it)) } ) SongsHeaderJumperDialog( - isVisible = sm.showJumperDialog, - items = { sm.recorder.list().filterIsInstance() }, - onSelectItem = { sm.doAction(ArtistDetailScreenAction.LocaleToGroupItem(it)) } + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(ArtistDetailAction.HideJumperDialog) }, + items = { songs.keys }, + onSelectItem = { vm.intent(ArtistDetailAction.LocaleToGroupItem(it)) } ) SongsSearcherPanel( - isVisible = sm.showSearcherPanel, - keyword = { sm.searcher.keywordState.value }, - onUpdateKeyword = { sm.searcher.keywordState.value = it } + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(ArtistDetailAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(ArtistDetailAction.SearchFor(it)) } ) SongsSelectorPanel( - isVisible = sm.selector.isSelecting, + isVisible = { vm.selector.isSelecting.value }, + onDismiss = { vm.selector.isSelecting.value = false }, screenActions = listOfNotNull( ScreenAction.Static( title = { "全选" }, color = { Color(0xFF00ACF0) }, icon = { RemixIcon.System.checkboxMultipleLine }, - onAction = { - val songs = sm.songs.value.values.flatten() - sm.selector.selectAll(songs) - } + onAction = { vm.selector.selectAll(songs.values.flatten()) } ), ScreenAction.Static( title = { "取消全选" }, icon = { RemixIcon.System.checkboxMultipleBlankLine }, color = { Color(0xFFFF5100) }, - onAction = { sm.selector.clear() } + onAction = { vm.selector.clear() } ), requestFor( qualifier = named("add_to_favourite_action"), - parameters = { parametersOf(sm.selector::selected) } + parameters = { parametersOf(vm.selector::selected) } ), requestFor( qualifier = named("add_to_playlist_action"), - parameters = { parametersOf(sm.selector::selected) } + parameters = { parametersOf(vm.selector::selected) } ) ) ) ArtistDetailScreenContent( - artistDetailSM = sm, - isSelecting = { sm.selector.isSelecting.value }, - isSelected = { sm.selector.isSelected(it) }, - onSelect = { sm.selector.onSelect(it) }, - onClickGroup = { sm.showJumperDialog.value = true } + songs = songs, + artist = artist, + recorder = vm.recorder, + eventFlow = vm.eventFlow(), + keys = { vm.recorder.list().filterNotNull() }, + isSelecting = { vm.selector.isSelecting.value }, + isSelected = { vm.selector.isSelected(it) }, + onSelect = { vm.selector.onSelect(it) }, + onClickGroup = { vm.intent(ArtistDetailAction.ToggleJumperDialog) } ) } } diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt index 7b8d26d6a..c340f68cf 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,23 +29,32 @@ import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig import com.gigamole.composefadingedges.fill.FadingEdgesFillType import com.gigamole.composefadingedges.verticalFadingEdges +import com.lalilu.component.base.smartBarPadding import com.lalilu.component.base.songs.SongsScreenStickyHeader import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent import com.lalilu.lartist.component.ArtistCard -import com.lalilu.lartist.viewModel.ArtistDetailSM +import com.lalilu.lartist.viewModel.ArtistDetailEvent import com.lalilu.lmedia.entity.LArtist import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.emptyFlow @Composable internal fun ArtistDetailScreenContent( - artistDetailSM: ArtistDetailSM, + artist: LArtist? = null, + songs: Map> = emptyMap(), + eventFlow: Flow = emptyFlow(), + keys: () -> Collection = { emptyList() }, + recorder: ItemRecorder = ItemRecorder(), isSelecting: () -> Boolean = { false }, isSelected: (LSong) -> Boolean = { false }, onSelect: (LSong) -> Unit = {}, @@ -58,21 +67,45 @@ internal fun ArtistDetailScreenContent( val hapticFeedback = LocalHapticFeedback.current val scroller = rememberLazyListAnimateScroller( listState = listState, - keys = { artistDetailSM.recorder.list().filterNotNull() } + keys = keys ) - val artist by artistDetailSM.artist - val songs by artistDetailSM.songs - val relateArtist = remember(artist) { artist?.songs?.map { it.artists } ?.flatten() ?.toSet() - ?.filter { it.id != artist!!.name } + ?.filter { it.id != artist.name } ?.toList() ?: emptyList() } + LaunchedEffect(Unit) { + eventFlow.collectLatest { event -> + when (event) { + is ArtistDetailEvent.ScrollToItem -> { + scroller.animateTo( + key = event.key, + isStickyHeader = { it.contentType == "group" }, + offset = { item -> + // 若是 sticky header,则滚动到顶部 + if (item.contentType == "group") { + return@animateTo -statusBar.getTop(density) + } + + val closestStickyHeaderSize = listState.layoutInfo.visibleItemsInfo + .lastOrNull { it.index < item.index && it.contentType == "group" } + ?.size ?: 0 + + -(statusBar.getTop(density) + closestStickyHeaderSize) + } + ) + } + + else -> {} + } + } + } + LazyColumn( modifier = Modifier .fillMaxSize() @@ -95,7 +128,7 @@ internal fun ArtistDetailScreenContent( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.Start ) { - startRecord(artistDetailSM.recorder) { + startRecord(recorder) { itemWithRecord(key = "HEADER") { Column( modifier = Modifier @@ -203,5 +236,7 @@ internal fun ArtistDetailScreenContent( } } } + + smartBarPadding() } } \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt index 8f902d343..4786bf533 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt @@ -1,10 +1,10 @@ package com.lalilu.lartist.screen.artists import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ToastUtils import com.lalilu.RemixIcon @@ -18,10 +18,11 @@ import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.extension.getViewModel +import com.lalilu.component.extension.registerAndGetViewModel import com.lalilu.lartist.R -import com.lalilu.lartist.viewModel.ArtistsSM -import com.lalilu.lartist.viewModel.ArtistsScreenAction -import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lartist.viewModel.ArtistsAction +import com.lalilu.lartist.viewModel.ArtistsVM import com.lalilu.remixicon.Design import com.lalilu.remixicon.Editor import com.lalilu.remixicon.System @@ -38,7 +39,6 @@ import com.zhangke.krouter.annotation.Destination @Destination("/pages/artists") object ArtistsScreen : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { private fun readResolve(): Any = ArtistsScreen - private var artistsSM: ArtistsSM? = null @Composable override fun provideScreenInfo(): ScreenInfo = remember { @@ -50,38 +50,37 @@ object ArtistsScreen : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBar @Composable override fun provideScreenActions(): List { + val vm = getViewModel() + val state by vm.state + return remember { listOf( ScreenAction.Static( title = { "排序" }, icon = { RemixIcon.Editor.sortDesc }, color = { Color(0xFF1793FF) }, - onAction = { artistsSM?.showSortPanel?.value = true } + onAction = { vm.intent(ArtistsAction.ToggleSortPanel) } ), ScreenAction.Static( title = { "选择" }, icon = { RemixIcon.Design.editBoxLine }, color = { Color(0xFF009673) }, - onAction = { artistsSM?.selector?.isSelecting?.value = true } + onAction = { vm.selector.isSelecting.value = true } ), ScreenAction.Static( title = { "搜索" }, subTitle = { - val isSearching = artistsSM?.searcher?.isSearching - - if (isSearching?.value == true) "搜索中: ${artistsSM?.searcher?.keywordState?.value}" - else null + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) "搜索中: $keyword" else null }, icon = { RemixIcon.System.menuSearchLine }, color = { Color(0xFF8BC34A) }, dotColor = { - val isSearching = artistsSM?.searcher?.isSearching - - if (isSearching?.value == true) Color.Red - else null + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) Color.Red else null }, onAction = { - artistsSM?.showSearcherPanel?.value = true + vm.intent(ArtistsAction.ToggleSearcherPanel) DialogWrapper.dismiss() } ), @@ -89,7 +88,7 @@ object ArtistsScreen : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBar title = { "定位当前播放所属" }, icon = { RemixIcon.Design.focus3Line }, color = { Color(0xFF8700FF) }, - onAction = { artistsSM?.doAction(ArtistsScreenAction.LocaleToPlayingItem) } + onAction = { vm.intent(ArtistsAction.LocaleToPlayingItem) } ), ) } @@ -97,45 +96,47 @@ object ArtistsScreen : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBar @Composable override fun Content() { - val sm = rememberScreenModel { ArtistsSM() } - .also { artistsSM = it } + val vm = registerAndGetViewModel() + val state by vm.state + val artists by vm.artists SongsSortPanelDialog( - isVisible = sm.showSortPanel, - supportSortActions = sm.supportSortActions, - isSortActionSelected = { sm.sorter.isSortActionSelected(it) }, - onSelectSortAction = { sm.sorter.selectSortAction(it) } + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(ArtistsAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(ArtistsAction.SelectSortAction(it)) } ) SongsHeaderJumperDialog( - isVisible = sm.showJumperDialog, - items = { sm.recorder.list().filterIsInstance() }, - onSelectItem = { sm.doAction(ArtistsScreenAction.LocaleToGroupItem(it)) } + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(ArtistsAction.HideJumperDialog) }, + items = { artists.keys }, + onSelectItem = { vm.intent(ArtistsAction.LocaleToGroupItem(it)) } ) SongsSearcherPanel( - isVisible = sm.showSearcherPanel, - keyword = { sm.searcher.keywordState.value }, - onUpdateKeyword = { sm.searcher.keywordState.value = it } + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(ArtistsAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(ArtistsAction.SearchFor(it)) } ) SongsSelectorPanel( - isVisible = sm.selector.isSelecting, + isVisible = { vm.selector.isSelecting.value }, + onDismiss = { vm.selector.isSelecting.value = false }, screenActions = listOfNotNull( ScreenAction.Static( title = { "全选" }, color = { Color(0xFF00ACF0) }, icon = { RemixIcon.System.checkboxMultipleLine }, - onAction = { - val artists = sm.artists.value.values.flatten() - sm.selector.selectAll(artists) - } + onAction = { vm.selector.selectAll(artists.values.flatten()) } ), ScreenAction.Static( title = { "取消全选" }, icon = { RemixIcon.System.checkboxMultipleBlankLine }, color = { Color(0xFFFF5100) }, - onAction = { sm.selector.clear() } + onAction = { vm.selector.clear() } ), ScreenAction.Static( title = { "添加到播放列表" }, @@ -150,11 +151,14 @@ object ArtistsScreen : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBar ) ArtistsScreenContent( - artistsSM = sm, - isSelecting = { sm.selector.isSelecting.value }, - isSelected = { sm.selector.isSelected(it) }, - onSelect = { sm.selector.onSelect(it) }, - onClickGroup = { sm.showJumperDialog.value = true } + artists = artists, + recorder = vm.recorder, + eventFlow = vm.eventFlow(), + keys = { vm.recorder.list().filterNotNull() }, + isSelecting = { vm.selector.isSelecting.value }, + isSelected = { vm.selector.isSelected(it) }, + onSelect = { vm.selector.onSelect(it) }, + onClickGroup = { vm.intent(ArtistsAction.ToggleJumperDialog) } ) } } \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt index cc31cd7a5..de2dfb24d 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt @@ -15,7 +15,6 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,47 +27,49 @@ import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig import com.gigamole.composefadingedges.fill.FadingEdgesFillType import com.gigamole.composefadingedges.verticalFadingEdges +import com.lalilu.component.base.smartBarPadding import com.lalilu.component.base.songs.SongsScreenScrollBar import com.lalilu.component.base.songs.SongsScreenStickyHeader +import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent -import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lartist.component.ArtistCard import com.lalilu.lartist.screen.ArtistDetailScreen -import com.lalilu.lartist.viewModel.ArtistsSM -import com.lalilu.lartist.viewModel.ArtistsScreenEvent +import com.lalilu.lartist.viewModel.ArtistsEvent import com.lalilu.lmedia.entity.LArtist import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.MPlayer +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import kotlinx.coroutines.flow.emptyFlow @Composable internal fun ArtistsScreenContent( - artistsSM: ArtistsSM, - playingVM: IPlayingViewModel = koinInject(), + artists: Map> = emptyMap(), + keys: () -> Collection = { emptyList() }, + recorder: ItemRecorder = ItemRecorder(), + eventFlow: Flow = emptyFlow(), isSelecting: () -> Boolean = { false }, isSelected: (LArtist) -> Boolean = { false }, onSelect: (LArtist) -> Unit = {}, onClickGroup: (GroupIdentity) -> Unit = {}, ) { - val artists by artistsSM.artists val listState = rememberLazyListState() val statusBar = WindowInsets.statusBars val density = LocalDensity.current val stickyHeaderContentType = remember { "group" } val scroller = rememberLazyListAnimateScroller( listState = listState, - keys = { artistsSM.recorder.list().filterNotNull() } + keys = keys ) LaunchedEffect(Unit) { - artistsSM.eventFlow.collectLatest { event -> + eventFlow.collectLatest { event -> when (event) { - is ArtistsScreenEvent.ScrollToItem -> { + is ArtistsEvent.ScrollToItem -> { scroller.animateTo( key = event.key, isStickyHeader = { it.contentType == stickyHeaderContentType }, @@ -118,7 +119,7 @@ internal fun ArtistsScreenContent( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.Start ) { - startRecord(artistsSM.recorder) { + startRecord(recorder) { itemWithRecord(key = "艺术家") { val count = remember(artists) { artists.values.flatten().size } @@ -185,6 +186,8 @@ internal fun ArtistsScreenContent( } } } + + smartBarPadding() } } } \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt deleted file mode 100644 index 837abe021..000000000 --- a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailSM.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.lalilu.lartist.viewModel - -import androidx.compose.runtime.mutableStateOf -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope -import com.lalilu.component.base.songs.ItemSearcher -import com.lalilu.component.base.songs.ItemSorter -import com.lalilu.component.extension.ItemRecorder -import com.lalilu.component.extension.ItemSelector -import com.lalilu.component.extension.toState -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LArtist -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmedia.extension.GroupIdentity -import com.lalilu.lmedia.extension.ListAction -import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lplayer.MPlayer -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -internal sealed interface ArtistDetailScreenAction { - data object LocaleToPlayingItem : ArtistDetailScreenAction - data class LocaleToGroupItem(val item: GroupIdentity) : ArtistDetailScreenAction -} - -internal sealed interface ArtistDetailScreenEvent { - data class ScrollToItem(val key: Any) : ArtistDetailScreenEvent -} - -internal class ArtistDetailSM( - private val artistName: String -) : ScreenModel { - // 持久化元素的状态 - val showSortPanel = mutableStateOf(false) - val showJumperDialog = mutableStateOf(false) - val showSearcherPanel = mutableStateOf(false) - val supportSortActions = setOf( - SortStaticAction.Normal, - SortStaticAction.Title, - SortStaticAction.ItemsCount, - SortStaticAction.Duration, - SortStaticAction.AddTime, - SortStaticAction.Shuffle, - ).filterNotNull() - .toSet() - - // 数据流 - private fun flow(): Flow = LMedia.getFlow(artistName) - val searcher = ItemSearcher(flow().map { it?.songs ?: emptyList() }) - val sorter = ItemSorter(searcher.output, supportSortActions) - - val artist = flow().toState( - defaultValue = null, - scope = screenModelScope - ) - val songs = sorter.output.toState( - defaultValue = emptyMap(), - scope = screenModelScope, - ) - - val selector = ItemSelector() - val recorder = ItemRecorder() - - private val _eventFlow = MutableSharedFlow() - val eventFlow: SharedFlow = _eventFlow - - fun doAction(action: ArtistDetailScreenAction) = screenModelScope.launch { - when (action) { - ArtistDetailScreenAction.LocaleToPlayingItem -> { - // 获取正在播放的元素ID - val mediaId = MPlayer.currentMediaItem?.mediaId - ?: return@launch - - // 获取该元素 - val item = LMedia.get(mediaId) - ?: return@launch - - // 获取该元素的所属分组ID - val artistsIds = item.artists - .map { it.id } - .takeIf { it.isNotEmpty() } - ?: return@launch - - // 获取第一个存在与列表中的元素的Index - val list = recorder.list() - artistsIds.firstOrNull { list.contains(it) }?.let { - _eventFlow.emit(ArtistDetailScreenEvent.ScrollToItem(it)) - } - } - - is ArtistDetailScreenAction.LocaleToGroupItem -> { - _eventFlow.emit(ArtistDetailScreenEvent.ScrollToItem(action.item)) - } - - else -> {} - } - } -} \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt new file mode 100644 index 000000000..617e83f27 --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt @@ -0,0 +1,148 @@ +package com.lalilu.lartist.viewModel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.LogUtils +import com.lalilu.common.MviWithIntent +import com.lalilu.common.ext.requestFor +import com.lalilu.common.mviImplWithIntent +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortDynamicAction +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lplayer.MPlayer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import org.koin.core.qualifier.named + + +data class ArtistDetailState( + val artistName: String, + + // control flags + val showSortPanel: Boolean = false, + val showJumperDialog: Boolean = false, + val showSearcherPanel: Boolean = false, + + // control params + val searchKeyWord: String = "", + val selectedSortAction: ListAction = SortStaticAction.Normal, +) { + fun getArtistFlow(): Flow { + return LMedia.getFlow(artistName) + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun getSongsFlow(): Flow>> { + val source = LMedia.getFlow(artistName) + .map { it?.songs ?: emptyList() } + + val keywords: List = when { + searchKeyWord.isBlank() -> emptyList() + searchKeyWord.contains(' ') -> searchKeyWord.split(' ') + else -> listOf(searchKeyWord) + } + + val searchResult = source.mapLatest { flow -> + flow.filter { item -> keywords.all { item.getMatchStr().contains(it) } } + } + + return when (selectedSortAction) { + is SortStaticAction -> searchResult.mapLatest { + selectedSortAction.doSort(it, false) + } + + is SortDynamicAction -> selectedSortAction.doSort(searchResult, false) + else -> flowOf(emptyMap()) + } + } +} + +sealed interface ArtistDetailEvent { + data class ScrollToItem(val key: Any) : ArtistDetailEvent +} + +sealed interface ArtistDetailAction { + data object ToggleSortPanel : ArtistDetailAction + data object ToggleSearcherPanel : ArtistDetailAction + data object ToggleJumperDialog : ArtistDetailAction + + data object HideSortPanel : ArtistDetailAction + data object HideSearcherPanel : ArtistDetailAction + data object HideJumperDialog : ArtistDetailAction + + data object LocaleToPlayingItem : ArtistDetailAction + data class LocaleToGroupItem(val item: GroupIdentity) : ArtistDetailAction + data class SearchFor(val keyword: String) : ArtistDetailAction + data class SelectSortAction(val action: ListAction) : ArtistDetailAction +} + +@OptIn(ExperimentalCoroutinesApi::class) +@KoinViewModel +class ArtistDetailVM( + private val artistName: String, +) : ViewModel(), + MviWithIntent by + mviImplWithIntent(ArtistDetailState(artistName)) { + val selector = ItemSelector() + val recorder = ItemRecorder() + + val songs = stateFlow().flatMapLatest { it.getSongsFlow() }.toState(emptyMap(), viewModelScope) + val artist = stateFlow().flatMapLatest { it.getArtistFlow() }.toState(viewModelScope) + val state = stateFlow().toState(ArtistDetailState(artistName), viewModelScope) + + val supportSortActions: Set = + setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.AddTime, + SortStaticAction.Shuffle, + SortStaticAction.Duration, + requestFor(named("sort_rule_play_count")), + requestFor(named("sort_rule_last_play_time")), + ).filterNotNull() + .toSet() + + override fun intent(intent: ArtistDetailAction) = viewModelScope.launch { + when (intent) { + ArtistDetailAction.ToggleJumperDialog -> reduce { it.copy(showJumperDialog = !it.showJumperDialog) } + ArtistDetailAction.ToggleSearcherPanel -> reduce { it.copy(showSearcherPanel = !it.showSearcherPanel) } + ArtistDetailAction.ToggleSortPanel -> reduce { it.copy(showSortPanel = !it.showSortPanel) } + ArtistDetailAction.HideSortPanel -> reduce { it.copy(showSortPanel = false) } + ArtistDetailAction.HideSearcherPanel -> reduce { it.copy(showSearcherPanel = false) } + ArtistDetailAction.HideJumperDialog -> reduce { it.copy(showJumperDialog = false) } + is ArtistDetailAction.SearchFor -> reduce { it.copy(searchKeyWord = intent.keyword) } + is ArtistDetailAction.SelectSortAction -> reduce { it.copy(selectedSortAction = intent.action) } + is ArtistDetailAction.LocaleToGroupItem -> postEvent { + ArtistDetailEvent.ScrollToItem( + intent.item + ) + } + + is ArtistDetailAction.LocaleToPlayingItem -> { + val mediaId = MPlayer.currentMediaItem?.mediaId ?: run { + LogUtils.e("can not find playing item's mediaId") + return@launch + } + postEvent { ArtistDetailEvent.ScrollToItem(mediaId) } + } + + else -> { + LogUtils.i("Not implemented action: $intent") + } + } + } +} + diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt deleted file mode 100644 index 294731395..000000000 --- a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsSM.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.lalilu.lartist.viewModel - -import androidx.compose.runtime.mutableStateOf -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope -import com.lalilu.component.base.songs.ItemSearcher -import com.lalilu.component.base.songs.ItemSorter -import com.lalilu.component.extension.ItemRecorder -import com.lalilu.component.extension.ItemSelector -import com.lalilu.component.extension.toState -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LArtist -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmedia.extension.GroupIdentity -import com.lalilu.lmedia.extension.ListAction -import com.lalilu.lmedia.extension.SortStaticAction -import com.lalilu.lplayer.MPlayer -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch - -internal sealed interface ArtistsScreenAction { - data object LocaleToPlayingItem : ArtistsScreenAction - data class LocaleToGroupItem(val item: GroupIdentity) : ArtistsScreenAction -} - -internal sealed interface ArtistsScreenEvent { - data class ScrollToItem(val key: Any) : ArtistsScreenEvent -} - -internal class ArtistsSM : ScreenModel { - // 持久化元素的状态 - val showSortPanel = mutableStateOf(false) - val showJumperDialog = mutableStateOf(false) - val showSearcherPanel = mutableStateOf(false) - val supportSortActions = setOf( - SortStaticAction.Normal, - SortStaticAction.Title, - SortStaticAction.ItemsCount, - SortStaticAction.Duration, - SortStaticAction.AddTime, - SortStaticAction.Shuffle, - ).filterNotNull() - .toSet() - - // 数据流 - private fun flow(): Flow> = LMedia.getFlow() - val searcher = ItemSearcher(flow()) - val sorter = ItemSorter(searcher.output, supportSortActions) - val artists = sorter.output.toState( - defaultValue = emptyMap(), - scope = screenModelScope, - ) - - val selector = ItemSelector() - val recorder = ItemRecorder() - - - private val _eventFlow = MutableSharedFlow() - val eventFlow: SharedFlow = _eventFlow - - fun doAction(action: ArtistsScreenAction) = screenModelScope.launch { - when (action) { - ArtistsScreenAction.LocaleToPlayingItem -> { - // 获取正在播放的元素ID - val mediaId = MPlayer.currentMediaItem?.mediaId - ?: return@launch - - // 获取该元素 - val item = LMedia.get(mediaId) - ?: return@launch - - // 获取该元素的所属分组ID - val artistsIds = item.artists - .map { it.id } - .takeIf { it.isNotEmpty() } - ?: return@launch - - // 获取第一个存在与列表中的元素的Index - val list = recorder.list() - artistsIds.firstOrNull { list.contains(it) }?.let { - _eventFlow.emit(ArtistsScreenEvent.ScrollToItem(it)) - } - } - - is ArtistsScreenAction.LocaleToGroupItem -> { - _eventFlow.emit(ArtistsScreenEvent.ScrollToItem(action.item)) - } - - else -> {} - } - } -} \ No newline at end of file diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt new file mode 100644 index 000000000..3cb626fe5 --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt @@ -0,0 +1,139 @@ +package com.lalilu.lartist.viewModel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.LogUtils +import com.lalilu.common.MviWithIntent +import com.lalilu.common.mviImplWithIntent +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortDynamicAction +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lplayer.MPlayer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel + +data class ArtistsState( + // control flags + val showSortPanel: Boolean = false, + val showJumperDialog: Boolean = false, + val showSearcherPanel: Boolean = false, + + // control params + val searchKeyWord: String = "", + val selectedSortAction: ListAction = SortStaticAction.Normal, +) { + @OptIn(ExperimentalCoroutinesApi::class) + fun getArtistsFlow(): Flow>> { + val source = LMedia.getFlow() + + val keywords: List = when { + searchKeyWord.isBlank() -> emptyList() + searchKeyWord.contains(' ') -> searchKeyWord.split(' ') + else -> listOf(searchKeyWord) + } + + val searchResult = source.mapLatest { flow -> + flow.filter { item -> keywords.all { item.getMatchStr().contains(it) } } + } + + return when (selectedSortAction) { + is SortStaticAction -> searchResult.mapLatest { + selectedSortAction.doSort(it, false) + } + + is SortDynamicAction -> selectedSortAction.doSort(searchResult, false) + else -> flowOf(emptyMap()) + } + } +} + +sealed interface ArtistsEvent { + data class ScrollToItem(val key: Any) : ArtistsEvent +} + +sealed interface ArtistsAction { + data object ToggleSortPanel : ArtistsAction + data object ToggleSearcherPanel : ArtistsAction + data object ToggleJumperDialog : ArtistsAction + + data object HideSortPanel : ArtistsAction + data object HideSearcherPanel : ArtistsAction + data object HideJumperDialog : ArtistsAction + + data object LocaleToPlayingItem : ArtistsAction + data class LocaleToGroupItem(val item: GroupIdentity) : ArtistsAction + data class SearchFor(val keyword: String) : ArtistsAction + data class SelectSortAction(val action: ListAction) : ArtistsAction +} + +@KoinViewModel +class ArtistsVM : ViewModel(), + MviWithIntent by mviImplWithIntent(ArtistsState()) { + val selector = ItemSelector() + val recorder = ItemRecorder() + + @OptIn(ExperimentalCoroutinesApi::class) + val artists = stateFlow().flatMapLatest { it.getArtistsFlow() } + .toState(emptyMap(), viewModelScope) + val state = stateFlow().toState(ArtistsState(), viewModelScope) + + val supportSortActions = setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.ItemsCount, + SortStaticAction.Duration, + SortStaticAction.AddTime, + SortStaticAction.Shuffle, + ).filterNotNull() + .toSet() + + override fun intent(intent: ArtistsAction) = viewModelScope.launch { + when (intent) { + ArtistsAction.ToggleJumperDialog -> reduce { it.copy(showJumperDialog = !it.showJumperDialog) } + ArtistsAction.ToggleSearcherPanel -> reduce { it.copy(showSearcherPanel = !it.showSearcherPanel) } + ArtistsAction.ToggleSortPanel -> reduce { it.copy(showSortPanel = !it.showSortPanel) } + ArtistsAction.HideSortPanel -> reduce { it.copy(showSortPanel = false) } + ArtistsAction.HideSearcherPanel -> reduce { it.copy(showSearcherPanel = false) } + ArtistsAction.HideJumperDialog -> reduce { it.copy(showJumperDialog = false) } + is ArtistsAction.SearchFor -> reduce { it.copy(searchKeyWord = intent.keyword) } + is ArtistsAction.SelectSortAction -> reduce { it.copy(selectedSortAction = intent.action) } + is ArtistsAction.LocaleToGroupItem -> postEvent { ArtistsEvent.ScrollToItem(intent.item) } + is ArtistsAction.LocaleToPlayingItem -> { + val mediaId = MPlayer.currentMediaItem?.mediaId ?: run { + LogUtils.e("can not find playing item's mediaId") + return@launch + } + + // 获取该元素 + val item = LMedia.get(mediaId) + ?: return@launch + + // 获取该元素的所属分组ID + val artistsIds = item.artists + .map { it.id } + .takeIf { it.isNotEmpty() } + ?: return@launch + + // 获取第一个存在与列表中的元素的Index + artistsIds.firstOrNull { recorder.list().contains(it) } + ?.let { postEvent { ArtistsEvent.ScrollToItem(mediaId) } } + } + + else -> { + LogUtils.i("Not implemented action: $intent") + } + } + } +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index 1f8ee1968..e146b0b76 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -126,7 +126,8 @@ private fun Screen.PlaylistScreen( if (this is ScreenBarFactory) { RegisterContent( - isVisible = selectHelper.isSelecting, + isVisible = { selectHelper.isSelecting.value }, + onDismiss = { selectHelper.isSelecting.value = false }, onBackPressed = { selectHelper.clear() } ) { Row( From cd49a1f4536c10c6b0d26cb95b2f06e05b24d0d0 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 1 Nov 2024 01:47:50 +0800 Subject: [PATCH 100/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E6=88=90AlbumsScreen=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 2 - .../main/java/com/lalilu/lmusic/LMusicApp.kt | 2 +- common/src/main/java/com/lalilu/common/MVI.kt | 5 + lalbum/build.gradle.kts | 1 + .../java/com/lalilu/lalbum/AlbumModule.kt | 13 +- .../com/lalilu/lalbum/screen/AlbumsScreen.kt | 229 ++++++------------ .../lalbum/screen/AlbumsScreenContent.kt | 128 ++++++++++ .../com/lalilu/lalbum/viewModel/AlbumsVM.kt | 118 +++++++++ .../lalbum/viewModel/AlbumsViewModel.kt | 20 -- 9 files changed, 334 insertions(+), 184 deletions(-) create mode 100644 lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt create mode 100644 lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt delete mode 100644 lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsViewModel.kt diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index 757172d12..c42298d0c 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -13,7 +13,6 @@ import coil3.util.DebugLogger import com.lalilu.R import com.lalilu.common.base.SourceType import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lalbum.viewModel.AlbumsViewModel import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.indexer.Filter import com.lalilu.lmedia.indexer.FilterGroup @@ -110,7 +109,6 @@ val ViewModelModule = module { viewModelOf(::PlayingViewModel) viewModel { get() } viewModelOf(::SearchViewModel) - viewModelOf(::AlbumsViewModel) viewModelOf(::SearchLyricViewModel) } diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 4665afaf3..788eadf5d 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -50,7 +50,7 @@ class LMusicApp : Application(), FilterProvider, ViewModelStoreOwner { HistoryModule.module, PlaylistModule2.module, ArtistModule.module, - AlbumModule, + AlbumModule.module, DictionaryModule, LMedia.module ) diff --git a/common/src/main/java/com/lalilu/common/MVI.kt b/common/src/main/java/com/lalilu/common/MVI.kt index b513d7578..c2ba05bd0 100644 --- a/common/src/main/java/com/lalilu/common/MVI.kt +++ b/common/src/main/java/com/lalilu/common/MVI.kt @@ -1,5 +1,6 @@ package com.lalilu.common +import androidx.compose.runtime.Stable import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -9,7 +10,11 @@ interface Mvi { suspend fun reduce(action: suspend (State) -> State) suspend fun postEvent(event: Event) suspend fun postEvent(action: suspend () -> Event) = postEvent(action()) + + @Stable fun stateFlow(): StateFlow + + @Stable fun eventFlow(): SharedFlow } diff --git a/lalbum/build.gradle.kts b/lalbum/build.gradle.kts index abd2a3566..ec88e16d6 100644 --- a/lalbum/build.gradle.kts +++ b/lalbum/build.gradle.kts @@ -36,4 +36,5 @@ composeCompiler { dependencies { implementation(project(":component")) + ksp(libs.koin.compiler) } \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/AlbumModule.kt b/lalbum/src/main/java/com/lalilu/lalbum/AlbumModule.kt index 46be6751a..84184f0b1 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/AlbumModule.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/AlbumModule.kt @@ -1,11 +1,8 @@ package com.lalilu.lalbum -import com.lalilu.lalbum.screen.AlbumDetailScreenModel -import com.lalilu.lalbum.screen.AlbumsScreenModel -import org.koin.core.module.dsl.factoryOf -import org.koin.dsl.module +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module -val AlbumModule = module { - factoryOf(::AlbumDetailScreenModel) - factoryOf(::AlbumsScreenModel) -} \ No newline at end of file +@Module +@ComponentScan("com.lalilu.lalbum") +object AlbumModule \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index 29283f157..5f5c23ca7 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -1,63 +1,39 @@ package com.lalilu.lalbum.screen -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Surface -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.koin.getScreenModel import com.lalilu.RemixIcon -import com.lalilu.component.base.LoadingScaffold -import com.lalilu.component.base.LocalSmartBarPadding -import com.lalilu.component.base.LocalWindowSize -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.collectAsLoadingState +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.component.viewmodel.SongsSp +import com.lalilu.component.base.songs.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSortPanelDialog +import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.extension.getViewModel +import com.lalilu.component.extension.registerAndGetViewModel import com.lalilu.lalbum.R -import com.lalilu.lalbum.component.AlbumCard -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LAlbum -import com.lalilu.lplayer.MPlayer +import com.lalilu.lalbum.viewModel.AlbumsAction +import com.lalilu.lalbum.viewModel.AlbumsVM +import com.lalilu.remixicon.Editor import com.lalilu.remixicon.Media +import com.lalilu.remixicon.System +import com.lalilu.remixicon.editor.formatClear +import com.lalilu.remixicon.editor.sortDesc +import com.lalilu.remixicon.editor.text import com.lalilu.remixicon.media.albumFill +import com.lalilu.remixicon.system.menuSearchLine import com.zhangke.krouter.annotation.Destination -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.launch -import org.koin.compose.koinInject @Destination("/pages/albums") data class AlbumsScreen( val albumsId: List = emptyList() -) : Screen, ScreenInfoFactory { +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { @Composable override fun provideScreenInfo(): ScreenInfo = remember { ScreenInfo( @@ -67,123 +43,70 @@ data class AlbumsScreen( } @Composable - override fun Content() { - val albumsSM = getScreenModel() + override fun provideScreenActions(): List { + val albumsVM = getViewModel() + val state by albumsVM.state - LaunchedEffect(Unit) { - albumsSM.updateAlbumsId(albumsId) + return remember { + listOf( + ScreenAction.Static( + title = { if (state.showText) "隐藏专辑名" else "显示专辑名" }, + icon = { if (state.showText) RemixIcon.Editor.text else RemixIcon.Editor.formatClear }, + onAction = { albumsVM.intent(AlbumsAction.ToggleShowText) } + ), + ScreenAction.Static( + title = { "排序" }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, + onAction = { albumsVM.intent(AlbumsAction.ToggleSortPanel) } + ), + ScreenAction.Static( + title = { "搜索" }, + subTitle = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) "搜索中: $keyword" else null + }, + icon = { RemixIcon.System.menuSearchLine }, + color = { Color(0xFF8BC34A) }, + dotColor = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) Color.Red else null + }, + onAction = { + albumsVM.intent(AlbumsAction.ToggleSearcherPanel) + DialogWrapper.dismiss() + } + ), + ) } - - AlbumsScreen( - albumsSM = albumsSM, - ) } -} - -@OptIn(ExperimentalCoroutinesApi::class) -class AlbumsScreenModel( - sp: SongsSp -) : ScreenModel { - private val albumsId = MutableStateFlow>(emptyList()) - val showTitle = sp.obtain("test") - val albums = albumsId.flatMapLatest { - if (it.isEmpty()) LMedia.getFlow() - else LMedia.flowMapBy(it) - } - - fun updateAlbumsId(albumsId: List) = screenModelScope.launch { - this@AlbumsScreenModel.albumsId.emit(albumsId) - } -} - -@Composable -private fun AlbumsScreen( - title: String = "全部专辑", - albumsSM: AlbumsScreenModel, - playingVM: IPlayingViewModel = koinInject(), -) { - val isPad = LocalWindowSize.current.widthSizeClass == WindowWidthSizeClass.Expanded - val albumsState = albumsSM.albums.collectAsLoadingState() - val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - LoadingScaffold( - targetState = albumsState - ) { albums -> - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(if (isPad) 3 else 2), - modifier = Modifier, - contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = statusBarPadding), - verticalItemSpacing = 10.dp, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - item(key = "Header", contentType = "Header") { - Surface(shape = RoundedCornerShape(5.dp)) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - NavigatorHeader( - title = title, - subTitle = "共 ${albums.size} 张专辑" - ) - } - } - } + @Composable + override fun Content() { + val vm = registerAndGetViewModel() + val state by vm.state + val albums by vm.albums - items( - items = albums, - key = { it.id }, - contentType = { LAlbum::class } - ) { item -> - AlbumCard( - album = { item }, - isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, - showTitle = { albumsSM.showTitle.value }, - onClick = { - AppRouter.intent( - NavIntent.Push( - AlbumDetailScreen(item.id) - ) - ) - } - ) - } + SongsSortPanelDialog( + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(AlbumsAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(AlbumsAction.SelectSortAction(it)) } + ) - item(span = StaggeredGridItemSpan.FullLine) { - val padding by LocalSmartBarPadding.current + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(AlbumsAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(AlbumsAction.SearchFor(it)) } + ) - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(padding.calculateBottomPadding() + 20.dp) - ) - } - } + AlbumsScreenContent( + eventFlow = vm.eventFlow(), + title = { "全部专辑" }, + albums = { albums }, + showText = { state.showText } + ) } - -// val scrollProgress = remember(gridState) { -// derivedStateOf { -// if (gridState.layoutInfo.totalItemsCount == 0) return@derivedStateOf 0f -// gridState.firstVisibleItemIndex / gridState.layoutInfo.totalItemsCount.toFloat() -// } -// } -// -//// LaunchedEffect(albumIdsText) { -//// albumsVM.updateByIds( -//// ids = albumIdsText.getIds(), -//// sortFor = sortFor, -//// supportSortRules = supportSortRules, -//// supportGroupRules = supportGroupRules, -//// supportOrderRules = supportOrderRules -//// ) -//// } -// -// SortPanelWrapper( -// sortFor = sortFor, -// showPanelState = showSortPanel, -// supportListAction = { emptyList() }, -// sp = koinInject() -// ) { -// } } \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt new file mode 100644 index 000000000..392527374 --- /dev/null +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt @@ -0,0 +1,128 @@ +package com.lalilu.lalbum.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.blankj.utilcode.util.LogUtils +import com.lalilu.component.base.LocalSmartBarPadding +import com.lalilu.component.base.LocalWindowSize +import com.lalilu.component.base.NavigatorHeader +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent +import com.lalilu.lalbum.component.AlbumCard +import com.lalilu.lalbum.viewModel.AlbumsEvent +import com.lalilu.lmedia.entity.LAlbum +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lplayer.MPlayer +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest + +@Composable +internal fun AlbumsScreenContent( + eventFlow: SharedFlow = MutableSharedFlow(), + title: () -> String = { "" }, + albums: () -> Map> = { emptyMap() }, + showText: () -> Boolean = { false }, +) { + val isPad = LocalWindowSize.current.widthSizeClass == WindowWidthSizeClass.Expanded + val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val gridState = rememberLazyStaggeredGridState() + + LaunchedEffect(Unit) { + eventFlow.collectLatest { event -> + when (event) { + is AlbumsEvent.ScrollToItem -> { + // TODO 待实现针对LazyVerticalStaggeredGrid的scroller + LogUtils.i("TODO 待实现针对LazyVerticalStaggeredGrid的scroller") + } + } + } + } + + LazyVerticalStaggeredGrid( + state = gridState, + columns = StaggeredGridCells.Fixed(if (isPad) 3 else 2), + modifier = Modifier, + contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = statusBarPadding), + verticalItemSpacing = 10.dp, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + item(key = "Header", contentType = "Header") { + Surface(shape = RoundedCornerShape(5.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + NavigatorHeader( + title = title(), + subTitle = "共 ${albums().size} 张专辑" + ) + } + } + } + + albums().forEach { (group, list) -> + if (group !is GroupIdentity.None) { + item( + key = group, + contentType = "group", + span = StaggeredGridItemSpan.FullLine + ) { + Text(group.text) + } + } + + items( + items = list, + key = { it.id }, + contentType = { LAlbum::class } + ) { item -> + AlbumCard( + album = { item }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, + showTitle = showText, + onClick = { + AppRouter.intent( + NavIntent.Push( + AlbumDetailScreen(item.id) + ) + ) + } + ) + } + } + + item(span = StaggeredGridItemSpan.FullLine) { + val padding by LocalSmartBarPadding.current + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(padding.calculateBottomPadding() + 20.dp) + ) + } + } +} \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt new file mode 100644 index 000000000..40ea8b21d --- /dev/null +++ b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt @@ -0,0 +1,118 @@ +package com.lalilu.lalbum.viewModel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lalilu.common.MviWithIntent +import com.lalilu.common.ext.requestFor +import com.lalilu.common.mviImplWithIntent +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LAlbum +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortDynamicAction +import com.lalilu.lmedia.extension.SortStaticAction +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import org.koin.core.qualifier.named + + +data class AlbumsState( + val albumIds: List = emptyList(), + + // control flags + val showText: Boolean = false, + val showSortPanel: Boolean = false, + val showSearcherPanel: Boolean = false, + + // control params + val searchKeyWord: String = "", + val selectedSortAction: ListAction = SortStaticAction.Normal, +) { + @OptIn(ExperimentalCoroutinesApi::class) + fun getAlbumsFlow(): Flow>> { + val source = LMedia.getFlow() + + val keywords: List = when { + searchKeyWord.isBlank() -> emptyList() + searchKeyWord.contains(' ') -> searchKeyWord.split(' ') + else -> listOf(searchKeyWord) + } + + val searchResult = source.mapLatest { flow -> + flow.filter { item -> keywords.all { item.getMatchStr().contains(it) } } + } + + return when (selectedSortAction) { + is SortStaticAction -> searchResult.mapLatest { + selectedSortAction.doSort(it, false) + } + + is SortDynamicAction -> selectedSortAction.doSort(searchResult, false) + else -> flowOf(emptyMap()) + } + } +} + +sealed interface AlbumsEvent { + data class ScrollToItem(val key: Any) : AlbumsEvent +} + +sealed interface AlbumsAction { + data object ToggleSortPanel : AlbumsAction + data object ToggleSearcherPanel : AlbumsAction + data object ToggleShowText : AlbumsAction + + data object HideSortPanel : AlbumsAction + data object HideSearcherPanel : AlbumsAction + data object HideShowText : AlbumsAction + + data object LocaleToPlayingItem : AlbumsAction + data class SearchFor(val keyword: String) : AlbumsAction + data class SelectSortAction(val action: ListAction) : AlbumsAction +} + +@KoinViewModel +class AlbumsVM( + val albumIds: List +) : ViewModel(), + MviWithIntent by mviImplWithIntent(AlbumsState(albumIds)) { + + @OptIn(ExperimentalCoroutinesApi::class) + val albums = stateFlow().flatMapLatest { it.getAlbumsFlow() } + .toState(emptyMap(), viewModelScope) + val state = stateFlow().toState(AlbumsState(), viewModelScope) + + val supportSortActions: Set = + setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.Shuffle, + SortStaticAction.Duration, + requestFor(named("sort_rule_play_count")), + requestFor(named("sort_rule_last_play_time")), + ).filterNotNull() + .toSet() + + override fun intent(intent: AlbumsAction): Any = viewModelScope.launch { + when (intent) { + AlbumsAction.HideSearcherPanel -> reduce { it.copy(showSearcherPanel = false) } + AlbumsAction.HideSortPanel -> reduce { it.copy(showSortPanel = false) } + AlbumsAction.HideShowText -> reduce { it.copy(showText = false) } + + AlbumsAction.ToggleSearcherPanel -> reduce { it.copy(showSearcherPanel = !it.showSearcherPanel) } + AlbumsAction.ToggleSortPanel -> reduce { it.copy(showSortPanel = !it.showSortPanel) } + AlbumsAction.ToggleShowText -> reduce { it.copy(showText = !it.showText) } + + is AlbumsAction.SearchFor -> reduce { it.copy(searchKeyWord = intent.keyword) } + is AlbumsAction.SelectSortAction -> reduce { it.copy(selectedSortAction = intent.action) } + + AlbumsAction.LocaleToPlayingItem -> {} + } + } +} \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsViewModel.kt b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsViewModel.kt deleted file mode 100644 index 868e70edc..000000000 --- a/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsViewModel.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.lalilu.lalbum.viewModel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.lalilu.component.extension.toState -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LAlbum -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine - -class AlbumsViewModel : ViewModel() { - private val albumIds = MutableStateFlow>(emptyList()) - private val albumSource = LMedia.getFlow().combine(albumIds) { albums, ids -> - if (ids.isEmpty()) return@combine albums - albums.filter { album -> album.id in ids } - } - - val albums = albumSource - .toState(emptyList(), viewModelScope) -} \ No newline at end of file From 6d000e6892a9a519278ba669ff61f00185a06388 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 1 Nov 2024 02:02:02 +0800 Subject: [PATCH 101/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E6=88=90AlbumDetailScreen=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lalbum/screen/AlbumDetailScreen.kt | 209 +++++++++++------- .../lalbum/screen/AlbumDetailScreenContent.kt | 194 ++++++++++++++++ .../lalilu/lalbum/viewModel/AlbumDetailVM.kt | 149 +++++++++++++ 3 files changed, 476 insertions(+), 76 deletions(-) create mode 100644 lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt create mode 100644 lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt index 6faa9c1a3..fa0bc9250 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt @@ -1,31 +1,46 @@ package com.lalilu.lalbum.screen import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.base.collectAsLoadingState +import com.lalilu.RemixIcon +import com.lalilu.common.ext.requestFor +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.component.base.screen.ScreenType +import com.lalilu.component.base.songs.SongsHeaderJumperDialog +import com.lalilu.component.base.songs.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSelectorPanel +import com.lalilu.component.base.songs.SongsSortPanelDialog +import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.extension.getViewModel +import com.lalilu.component.extension.registerAndGetViewModel import com.lalilu.lalbum.R -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LAlbum +import com.lalilu.lalbum.viewModel.AlbumDetailAction +import com.lalilu.lalbum.viewModel.AlbumDetailVM +import com.lalilu.remixicon.Design +import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.System +import com.lalilu.remixicon.design.editBoxLine +import com.lalilu.remixicon.design.focus3Line +import com.lalilu.remixicon.editor.sortDesc +import com.lalilu.remixicon.system.checkboxMultipleBlankLine +import com.lalilu.remixicon.system.checkboxMultipleLine +import com.lalilu.remixicon.system.menuSearchLine import com.zhangke.krouter.annotation.Destination -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.launch +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named @Destination("/pages/albums/detail") data class AlbumDetailScreen( private val albumId: String -) : Screen, ScreenInfoFactory, ScreenType.List { +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { override val key: ScreenKey = "${super.key}:$albumId" @Composable @@ -36,75 +51,117 @@ data class AlbumDetailScreen( } @Composable - override fun Content() { - val albumDetailSM = getScreenModel() + override fun provideScreenActions(): List { + val vm = getViewModel() + val state by vm.state - LaunchedEffect(Unit) { - albumDetailSM.updateAlbumId(albumId) + return remember { + listOf( + ScreenAction.Static( + title = { "排序" }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, + onAction = { vm.intent(AlbumDetailAction.ToggleSortPanel) } + ), + ScreenAction.Static( + title = { "选择" }, + icon = { RemixIcon.Design.editBoxLine }, + color = { Color(0xFF009673) }, + onAction = { vm.selector.isSelecting.value = true } + ), + ScreenAction.Static( + title = { "搜索" }, + subTitle = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) "搜索中: $keyword" else null + }, + icon = { RemixIcon.System.menuSearchLine }, + color = { Color(0xFF8BC34A) }, + dotColor = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) Color.Red else null + }, + onAction = { + vm.intent(AlbumDetailAction.ToggleSearcherPanel) + DialogWrapper.dismiss() + } + ), + ScreenAction.Static( + title = { "定位至当前播放歌曲" }, + icon = { RemixIcon.Design.focus3Line }, + color = { Color(0xFF8700FF) }, + onAction = { vm.intent(AlbumDetailAction.LocaleToPlayingItem) } + ), + ) } - - AlbumDetail(albumDetailSM = albumDetailSM) } -} -@OptIn(ExperimentalCoroutinesApi::class) -class AlbumDetailScreenModel : ScreenModel { - private val albumId = MutableStateFlow(null) - val album = albumId.flatMapLatest { LMedia.getFlow(it) } + @Composable + override fun Content() { + val vm = registerAndGetViewModel(parameters = { parametersOf(albumId) }) + val songs by vm.songs + val state by vm.state + val album by vm.album - fun updateAlbumId(albumId: String) = screenModelScope.launch { - this@AlbumDetailScreenModel.albumId.emit(albumId) - } -} + SongsSortPanelDialog( + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(AlbumDetailAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(AlbumDetailAction.SelectSortAction(it)) } + ) -@Composable -private fun Screen.AlbumDetail( - albumDetailSM: AlbumDetailScreenModel -) { - val albumLoadingState = albumDetailSM.album.collectAsLoadingState() + SongsHeaderJumperDialog( + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(AlbumDetailAction.HideJumperDialog) }, + items = { songs.keys }, + onSelectItem = { vm.intent(AlbumDetailAction.LocaleToGroupItem(it)) } + ) + + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(AlbumDetailAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(AlbumDetailAction.SearchFor(it)) } + ) -// LoadingScaffold( -// modifier = Modifier.fillMaxSize(), -// targetState = albumLoadingState, -// onLoadErrorContent = { -// Box(modifier = Modifier.fillMaxSize()) { -// Text(text = "loading") -// } -// } -// ) { album -> -// Songs( -// modifier = Modifier.fillMaxSize(), -// mediaIds = album.songs.map { it.mediaId }, -// sortFor = "ALBUM_DETAIL", -// supportListAction = { emptyList() }, -// headerContent = { -// item { -// AlbumCoverCard( -// modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), -// shape = RoundedCornerShape(10.dp), -// elevation = 2.dp, -// imageData = { album }, -// onClick = { } -// ) -// } -// -// item { -// NavigatorHeader( -// title = album.name, -// subTitle = "共 ${it.value.values.flatten().size} 首歌曲,总时长 ${ -// album.requireItemsDuration().durationToTime() -// }" -// ) -// } -// } -// ) -// } -} + SongsSelectorPanel( + isVisible = { vm.selector.isSelecting.value }, + onDismiss = { vm.selector.isSelecting.value = false }, + screenActions = listOfNotNull( + ScreenAction.Static( + title = { "全选" }, + color = { Color(0xFF00ACF0) }, + icon = { RemixIcon.System.checkboxMultipleLine }, + onAction = { vm.selector.selectAll(songs.values.flatten()) } + ), + ScreenAction.Static( + title = { "取消全选" }, + icon = { RemixIcon.System.checkboxMultipleBlankLine }, + color = { Color(0xFFFF5100) }, + onAction = { vm.selector.clear() } + ), + requestFor( + qualifier = named("add_to_favourite_action"), + parameters = { parametersOf(vm.selector::selected) } + ), + requestFor( + qualifier = named("add_to_playlist_action"), + parameters = { parametersOf(vm.selector::selected) } + ) + ) + ) -fun Long.durationToTime(): String { - val hour = this / 3600000 - val minute = this / 60000 % 60 - val second = this / 1000 % 60 - return if (hour > 0L) "%02d:%02d:%02d".format(hour, minute, second) - else "%02d:%02d".format(minute, second) + AlbumDetailScreenContent( + songs = songs, + album = album, + recorder = vm.recorder, + eventFlow = vm.eventFlow(), + keys = { vm.recorder.list().filterNotNull() }, + isSelecting = { vm.selector.isSelecting.value }, + isSelected = { vm.selector.isSelected(it) }, + onSelect = { vm.selector.onSelect(it) }, + onClickGroup = { vm.intent(AlbumDetailAction.ToggleJumperDialog) } + ) + } } \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt new file mode 100644 index 000000000..3a31a6b85 --- /dev/null +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt @@ -0,0 +1,194 @@ +package com.lalilu.lalbum.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gigamole.composefadingedges.FadingEdgesGravity +import com.gigamole.composefadingedges.content.FadingEdgesContentType +import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig +import com.gigamole.composefadingedges.fill.FadingEdgesFillType +import com.gigamole.composefadingedges.verticalFadingEdges +import com.lalilu.component.base.smartBarPadding +import com.lalilu.component.base.songs.SongsScreenStickyHeader +import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.rememberLazyListAnimateScroller +import com.lalilu.component.extension.startRecord +import com.lalilu.component.navigation.AppRouter +import com.lalilu.lalbum.viewModel.AlbumDetailEvent +import com.lalilu.lmedia.entity.LAlbum +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lplayer.extensions.PlayerAction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.emptyFlow + +@Composable +fun AlbumDetailScreenContent( + album: LAlbum? = null, + songs: Map> = emptyMap(), + eventFlow: Flow = emptyFlow(), + keys: () -> Collection = { emptyList() }, + recorder: ItemRecorder = ItemRecorder(), + isSelecting: () -> Boolean = { false }, + isSelected: (LSong) -> Boolean = { false }, + onSelect: (LSong) -> Unit = {}, + onClickGroup: (GroupIdentity) -> Unit = {} +) { + val listState = rememberLazyListState() + val statusBar = WindowInsets.statusBars + val density = LocalDensity.current + val stickyHeaderContentType = remember { "group" } + val hapticFeedback = LocalHapticFeedback.current + val scroller = rememberLazyListAnimateScroller( + listState = listState, + keys = keys + ) + + LaunchedEffect(Unit) { + eventFlow.collectLatest { event -> + when (event) { + is AlbumDetailEvent.ScrollToItem -> { + scroller.animateTo( + key = event.key, + isStickyHeader = { it.contentType == "group" }, + offset = { item -> + // 若是 sticky header,则滚动到顶部 + if (item.contentType == "group") { + return@animateTo -statusBar.getTop(density) + } + + val closestStickyHeaderSize = listState.layoutInfo.visibleItemsInfo + .lastOrNull { it.index < item.index && it.contentType == "group" } + ?.size ?: 0 + + -(statusBar.getTop(density) + closestStickyHeaderSize) + } + ) + } + + else -> {} + } + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .verticalFadingEdges( + length = statusBar + .asPaddingValues() + .calculateTopPadding(), + contentType = FadingEdgesContentType.Dynamic.Lazy.List( + scrollConfig = FadingEdgesScrollConfig.Dynamic(), + state = listState + ), + gravity = FadingEdgesGravity.Start, + fillType = remember { + FadingEdgesFillType.FadeClip( + fillStops = Triple(0f, 0.7f, 1f) + ) + } + ), + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + startRecord(recorder) { + itemWithRecord(key = "HEADER") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = album?.name ?: "Unknown", + fontSize = 20.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + Text( + text = "共 ${album?.songs?.size ?: 0} 首歌曲", + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + } + + songs.forEach { (group, list) -> + if (group !is GroupIdentity.None) { + stickyHeaderWithRecord( + key = group, + contentType = stickyHeaderContentType + ) { + SongsScreenStickyHeader( + modifier = Modifier.animateItem(), + listState = listState, + group = group, + minOffset = { statusBar.getTop(density) }, + onClickGroup = onClickGroup + ) + } + } + + itemsWithRecord( + items = list, + key = { it.id }, + contentType = { it::class.java } + ) { + SongCard( + song = { it }, + isSelected = { isSelected(it) }, + onClick = { + if (isSelecting()) { + onSelect(it) + } else { + PlayerAction.PlayById(it.id).action() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + if (isSelecting()) { + onSelect(it) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() + } + }, + onEnterSelect = { onSelect(it) } + ) + } + } + } + + smartBarPadding() + } +} \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt new file mode 100644 index 000000000..1fce458f2 --- /dev/null +++ b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt @@ -0,0 +1,149 @@ +package com.lalilu.lalbum.viewModel + + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.LogUtils +import com.lalilu.common.MviWithIntent +import com.lalilu.common.ext.requestFor +import com.lalilu.common.mviImplWithIntent +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LAlbum +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortDynamicAction +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lplayer.MPlayer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import org.koin.core.qualifier.named + + +data class AlbumDetailState( + val albumId: String, + + // control flags + val showSortPanel: Boolean = false, + val showJumperDialog: Boolean = false, + val showSearcherPanel: Boolean = false, + + // control params + val searchKeyWord: String = "", + val selectedSortAction: ListAction = SortStaticAction.Normal, +) { + fun getAlbumFlow(): Flow { + return LMedia.getFlow(albumId) + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun getSongsFlow(): Flow>> { + val source = LMedia.getFlow(albumId) + .map { it?.songs ?: emptyList() } + + val keywords: List = when { + searchKeyWord.isBlank() -> emptyList() + searchKeyWord.contains(' ') -> searchKeyWord.split(' ') + else -> listOf(searchKeyWord) + } + + val searchResult = source.mapLatest { flow -> + flow.filter { item -> keywords.all { item.getMatchStr().contains(it) } } + } + + return when (selectedSortAction) { + is SortStaticAction -> searchResult.mapLatest { + selectedSortAction.doSort(it, false) + } + + is SortDynamicAction -> selectedSortAction.doSort(searchResult, false) + else -> flowOf(emptyMap()) + } + } +} + +sealed interface AlbumDetailEvent { + data class ScrollToItem(val key: Any) : AlbumDetailEvent +} + +sealed interface AlbumDetailAction { + data object ToggleSortPanel : AlbumDetailAction + data object ToggleSearcherPanel : AlbumDetailAction + data object ToggleJumperDialog : AlbumDetailAction + + data object HideSortPanel : AlbumDetailAction + data object HideSearcherPanel : AlbumDetailAction + data object HideJumperDialog : AlbumDetailAction + + data object LocaleToPlayingItem : AlbumDetailAction + data class LocaleToGroupItem(val item: GroupIdentity) : AlbumDetailAction + data class SearchFor(val keyword: String) : AlbumDetailAction + data class SelectSortAction(val action: ListAction) : AlbumDetailAction +} + +@OptIn(ExperimentalCoroutinesApi::class) +@KoinViewModel +class AlbumDetailVM( + private val albumId: String, +) : ViewModel(), + MviWithIntent by + mviImplWithIntent(AlbumDetailState(albumId)) { + val selector = ItemSelector() + val recorder = ItemRecorder() + + val songs = stateFlow().flatMapLatest { it.getSongsFlow() }.toState(emptyMap(), viewModelScope) + val album = stateFlow().flatMapLatest { it.getAlbumFlow() }.toState(viewModelScope) + val state = stateFlow().toState(AlbumDetailState(albumId), viewModelScope) + + val supportSortActions: Set = + setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.AddTime, + SortStaticAction.Shuffle, + SortStaticAction.Duration, + requestFor(named("sort_rule_play_count")), + requestFor(named("sort_rule_last_play_time")), + ).filterNotNull() + .toSet() + + override fun intent(intent: AlbumDetailAction) = viewModelScope.launch { + when (intent) { + AlbumDetailAction.ToggleJumperDialog -> reduce { it.copy(showJumperDialog = !it.showJumperDialog) } + AlbumDetailAction.ToggleSearcherPanel -> reduce { it.copy(showSearcherPanel = !it.showSearcherPanel) } + AlbumDetailAction.ToggleSortPanel -> reduce { it.copy(showSortPanel = !it.showSortPanel) } + AlbumDetailAction.HideSortPanel -> reduce { it.copy(showSortPanel = false) } + AlbumDetailAction.HideSearcherPanel -> reduce { it.copy(showSearcherPanel = false) } + AlbumDetailAction.HideJumperDialog -> reduce { it.copy(showJumperDialog = false) } + is AlbumDetailAction.SearchFor -> reduce { it.copy(searchKeyWord = intent.keyword) } + is AlbumDetailAction.SelectSortAction -> reduce { it.copy(selectedSortAction = intent.action) } + is AlbumDetailAction.LocaleToGroupItem -> postEvent { + AlbumDetailEvent.ScrollToItem( + intent.item + ) + } + + is AlbumDetailAction.LocaleToPlayingItem -> { + val mediaId = MPlayer.currentMediaItem?.mediaId ?: run { + LogUtils.e("can not find playing item's mediaId") + return@launch + } + postEvent { AlbumDetailEvent.ScrollToItem(mediaId) } + } + + else -> { + LogUtils.i("Not implemented action: $intent") + } + } + } +} + From 74c08a0183604a987cffbfe4f66dfdedc39a7c98 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 1 Nov 2024 02:23:24 +0800 Subject: [PATCH 102/213] =?UTF-8?q?[refactor]=E8=A7=A3=E5=86=B3state?= =?UTF-8?q?=E4=BB=BB=E4=B8=80=E5=8F=82=E6=95=B0=E5=8F=98=E5=8C=96=E9=83=BD?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E4=B8=8B=E6=B8=B8Flow=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E7=9B=91=E5=90=AC=E6=9F=90=E4=BA=9B=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lmusic/viewmodel/SongsVM.kt | 12 ++++++++++- .../lalbum/screen/AlbumsScreenContent.kt | 6 +++++- .../lalilu/lalbum/viewModel/AlbumDetailVM.kt | 20 ++++++++++++++++--- .../com/lalilu/lalbum/viewModel/AlbumsVM.kt | 16 ++++++++++++--- .../lartist/viewModel/ArtistDetailVM.kt | 20 +++++++++++++++---- .../com/lalilu/lartist/viewModel/ArtistsVM.kt | 12 ++++++++++- 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt index 586a82f01..cfbfd5ee1 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt @@ -1,5 +1,7 @@ package com.lalilu.lmusic.viewmodel +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.blankj.utilcode.util.LogUtils @@ -18,6 +20,7 @@ import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lplayer.MPlayer import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest @@ -26,6 +29,8 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.qualifier.named +@Stable +@Immutable data class SongsState( // initialize values val mediaIds: List = emptyList(), @@ -39,6 +44,9 @@ data class SongsState( val searchKeyWord: String = "", val selectedSortAction: ListAction = SortStaticAction.Normal, ) { + val distinctKey: Int = + mediaIds.hashCode() + searchKeyWord.hashCode() + selectedSortAction.hashCode() + @OptIn(ExperimentalCoroutinesApi::class) fun getSongsFlow(): Flow>> { val source = if (mediaIds.isEmpty()) LMedia.getFlow() @@ -93,7 +101,9 @@ class SongsVM( val recorder = ItemRecorder() @OptIn(ExperimentalCoroutinesApi::class) - val songs = stateFlow().flatMapLatest { it.getSongsFlow() } + val songs = stateFlow() + .distinctUntilChangedBy { it.distinctKey } + .flatMapLatest { it.getSongsFlow() } .toState(emptyMap(), viewModelScope) val state = stateFlow().toState(SongsState(), viewModelScope) diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt index 392527374..7d45f2e6b 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt @@ -91,7 +91,10 @@ internal fun AlbumsScreenContent( contentType = "group", span = StaggeredGridItemSpan.FullLine ) { - Text(group.text) + Text( + modifier = Modifier.animateItem(), + text = group.text + ) } } @@ -101,6 +104,7 @@ internal fun AlbumsScreenContent( contentType = { LAlbum::class } ) { item -> AlbumCard( + modifier = Modifier.animateItem(), album = { item }, isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, showTitle = showText, diff --git a/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt index 1fce458f2..b9694e440 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt @@ -1,6 +1,8 @@ package com.lalilu.lalbum.viewModel +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.blankj.utilcode.util.LogUtils @@ -20,6 +22,7 @@ import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lplayer.MPlayer import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -29,6 +32,8 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.qualifier.named +@Stable +@Immutable data class AlbumDetailState( val albumId: String, @@ -41,6 +46,9 @@ data class AlbumDetailState( val searchKeyWord: String = "", val selectedSortAction: ListAction = SortStaticAction.Normal, ) { + val distinctKey: Int = + albumId.hashCode() + searchKeyWord.hashCode() + selectedSortAction.hashCode() + fun getAlbumFlow(): Flow { return LMedia.getFlow(albumId) } @@ -100,9 +108,15 @@ class AlbumDetailVM( val selector = ItemSelector() val recorder = ItemRecorder() - val songs = stateFlow().flatMapLatest { it.getSongsFlow() }.toState(emptyMap(), viewModelScope) - val album = stateFlow().flatMapLatest { it.getAlbumFlow() }.toState(viewModelScope) - val state = stateFlow().toState(AlbumDetailState(albumId), viewModelScope) + val songs = stateFlow() + .distinctUntilChangedBy { it.distinctKey } + .flatMapLatest { it.getSongsFlow() } + .toState(emptyMap(), viewModelScope) + val album = stateFlow() + .flatMapLatest { it.getAlbumFlow() } + .toState(viewModelScope) + val state = stateFlow() + .toState(AlbumDetailState(albumId), viewModelScope) val supportSortActions: Set = setOf( diff --git a/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt index 40ea8b21d..694c33080 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt @@ -1,5 +1,7 @@ package com.lalilu.lalbum.viewModel +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lalilu.common.MviWithIntent @@ -14,6 +16,7 @@ import com.lalilu.lmedia.extension.SortDynamicAction import com.lalilu.lmedia.extension.SortStaticAction import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest @@ -21,7 +24,8 @@ import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import org.koin.core.qualifier.named - +@Stable +@Immutable data class AlbumsState( val albumIds: List = emptyList(), @@ -34,6 +38,9 @@ data class AlbumsState( val searchKeyWord: String = "", val selectedSortAction: ListAction = SortStaticAction.Normal, ) { + val distinctKey: Int = + albumIds.hashCode() + searchKeyWord.hashCode() + selectedSortAction.hashCode() + @OptIn(ExperimentalCoroutinesApi::class) fun getAlbumsFlow(): Flow>> { val source = LMedia.getFlow() @@ -84,9 +91,12 @@ class AlbumsVM( MviWithIntent by mviImplWithIntent(AlbumsState(albumIds)) { @OptIn(ExperimentalCoroutinesApi::class) - val albums = stateFlow().flatMapLatest { it.getAlbumsFlow() } + val albums = stateFlow() + .distinctUntilChangedBy { it.distinctKey } + .flatMapLatest { it.getAlbumsFlow() } .toState(emptyMap(), viewModelScope) - val state = stateFlow().toState(AlbumsState(), viewModelScope) + val state = stateFlow() + .toState(AlbumsState(), viewModelScope) val supportSortActions: Set = setOf( diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt index 617e83f27..6307936d5 100644 --- a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt @@ -1,5 +1,7 @@ package com.lalilu.lartist.viewModel +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.blankj.utilcode.util.LogUtils @@ -19,6 +21,7 @@ import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lplayer.MPlayer import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -27,7 +30,8 @@ import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import org.koin.core.qualifier.named - +@Stable +@Immutable data class ArtistDetailState( val artistName: String, @@ -40,6 +44,8 @@ data class ArtistDetailState( val searchKeyWord: String = "", val selectedSortAction: ListAction = SortStaticAction.Normal, ) { + val distinctKey: Int = searchKeyWord.hashCode() + selectedSortAction.hashCode() + fun getArtistFlow(): Flow { return LMedia.getFlow(artistName) } @@ -99,9 +105,15 @@ class ArtistDetailVM( val selector = ItemSelector() val recorder = ItemRecorder() - val songs = stateFlow().flatMapLatest { it.getSongsFlow() }.toState(emptyMap(), viewModelScope) - val artist = stateFlow().flatMapLatest { it.getArtistFlow() }.toState(viewModelScope) - val state = stateFlow().toState(ArtistDetailState(artistName), viewModelScope) + val songs = stateFlow() + .distinctUntilChangedBy { it.distinctKey } + .flatMapLatest { it.getSongsFlow() } + .toState(emptyMap(), viewModelScope) + val artist = stateFlow() + .flatMapLatest { it.getArtistFlow() } + .toState(viewModelScope) + val state = stateFlow() + .toState(ArtistDetailState(artistName), viewModelScope) val supportSortActions: Set = setOf( diff --git a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt index 3cb626fe5..c5ec379f9 100644 --- a/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt @@ -1,5 +1,7 @@ package com.lalilu.lartist.viewModel +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.blankj.utilcode.util.LogUtils @@ -18,12 +20,16 @@ import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lplayer.MPlayer import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel + +@Stable +@Immutable data class ArtistsState( // control flags val showSortPanel: Boolean = false, @@ -34,6 +40,8 @@ data class ArtistsState( val searchKeyWord: String = "", val selectedSortAction: ListAction = SortStaticAction.Normal, ) { + val distinctKey: Int = searchKeyWord.hashCode() + selectedSortAction.hashCode() + @OptIn(ExperimentalCoroutinesApi::class) fun getArtistsFlow(): Flow>> { val source = LMedia.getFlow() @@ -85,7 +93,9 @@ class ArtistsVM : ViewModel(), val recorder = ItemRecorder() @OptIn(ExperimentalCoroutinesApi::class) - val artists = stateFlow().flatMapLatest { it.getArtistsFlow() } + val artists = stateFlow() + .distinctUntilChangedBy { it.distinctKey } + .flatMapLatest { it.getArtistsFlow() } .toState(emptyMap(), viewModelScope) val state = stateFlow().toState(ArtistsState(), viewModelScope) From 964977efb94316d4c1a954f1830ec6c1fdb100b4 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 6 Nov 2024 02:45:55 +0800 Subject: [PATCH 103/213] =?UTF-8?q?[refactor]=E6=8B=86=E5=88=86Key?= =?UTF-8?q?=E7=9A=84=E6=98=A0=E5=B0=84=E9=80=BB=E8=BE=91=EF=BC=8C=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=E5=9B=A0size=E4=B8=8D=E5=90=8C=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E4=BB=8EmemoryCache=E4=B8=AD=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E8=8E=B7=E5=8F=96=E5=88=B0=E5=AF=B9=E5=BA=94=E5=85=83?= =?UTF-8?q?=E7=B4=A0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 10 ++++----- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 6 ++--- .../utils/coil/fetcher/LAlbumFetcher.kt | 2 +- .../lmusic/utils/coil/keyer/SongCoverKeyer.kt | 16 ++++++++++---- .../lalbum/screen/AlbumDetailScreenContent.kt | 22 ++++++++++++++++++- 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index c42298d0c..d4dfc3d96 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -28,9 +28,9 @@ import com.lalilu.lmusic.utils.coil.CrossfadeTransitionFactory import com.lalilu.lmusic.utils.coil.fetcher.LAlbumFetcher import com.lalilu.lmusic.utils.coil.fetcher.LSongFetcher import com.lalilu.lmusic.utils.coil.fetcher.MediaItemFetcher +import com.lalilu.lmusic.utils.coil.keyer.LAlbumCoverKeyer +import com.lalilu.lmusic.utils.coil.keyer.LSongCoverKeyer import com.lalilu.lmusic.utils.coil.keyer.MediaItemKeyer -import com.lalilu.lmusic.utils.coil.keyer.SongCoverKeyer -import com.lalilu.lmusic.utils.coil.mapper.LSongMapper import com.lalilu.lmusic.utils.extension.toBitmap import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchLyricViewModel @@ -64,12 +64,12 @@ fun provideImageLoaderFactory( ImageLoader.Builder(context) .components { add(OkHttpNetworkFetcherFactory(client)) - add(SongCoverKeyer()) - add(LSongMapper()) + add(LSongCoverKeyer()) + add(LAlbumCoverKeyer()) add(MediaItemKeyer()) - add(MediaItemFetcher.MediaItemFetcherFactory()) add(LSongFetcher.SongFactory()) add(LAlbumFetcher.AlbumFactory()) + add(MediaItemFetcher.MediaItemFetcherFactory()) } .transitionFactory(CrossfadeTransitionFactory()) .logger(DebugLogger()) diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 788eadf5d..c476b05ed 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -54,15 +54,15 @@ class LMusicApp : Application(), FilterProvider, ViewModelStoreOwner { DictionaryModule, LMedia.module ) + + SingletonImageLoader + .setSafe(KoinJavaComponent.get(SingletonImageLoader.Factory::class.java)) } } override fun onCreate() { super.onCreate() - SingletonImageLoader - .setSafe(KoinJavaComponent.get(SingletonImageLoader.Factory::class.java)) - LogUtils.getConfig() .setLog2FileSwitch(true) .setFileExtension(".log") diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LAlbumFetcher.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LAlbumFetcher.kt index 54948523d..75d2cc3ca 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LAlbumFetcher.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/fetcher/LAlbumFetcher.kt @@ -5,8 +5,8 @@ import coil3.decode.DataSource import coil3.decode.ImageSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher -import coil3.request.Options import coil3.fetch.SourceFetchResult +import coil3.request.Options import com.lalilu.lmedia.entity.LAlbum import okio.buffer import okio.source diff --git a/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt b/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt index ff8a708f5..6e72ad62f 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/coil/keyer/SongCoverKeyer.kt @@ -3,11 +3,19 @@ package com.lalilu.lmusic.utils.coil.keyer import androidx.media3.common.MediaItem import coil3.key.Keyer import coil3.request.Options -import com.lalilu.lmedia.entity.Item +import com.lalilu.lmedia.entity.LAlbum +import com.lalilu.lmedia.entity.LSong -class SongCoverKeyer : Keyer { - override fun key(data: Item, options: Options): String { - return "${data::class.simpleName}_${data.id}" + +class LSongCoverKeyer : Keyer { + override fun key(data: LSong, options: Options): String { + return "LSONG_${data.id}_${options.size.width}_${options.size.height}" + } +} + +class LAlbumCoverKeyer : Keyer { + override fun key(data: LAlbum, options: Options): String { + return "LALBUM_${data.id}_${options.size.width}_${options.size.height}" } } diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt index 3a31a6b85..f8651e32e 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt @@ -1,5 +1,6 @@ package com.lalilu.lalbum.screen +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -18,12 +20,16 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage import com.gigamole.composefadingedges.FadingEdgesGravity import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig @@ -123,8 +129,22 @@ fun AlbumDetailScreenContent( .fillMaxWidth() .padding(16.dp) .statusBarsPadding(), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = Color.White.copy(0.3f), + shape = RoundedCornerShape(8.dp) + ), + model = album, + contentScale = ContentScale.FillWidth, + contentDescription = "Album art" + ) + Text( text = album?.name ?: "Unknown", fontSize = 20.sp, From d4611650af6675e330dadb3d8986f9d713e3059b Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 12 Nov 2024 09:25:39 +0800 Subject: [PATCH 104/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E9=87=8D?= =?UTF-8?q?=E6=9E=84CustomAnchoredDraggableState=EF=BC=8C=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=97=A0=E5=85=B3Android=E5=B9=B3=E5=8F=B0=E7=9A=84?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=AE=9E=E7=8E=B0=E5=AF=B9=E5=BA=94=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/CustomAnchoredDraggableState.kt | 143 +++++++++++------- .../screen/playing/NestedScrollBaseLayout.kt | 21 +-- 2 files changed, 99 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt index 8a28f79a0..e3230fe2c 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt @@ -1,21 +1,27 @@ package com.lalilu.lmusic.compose.screen.playing -import android.content.Context -import android.widget.OverScroller +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.dynamicanimation.animation.SpringAnimation -import androidx.dynamicanimation.animation.SpringForce -import androidx.dynamicanimation.animation.springAnimationOf -import androidx.dynamicanimation.animation.withSpringForceProperties +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import kotlin.math.abs enum class DragAnchor { @@ -27,7 +33,7 @@ enum class DragAnchor { restore = { mutableStateOf(getByOrdinal(it)) } ) - fun getByOrdinal(ordinal: Int): DragAnchor { + private fun getByOrdinal(ordinal: Int): DragAnchor { return when (ordinal) { 0 -> Min 1 -> MinXMiddle @@ -41,34 +47,57 @@ enum class DragAnchor { } class CustomAnchoredDraggableState( - context: Context, + private val scope: CoroutineScope, + private val flingBehavior: FlingBehavior, private val initAnchor: () -> DragAnchor, - private val onStateChange: (DragAnchor, DragAnchor) -> Unit = { _, _ -> } -) { - private val animator: SpringAnimation by lazy { - springAnimationOf( - setter = { updatePosition(it) }, - getter = { position.floatValue }, - finalPosition = 0f - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - }.apply { - addEndListener { animation, canceled, value, velocity -> - - } - } - } + private val onStateChange: (DragAnchor, DragAnchor) -> Unit = { _, _ -> }, +) : ScrollableState { + private val animation by lazy { Animatable(0f, Float.VectorConverter) } private val dragThreshold = 120 - private val overScroller by lazy { OverScroller(context) } - private var minPosition = Int.MIN_VALUE private var middlePosition = Int.MIN_VALUE private var maxPosition = Int.MIN_VALUE - val position = mutableFloatStateOf(Float.MIN_VALUE) val state = mutableStateOf(initAnchor()) + private val scrollMutex = MutatorMutex() + private val isScrollingState = mutableStateOf(false) + private val isLastScrollForwardState = mutableStateOf(false) + private val isLastScrollBackwardState = mutableStateOf(false) + override val isScrollInProgress: Boolean + get() = isScrollingState.value + + private val scrollScope: ScrollScope = object : ScrollScope { + override fun scrollBy(pixels: Float): Float { + if (pixels.isNaN()) return 0f + val delta = dispatchRawDelta(pixels) + isLastScrollForwardState.value = delta > 0 + isLastScrollBackwardState.value = delta < 0 + return delta + } + } + + override fun dispatchRawDelta(delta: Float): Float { + val dyResult = dampDy(delta) + val oldPosition = position.floatValue + updatePosition(position.floatValue + dyResult) + return position.floatValue - oldPosition + } + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit + ) { + scrollMutex.mutateWith(scrollScope, scrollPriority) { + isScrollingState.value = true + try { + block() + } finally { + isScrollingState.value = false + } + } + } + private var oldStateValue: DragAnchor = initAnchor() set(value) { if (field == value) return @@ -175,29 +204,20 @@ class CustomAnchoredDraggableState( return if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress } - fun scrollBy(dy: Float): Float { - val dyResult = dampDy(dy) - val oldPosition = position.floatValue - updatePosition(position.floatValue + dyResult) - return position.floatValue - oldPosition - } - - fun fling(velocityY: Float): Float { + suspend fun fling(velocityY: Float): Float { if (velocityY == 0f) { animateToState() return velocityY } - overScroller.fling( - 0, position.floatValue.toInt(), - 0, velocityY.toInt(), - 0, 0, - minPosition, - maxPosition - ) + var velocityLeft = velocityY + scroll { velocityLeft = with(flingBehavior) { performFling(velocityY) } } - snapBy(overScroller.finalY) - return velocityY + if (velocityLeft == 0f) { + snapBy(position.floatValue.toInt()) + } + + return velocityLeft } fun snapBy(targetPosition: Int) { @@ -205,19 +225,18 @@ class CustomAnchoredDraggableState( DragAnchor.Middle -> { val position = calcSnapToPosition(targetPosition, minPosition, middlePosition, maxPosition) - animator.animateToFinalPosition(position.toFloat()) + scope.launch { doAnimateTo(position.toFloat()) } } DragAnchor.Min -> { - val position = - calcSnapToPosition(targetPosition, minPosition, middlePosition) - animator.animateToFinalPosition(position.toFloat()) + val position = calcSnapToPosition(targetPosition, minPosition, middlePosition) + scope.launch { doAnimateTo(position.toFloat()) } } DragAnchor.Max -> { val position = calcSnapToPosition(targetPosition, middlePosition, maxPosition) - animator.animateToFinalPosition(position.toFloat()) + scope.launch { doAnimateTo(position.toFloat()) } } else -> animateToState() @@ -226,12 +245,24 @@ class CustomAnchoredDraggableState( fun animateToState(newState: DragAnchor = stateValue) { val targetPosition = getSnapPositionByState(newState) - animator.animateToFinalPosition(targetPosition.toFloat()) + scope.launch { doAnimateTo(targetPosition.toFloat()) } } fun tryCancel() { - if (animator.isRunning) { - animator.cancel() + if (animation.isRunning) { + scope.launch { animation.stop() } + } + } + + suspend fun doAnimateTo(offset: Float) { + animation.apply { + snapTo(position.floatValue) + animateTo( + targetValue = offset, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) { + updatePosition(value) + } } } } @@ -239,14 +270,16 @@ class CustomAnchoredDraggableState( @Composable fun rememberCustomAnchoredDraggableState( - context: Context = LocalContext.current, onStateChange: (DragAnchor, DragAnchor) -> Unit = { _, _ -> } ): CustomAnchoredDraggableState { var initAnchor by rememberSaveable(saver = DragAnchor.Saver) { mutableStateOf(DragAnchor.Middle) } + val flingBehavior = ScrollableDefaults.flingBehavior() + val scope = rememberCoroutineScope() return remember { CustomAnchoredDraggableState( - context = context, + scope = scope, + flingBehavior = flingBehavior, initAnchor = { initAnchor }, onStateChange = { oldState, newState -> initAnchor = newState diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt index 06b6ef122..582afa26b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Velocity -import kotlinx.coroutines.CancellationException +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt @@ -45,7 +45,7 @@ fun NestedScrollBaseLayout( if ( !isLyricScrollEnable.value && available.y > 0 - && source == NestedScrollSource.Drag + && source == NestedScrollSource.UserInput && draggable.position.floatValue.toInt() == draggable.getPositionByAnchor(DragAnchor.Max) ) { @@ -56,8 +56,8 @@ fun NestedScrollBaseLayout( return if (isLyricScrollEnable.value) { super.onPreScroll(available, source) } else { - if (source == NestedScrollSource.Drag) { - draggable.scrollBy(available.y) + if (source == NestedScrollSource.UserInput) { + draggable.dispatchRawDelta(available.y) } available } @@ -71,7 +71,7 @@ fun NestedScrollBaseLayout( return if (isLyricScrollEnable.value) { super.onPreScroll(available, source) } else { - draggable.scrollBy(available.y) + draggable.dispatchRawDelta(available.y) available } } @@ -99,7 +99,7 @@ fun NestedScrollBaseLayout( draggable.tryCancel() if (available.y < 0f) { - return available.copy(y = draggable.scrollBy(available.y)) + return available.copy(y = draggable.dispatchRawDelta(available.y)) } return super.onPreScroll(available, source) @@ -111,19 +111,20 @@ fun NestedScrollBaseLayout( source: NestedScrollSource ): Offset { if (available.y > 0f) { - val consumedY = draggable.scrollBy(available.y) + val consumedY = draggable.dispatchRawDelta(available.y) - if (available.y - consumedY > 0.005f && source == NestedScrollSource.SideEffect) { + println("onPostScroll ${available.y} = $consumedY $source") + if ((available.y - consumedY) > 0.005f && source == NestedScrollSource.SideEffect) { throw CancellationException() } - return available + return available.copy(y = consumedY) } return super.onPostScroll(consumed, available, source) } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - draggable.fling(0f) + draggable.fling(available.y) return if (available.y > 0) { // 向下滑动的情况,消耗剩余的所有速度,避免剩余的速度传递给OverScroll From 387988826f8ca6e5e8e8181674b05a81871fe63c Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 12 Nov 2024 23:30:27 +0800 Subject: [PATCH 105/213] =?UTF-8?q?[refactor]=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=BB=9A=E5=8A=A8=E6=95=88=E6=9E=9C=E5=92=8C=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/CustomAnchoredDraggableState.kt | 80 ++++++++----- .../java/com/lalilu/component/OverScroller.kt | 111 ++++++++++++++++++ 2 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/OverScroller.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt index e3230fe2c..cf0cd9ef6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt @@ -4,11 +4,10 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.spring +import androidx.compose.animation.rememberSplineBasedDecay import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatorMutex -import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollScope -import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -20,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import com.lalilu.component.OverScroller import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlin.math.abs @@ -48,7 +48,7 @@ enum class DragAnchor { class CustomAnchoredDraggableState( private val scope: CoroutineScope, - private val flingBehavior: FlingBehavior, + private val overScroller: OverScroller, private val initAnchor: () -> DragAnchor, private val onStateChange: (DragAnchor, DragAnchor) -> Unit = { _, _ -> }, ) : ScrollableState { @@ -210,36 +210,48 @@ class CustomAnchoredDraggableState( return velocityY } - var velocityLeft = velocityY - scroll { velocityLeft = with(flingBehavior) { performFling(velocityY) } } + // 使用自定义的OverScroller进行Fling推算,获取终速 + val velocityLeft = overScroller.fling( + initialVelocity = velocityY, + startPosition = position.floatValue, + min = minPosition.toFloat(), + max = maxPosition.toFloat() + ) - if (velocityLeft == 0f) { - snapBy(position.floatValue.toInt()) - } + val targetPosition = calcSnapByTargetPosition( + targetPosition = overScroller.finalPosition.toInt() + ) + + doAnimateTo( + offset = targetPosition.toFloat(), + initialVelocity = velocityY + ) return velocityLeft } - fun snapBy(targetPosition: Int) { - when (stateValue) { - DragAnchor.Middle -> { - val position = - calcSnapToPosition(targetPosition, minPosition, middlePosition, maxPosition) - scope.launch { doAnimateTo(position.toFloat()) } - } - - DragAnchor.Min -> { - val position = calcSnapToPosition(targetPosition, minPosition, middlePosition) - scope.launch { doAnimateTo(position.toFloat()) } - } - - DragAnchor.Max -> { - val position = - calcSnapToPosition(targetPosition, middlePosition, maxPosition) - scope.launch { doAnimateTo(position.toFloat()) } - } - - else -> animateToState() + fun calcSnapByTargetPosition(targetPosition: Int): Int { + return when (stateValue) { + DragAnchor.Middle -> calcSnapToPosition( + targetPosition, + minPosition, + middlePosition, + maxPosition + ) + + DragAnchor.Min -> calcSnapToPosition( + targetPosition, + minPosition, + middlePosition + ) + + DragAnchor.Max -> calcSnapToPosition( + targetPosition, + middlePosition, + maxPosition + ) + + else -> getSnapPositionByState(stateValue) } } @@ -254,11 +266,15 @@ class CustomAnchoredDraggableState( } } - suspend fun doAnimateTo(offset: Float) { + suspend fun doAnimateTo( + offset: Float, + initialVelocity: Float = animation.velocity + ) { animation.apply { snapTo(position.floatValue) animateTo( targetValue = offset, + initialVelocity = initialVelocity, animationSpec = spring(stiffness = Spring.StiffnessLow) ) { updatePosition(value) @@ -267,19 +283,19 @@ class CustomAnchoredDraggableState( } } - @Composable fun rememberCustomAnchoredDraggableState( onStateChange: (DragAnchor, DragAnchor) -> Unit = { _, _ -> } ): CustomAnchoredDraggableState { var initAnchor by rememberSaveable(saver = DragAnchor.Saver) { mutableStateOf(DragAnchor.Middle) } - val flingBehavior = ScrollableDefaults.flingBehavior() + val flingSpec = rememberSplineBasedDecay() + val overScroller = remember { OverScroller(flingSpec) } val scope = rememberCoroutineScope() return remember { CustomAnchoredDraggableState( scope = scope, - flingBehavior = flingBehavior, + overScroller = overScroller, initAnchor = { initAnchor }, onStateChange = { oldState, newState -> initAnchor = newState diff --git a/component/src/main/java/com/lalilu/component/OverScroller.kt b/component/src/main/java/com/lalilu/component/OverScroller.kt new file mode 100644 index 000000000..99a41d9fa --- /dev/null +++ b/component/src/main/java/com/lalilu/component/OverScroller.kt @@ -0,0 +1,111 @@ +package com.lalilu.component + +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.animateDecay +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.ui.MotionDurationScale +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import kotlin.math.abs + +/** + * 用于计算滚动的最终位置和最终速度的实现 + */ +class OverScroller( + animationSpec: DecayAnimationSpec = exponentialDecay() +) { + private val flingBehavior = NoMotionFlingBehavior(animationSpec) + private var position: Float = 0f + private var minPosition: Float = 0f + private var maxPosition: Float = Float.MAX_VALUE + private val scrollScope = object : ScrollScope { + override fun scrollBy(pixels: Float): Float { + val oldPosition = position + position = (position + pixels).coerceIn(minPosition, maxPosition) + return position - oldPosition + } + } + + /** + * 滚动的最终位置,需要在调用[fling]函数后获取 + */ + val finalPosition: Float + get() = position + + /** + * fling实现 + * + * @param initialVelocity 初始速度 + * @param startPosition 起始位置 + * @param min 最小值 + * @param max 最大值 + * + * @return 到达边界时的最终速度,或未到达边界速度减至0 + */ + suspend fun fling( + initialVelocity: Float, + startPosition: Float = position, + min: Float = minPosition, + max: Float = maxPosition + ): Float { + position = startPosition + minPosition = min + maxPosition = max + + return with(flingBehavior) { + scrollScope.performFling(initialVelocity = initialVelocity) + } + } +} + +/** + * 默认的DefaultFlingBehavior的Copy, + * 替换了[motionDurationScale]为[DisableScrollMotionDurationScale] + */ +internal class NoMotionFlingBehavior( + private val flingDecay: DecayAnimationSpec, + private val motionDurationScale: MotionDurationScale = DisableScrollMotionDurationScale +) : FlingBehavior { + + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + return withContext(motionDurationScale) { + if (abs(initialVelocity) > 1f) { + var velocityLeft = initialVelocity + var lastValue = 0f + val animationState = + AnimationState( + initialValue = 0f, + initialVelocity = initialVelocity, + ) + try { + animationState.animateDecay(flingDecay) { + val delta = value - lastValue + val consumed = scrollBy(delta) + lastValue = value + velocityLeft = this.velocity + // avoid rounding errors and stop if anything is unconsumed + if (abs(delta - consumed) > 0.5f) this.cancelAnimation() + } + } catch (exception: CancellationException) { + velocityLeft = animationState.velocity + } + velocityLeft + } else { + initialVelocity + } + } + } +} + +/** + * [MotionDurationScale.scaleFactor]为0f时,动画会在下一帧内直接完成,而不会阻塞 + * 0f would cause motion to finish in the next frame callback. + */ +internal val DisableScrollMotionDurationScale = + object : MotionDurationScale { + override val scaleFactor: Float + get() = 0f + } From c78636a5f1cfa2a5f78202b759b2e97dfd51a0e4 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Wed, 13 Nov 2024 19:53:41 +0800 Subject: [PATCH 106/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E8=BF=9B=E5=BA=A6=E6=9D=A1=E7=9A=84=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/SeekbarLayout2.kt | 162 +++++++++++------- 1 file changed, 104 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt index ae0a3499f..5b0639f91 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures @@ -45,12 +44,15 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.blankj.utilcode.util.TimeUtils +import androidx.compose.ui.unit.sp import com.lalilu.lmusic.utils.extension.durationToTime import kotlinx.coroutines.launch @@ -72,7 +74,6 @@ sealed interface ClickPart { } @Preview -@OptIn(ExperimentalFoundationApi::class) @Composable fun SeekbarLayout2( modifier: Modifier = Modifier, @@ -132,22 +133,22 @@ fun SeekbarLayout2( val deltaY = offset.y val deltaX = offset.x + // 直接记录Y轴上的滚动距离 seekbarOffsetY.floatValue += deltaY - currentValue.floatValue = if (isCanceled.value) { - animateValue.value - } else { + + // 根据当前状态控制进度变量 + currentValue.floatValue = if (oldState == SeekbarVerticalState.ProgressBar) { (currentValue.floatValue + deltaX / boxSize.width * (maxValue() - minValue()) * scrollSensitivity) .coerceIn(minValue(), maxValue()) + } else { + animateValue.value } - when { - seekbarOffsetY.floatValue < -200f -> seekbarVerticalState.value = - SeekbarVerticalState.Dispatcher - - seekbarOffsetY.floatValue < -100f -> seekbarVerticalState.value = - SeekbarVerticalState.Cancel - - else -> seekbarVerticalState.value = SeekbarVerticalState.ProgressBar + // 根据Y轴滚动距离决定新的状态 + seekbarVerticalState.value = when { + seekbarOffsetY.floatValue < -200f -> SeekbarVerticalState.Dispatcher + seekbarOffsetY.floatValue < -100f -> SeekbarVerticalState.Cancel + else -> SeekbarVerticalState.ProgressBar } // 当状态发生变化的时候,进行震动 @@ -181,13 +182,12 @@ fun SeekbarLayout2( else -> {} } + // 若当前状态为Dispatcher,则将滚动的位移量向外分发 if (seekbarVerticalState.value == SeekbarVerticalState.Dispatcher) { onDispatchDragOffset(deltaY) } } - - Box( modifier = modifier .padding(bottom = 100.dp) @@ -259,6 +259,14 @@ fun SeekbarLayout2( animationSpec = spring(stiffness = Spring.StiffnessLow), label = "" ) + val textStyle = remember { + TextStyle.Default.copy( + fontSize = 16.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold, + baselineShift = BaselineShift.None + ) + } Box( modifier = Modifier @@ -273,73 +281,111 @@ fun SeekbarLayout2( val maxValueText = maxValue() .toLong() .durationToTime() - val currentValueTextResult = textMeasurer.measure(currentValueText) - val maxValueTextResult = textMeasurer.measure(maxValueText) + val currentValueTextResult = textMeasurer.measure( + text = currentValueText, + style = textStyle + ) + val maxValueTextResult = textMeasurer.measure( + text = maxValueText, + style = textStyle + ) - onDrawBehind { - val maxPadding = 4.dp.toPx() - val paddingAnimate = maxPadding * alpha.value - - val innerRadius = 16.dp.toPx() - paddingAnimate - val innerHeight = size.height - (paddingAnimate * 2f) - val innerWidth = size.width - (paddingAnimate * 2f) - - innerPath.reset() - innerPath.addRoundRect( - RoundRect( - rect = Rect( - offset = Offset(x = paddingAnimate, y = paddingAnimate), - size = Size(width = innerWidth, height = innerHeight) - ), - cornerRadius = CornerRadius(innerRadius, innerRadius) - ) + val maxPadding = 4.dp.toPx() + val paddingAnimate = maxPadding * alpha.value + + val innerRadius = 16.dp.toPx() - paddingAnimate + val innerHeight = size.height - (paddingAnimate * 2f) + val innerWidth = size.width - (paddingAnimate * 2f) + + val actualValue = animateValue.value + val actualProgress = actualValue.normalize(minValue(), maxValue()) + + val thumbWidth = innerWidth * actualProgress + val thumbHeight = innerHeight + + innerPath.reset() + innerPath.addRoundRect( + RoundRect( + rect = Rect( + offset = Offset(x = paddingAnimate, y = paddingAnimate), + size = Size(width = innerWidth, height = innerHeight) + ), + cornerRadius = CornerRadius(innerRadius, innerRadius) ) + ) + onDrawBehind { + // 纯色背景 drawRect(color = bgColor, alpha = alpha.value) + // 圆角裁切 clipPath(innerPath) { drawRect(color = Color(100, 100, 100, 50)) + onValueChange(actualValue) + // 绘制总时长文本(固定右侧) drawText( - textLayoutResult = currentValueTextResult, + textLayoutResult = maxValueTextResult, topLeft = Offset( - x = size.width - currentValueTextResult.size.width, - y = 0f - ) + x = size.width - maxValueTextResult.size.width - 16.dp.toPx(), + y = (size.height - maxValueTextResult.size.height) / 2f + ), + color = Color.White, ) - val actualValue = animateValue.value - val actualProgress = actualValue.normalize(minValue(), maxValue()) - onValueChange(actualValue) - + // 绘制滑块 drawRoundRect( color = animateColor(), cornerRadius = CornerRadius(innerRadius, innerRadius), topLeft = Offset(x = paddingAnimate, y = paddingAnimate), - size = Size( - width = innerWidth * actualProgress, - height = innerHeight - ) + size = Size(width = thumbWidth, height = thumbHeight) ) - drawRoundRect( - color = Color.White, - alpha = alpha.value, - cornerRadius = CornerRadius(50f), + // 绘制实时进度文本(移动) + drawText( + textLayoutResult = currentValueTextResult, topLeft = Offset( - x = innerWidth * actualProgress + paddingAnimate - 8.dp.toPx(), - y = (size.height - (innerHeight * 0.5f)) / 2f + x = (thumbWidth - 16.dp.toPx() - currentValueTextResult.size.width) + .coerceAtLeast(16.dp.toPx()), + y = (size.height - currentValueTextResult.size.height) / 2f ), - size = Size( - width = 4.dp.toPx(), - height = innerHeight * 0.5f - ) + color = Color.White, ) + +// // 绘制把手元素 +// drawRoundRect( +// color = Color.White, +// alpha = alpha.value, +// cornerRadius = CornerRadius(50f), +// topLeft = Offset( +// x = innerWidth * actualProgress + paddingAnimate - 8.dp.toPx(), +// y = (size.height - (innerHeight * 0.5f)) / 2f +// ), +// size = Size( +// width = 4.dp.toPx(), +// height = innerHeight * 0.5f +// ) +// ) } } } ) { - +// Row { +// Icon( +// imageVector = RemixIcon.Media.repeatFill, +// contentDescription = null +// ) +// +// Icon( +// imageVector = RemixIcon.Media.repeatFill, +// contentDescription = null +// ) +// +// Icon( +// imageVector = RemixIcon.Media.repeatFill, +// contentDescription = null +// ) +// } } } } From df3a72239c006b32261b8e7f80b8476c44de8543 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 14 Nov 2024 11:06:56 +0800 Subject: [PATCH 107/213] =?UTF-8?q?[refactor]=E8=B0=83=E6=95=B4=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=BF=9B=E5=BA=A6=E7=9A=84=E5=80=BC=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E6=95=88=E6=9E=9C=EF=BC=8C=E8=A7=A3=E5=86=B3=E6=96=87=E5=AD=97?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E6=97=B6=E7=9A=84=E6=8A=BD=E5=8A=A8=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/SeekbarLayout2.kt | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt index 5b0639f91..cd68b938b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt @@ -49,10 +49,12 @@ import androidx.compose.ui.text.drawText import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.lalilu.common.AccumulatedValue import com.lalilu.lmusic.utils.extension.durationToTime import kotlinx.coroutines.launch @@ -264,9 +266,25 @@ fun SeekbarLayout2( fontSize = 16.sp, lineHeight = 16.sp, fontWeight = FontWeight.Bold, - baselineShift = BaselineShift.None + baselineShift = BaselineShift.None, + textAlign = TextAlign.End, + color = Color.White ) } + val accumulator = remember { AccumulatedValue() } + val textSize = remember { + derivedStateOf { + // 获取最大时长,并使用0替换其内部的数字,计算其最大宽度 + val text = maxValue().toLong() + .durationToTime() + .replace(Regex("[0-9]"), "0") + val result = textMeasurer.measure( + text = text, + style = textStyle + ) + result.size.width to result.size.height + } + } Box( modifier = Modifier @@ -281,14 +299,6 @@ fun SeekbarLayout2( val maxValueText = maxValue() .toLong() .durationToTime() - val currentValueTextResult = textMeasurer.measure( - text = currentValueText, - style = textStyle - ) - val maxValueTextResult = textMeasurer.measure( - text = maxValueText, - style = textStyle - ) val maxPadding = 4.dp.toPx() val paddingAnimate = maxPadding * alpha.value @@ -325,12 +335,13 @@ fun SeekbarLayout2( // 绘制总时长文本(固定右侧) drawText( - textLayoutResult = maxValueTextResult, + textMeasurer = textMeasurer, + text = maxValueText, + style = textStyle, topLeft = Offset( - x = size.width - maxValueTextResult.size.width - 16.dp.toPx(), - y = (size.height - maxValueTextResult.size.height) / 2f - ), - color = Color.White, + x = size.width - textSize.value.first - 16.dp.toPx(), + y = (size.height - textSize.value.second) / 2f + ) ) // 绘制滑块 @@ -341,15 +352,19 @@ fun SeekbarLayout2( size = Size(width = thumbWidth, height = thumbHeight) ) + val textX = + (paddingAnimate + thumbWidth - 16.dp.toPx() - textSize.value.first) + .let { accumulator.accumulate(it) } + .coerceAtLeast(16.dp.roundToPx()) // 绘制实时进度文本(移动) drawText( - textLayoutResult = currentValueTextResult, + textMeasurer = textMeasurer, + text = currentValueText, + style = textStyle, topLeft = Offset( - x = (thumbWidth - 16.dp.toPx() - currentValueTextResult.size.width) - .coerceAtLeast(16.dp.toPx()), - y = (size.height - currentValueTextResult.size.height) / 2f - ), - color = Color.White, + x = textX.toFloat(), + y = (size.height - textSize.value.second) / 2f + ) ) // // 绘制把手元素 @@ -391,7 +406,7 @@ fun SeekbarLayout2( } @Composable -fun Modifier.combineDetectDrag( +private fun Modifier.combineDetectDrag( key: Any = Unit, onDragStart: (Offset) -> Unit = { }, onDragEnd: () -> Unit = { }, From 8a87a2907f158e59e3d1403cf6c2caadd97a543c Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 14 Nov 2024 20:34:40 +0800 Subject: [PATCH 108/213] =?UTF-8?q?[refactor]=E6=B7=BB=E5=8A=A0=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E9=9F=B3=E9=A2=91=E6=B5=81=E6=95=B0=E6=8D=AE=E7=9A=84?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E5=BE=85=E6=B7=BB=E5=8A=A0=E9=A2=91?= =?UTF-8?q?=E8=B0=B1=E5=A4=84=E7=90=86=E5=8F=AF=E8=A7=86=E5=8C=96=E7=9A=84?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extensions/FadeTransitionAudioSink.kt | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt index d5de826fb..86521dddc 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt @@ -5,10 +5,15 @@ import androidx.annotation.OptIn import androidx.dynamicanimation.animation.SpringForce import androidx.dynamicanimation.animation.springAnimationOf import androidx.dynamicanimation.animation.withSpringForceProperties +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.common.audio.AudioProcessorChain import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.audio.ForwardingAudioSink +import androidx.media3.exoplayer.audio.TeeAudioProcessor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -59,13 +64,34 @@ class FadeTransitionAudioSink( class FadeTransitionRenderersFactory( context: Context, val scope: CoroutineScope, -) : DefaultRenderersFactory(context) { + teeBufferListener: TeeAudioProcessor.AudioBufferSink? = null, +) : DefaultRenderersFactory(context), AudioProcessorChain { + + private val teeAudioProcessor = teeBufferListener + ?.let { TeeAudioProcessor(it) } + override fun buildAudioSink( context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean - ): AudioSink? { - return super.buildAudioSink(context, enableFloatOutput, enableAudioTrackPlaybackParams) - ?.let { FadeTransitionAudioSink(it, scope) } + ): AudioSink { + val defaultAudioSink = DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessorChain(this) + .build() + + return FadeTransitionAudioSink(defaultAudioSink, scope) } + + override fun getAudioProcessors(): Array { + return if (teeAudioProcessor != null) arrayOf(teeAudioProcessor) + else emptyArray() + } + + override fun getMediaDuration(playoutDuration: Long): Long = playoutDuration + override fun getSkippedOutputFrameCount(): Long = 0 + override fun applySkipSilenceEnabled(skipSilenceEnabled: Boolean): Boolean = skipSilenceEnabled + override fun applyPlaybackParameters(playbackParameters: PlaybackParameters): PlaybackParameters = + playbackParameters } \ No newline at end of file From b6f1957b4f0ac2f4e7a6171fe9f478f213ce516d Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 15 Nov 2024 09:32:54 +0800 Subject: [PATCH 109/213] =?UTF-8?q?[refactor]=E6=B7=BB=E5=8A=A0=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E7=BA=A0=E6=AD=A3Toolbar=E4=BD=8D=E7=BD=AE=E7=9A=84?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/playing/NestedScrollBaseLayout.kt | 1 - .../compose/screen/playing/PlayingLayout.kt | 36 ++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt index 582afa26b..4c2c5c38a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt @@ -113,7 +113,6 @@ fun NestedScrollBaseLayout( if (available.y > 0f) { val consumedY = draggable.dispatchRawDelta(available.y) - println("onPostScroll ${available.y} = $consumedY $source") if ((available.y - consumedY) > 0.005f && source == NestedScrollSource.SideEffect) { throw CancellationException() } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 389e92645..ac44be68f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -10,9 +10,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.rememberLazyListState @@ -33,6 +35,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp @@ -42,14 +45,12 @@ import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.lalilu.component.base.LocalEnhanceSheetState import com.lalilu.component.extension.hideControl -import com.lalilu.component.extension.singleViewModel import com.lalilu.lmedia.lyric.LyricItem import com.lalilu.lmedia.lyric.LyricSourceEmbedded import com.lalilu.lmedia.lyric.LyricUtils import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.Dispatchers @@ -60,21 +61,21 @@ import kotlin.math.pow @Composable fun PlayingLayout( - playingVM: PlayingViewModel = singleViewModel(), settingsSp: SettingsSp = koinInject(), ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current - val enhanceSheetState = LocalEnhanceSheetState.current val lifecycle = LocalLifecycleOwner.current + val enhanceSheetState = LocalEnhanceSheetState.current val systemUiController = rememberSystemUiController() - val lyricLayoutLazyListState = rememberLazyListState() + val listState = rememberLazyListState() val isLyricScrollEnable = remember { mutableStateOf(false) } val backgroundColor = remember { mutableStateOf(Color.DarkGray) } val animateColor = animateColorAsState(targetValue = backgroundColor.value, label = "") val scrollToTopEvent = remember { mutableStateOf(0L) } val seekbarTime = remember { mutableLongStateOf(0L) } + val currentPosition = remember { mutableFloatStateOf(0f) } val draggable = rememberCustomAnchoredDraggableState { oldState, newState -> if (newState == DragAnchor.MiddleXMax && oldState != DragAnchor.MiddleXMax) { @@ -95,8 +96,6 @@ fun PlayingLayout( systemUiController.isStatusBarVisible = !hideComponent.value } - val currentPosition = remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { while (isActive) { @@ -114,6 +113,18 @@ fun PlayingLayout( draggable = draggable, isLyricScrollEnable = isLyricScrollEnable, toolbarContent = { + val density = LocalDensity.current + val navigationBar = WindowInsets.navigationBars + val middleToMaxProgress = remember { + derivedStateOf { + draggable.progressBetween( + from = DragAnchor.Middle, + to = DragAnchor.Max, + offset = draggable.position.floatValue + ) + } + } + Column( modifier = Modifier .hideControl( @@ -123,6 +134,15 @@ fun PlayingLayout( .fillMaxWidth() .statusBarsPadding() .padding(bottom = 10.dp) + .graphicsLayer { + translationY = lerp( + start = 0f, + stop = -navigationBar + .getBottom(density) + .toFloat() + 10.dp.toPx(), + fraction = middleToMaxProgress.value + ) + } ) { PlayingToolbar( isItemPlaying = { mediaId -> MPlayer.isItemPlaying(mediaId) }, @@ -240,7 +260,7 @@ fun PlayingLayout( alpha = progressIncrease }, lyricEntry = lyrics, - listState = lyricLayoutLazyListState, + listState = listState, currentTime = { seekbarTime.longValue }, maxWidth = { constraints.maxWidth }, textSize = rememberTextSizeFromInt { settingsSp.lyricTextSize.value }, From 8b653fb517d98d905feb92736f967f5aeec76dbd Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Fri, 15 Nov 2024 17:10:18 +0800 Subject: [PATCH 110/213] =?UTF-8?q?[refactor]=E8=A7=A3=E5=86=B3=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E9=AB=98=E5=BA=A6=E5=8F=98=E5=8C=96=E6=97=B6=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=8D=E8=B7=9F=E9=9A=8F=E5=8F=98=E5=8C=96=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E4=BF=AE=E6=AD=A3=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=9A=84=E5=88=9D=E5=A7=8B=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/CustomAnchoredDraggableState.kt | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt index cf0cd9ef6..419f1faec 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt @@ -57,7 +57,7 @@ class CustomAnchoredDraggableState( private var minPosition = Int.MIN_VALUE private var middlePosition = Int.MIN_VALUE private var maxPosition = Int.MIN_VALUE - val position = mutableFloatStateOf(Float.MIN_VALUE) + val position = mutableFloatStateOf(Float.MAX_VALUE) val state = mutableStateOf(initAnchor()) private val scrollMutex = MutatorMutex() @@ -114,13 +114,32 @@ class CustomAnchoredDraggableState( } fun updateAnchor(min: Int, middle: Int, max: Int) { + val maxPositionChange = maxPosition != max + minPosition = min middlePosition = middle maxPosition = max - if (position.floatValue == Float.MIN_VALUE) { - val targetPosition = getPositionByAnchor(initAnchor()) ?: middlePosition - updatePosition(targetPosition.toFloat()) + when { + // 若位置未初始化,则尝试初始化 + position.floatValue == Float.MAX_VALUE -> { + val targetPosition = getPositionByAnchor(initAnchor()) ?: middlePosition + updatePosition(targetPosition.toFloat()) + } + + // 若位置超出范围,则尝试修正 + position.floatValue.toInt() !in minPosition..maxPosition -> { + val targetPosition = position.floatValue.coerceIn(min.toFloat(), max.toFloat()) + updatePosition(targetPosition) + } + + // 若最大值改变,则尝试修正 + maxPositionChange -> { + val targetPosition = position.floatValue.coerceIn(min.toFloat(), max.toFloat()) + .let { calcSnapByTargetPosition(it.toInt()) } + .toFloat() + updatePosition(targetPosition) + } } } From 81b14484a6a7d474a56e81cf6f07b4c2ef5d84c3 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Fri, 15 Nov 2024 17:36:02 +0800 Subject: [PATCH 111/213] =?UTF-8?q?[fix]=E8=A7=A3=E5=86=B3BottomSheet?= =?UTF-8?q?=E5=81=B6=E7=8E=B0=E6=97=A0=E6=B3=95=E6=AD=A3=E5=B8=B8=E6=94=B6?= =?UTF-8?q?=E8=B5=B7=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/base/EnhanceSheetState.kt | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt b/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt index dfa82a84b..b10c210d2 100644 --- a/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt +++ b/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt @@ -30,19 +30,11 @@ class EnhanceBottomSheetState( get() = bottomSheetState.isExpanded override fun show() { - scope.launch { - if (bottomSheetState.isCollapsed) { - bottomSheetState.expand() - } - } + scope.launch { bottomSheetState.expand() } } override fun hide() { - scope.launch { - if (bottomSheetState.isExpanded) { - bottomSheetState.collapse() - } - } + scope.launch { bottomSheetState.collapse() } } override fun progress(from: Any, to: Any): Float { @@ -73,15 +65,11 @@ class EnhanceModalSheetState( } override fun hide() { - if (isVisible) { - scope.launch { sheetState.hide() } - } + scope.launch { sheetState.hide() } } override fun show() { - if (!isVisible) { - scope.launch { sheetState.show() } - } + scope.launch { sheetState.show() } } override fun progress(from: Any, to: Any): Float { From 57c4c98a1c96647165ef87176d3552435105d4c5 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 18 Nov 2024 09:54:12 +0800 Subject: [PATCH 112/213] =?UTF-8?q?[refactor]=E8=BF=9B=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=AE=8C=E5=96=84=E8=BF=9B=E5=BA=A6=E6=9D=A1?= =?UTF-8?q?=E9=95=BF=E6=8C=89=E5=90=8E=E8=BF=9B=E5=85=A5=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=A8=A1=E5=BC=8F=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/SeekbarLayout2.kt | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt index cd68b938b..1f05a3a8d 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt @@ -234,11 +234,12 @@ fun SeekbarLayout2( .combineDetectDrag( onLongClickStart = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) + seekbarHorizontalState.value = SeekbarHorizontalState.Follow + seekbarVerticalState.value = SeekbarVerticalState.Cancel }, onDragStart = { moved.value = true seekbarVerticalState.value = SeekbarVerticalState.ProgressBar - seekbarHorizontalState.value = SeekbarHorizontalState.Follow seekbarOffsetY.floatValue = it.y scope.launch { onDragStart(it) } @@ -256,11 +257,16 @@ fun SeekbarLayout2( ) { val textMeasurer = rememberTextMeasurer() val bgColor = MaterialTheme.colors.background - val alpha = animateFloatAsState( + val bgAlpha = animateFloatAsState( targetValue = if (isTouching.value) 1f else 0f, animationSpec = spring(stiffness = Spring.StiffnessLow), label = "" ) + val textAlpha = animateFloatAsState( + targetValue = if (seekbarHorizontalState.value is SeekbarHorizontalState.Idle) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "" + ) val textStyle = remember { TextStyle.Default.copy( fontSize = 16.sp, @@ -300,8 +306,17 @@ fun SeekbarLayout2( .toLong() .durationToTime() + val currentTextResult = textMeasurer.measure( + text = currentValueText, + style = textStyle + ) + val maxTextResult = textMeasurer.measure( + text = maxValueText, + style = textStyle + ) + val maxPadding = 4.dp.toPx() - val paddingAnimate = maxPadding * alpha.value + val paddingAnimate = maxPadding * bgAlpha.value val innerRadius = 16.dp.toPx() - paddingAnimate val innerHeight = size.height - (paddingAnimate * 2f) @@ -326,7 +341,7 @@ fun SeekbarLayout2( onDrawBehind { // 纯色背景 - drawRect(color = bgColor, alpha = alpha.value) + drawRect(color = bgColor, alpha = bgAlpha.value) // 圆角裁切 clipPath(innerPath) { @@ -335,9 +350,9 @@ fun SeekbarLayout2( // 绘制总时长文本(固定右侧) drawText( - textMeasurer = textMeasurer, - text = maxValueText, - style = textStyle, + textLayoutResult = maxTextResult, + color = Color.White, + alpha = textAlpha.value, topLeft = Offset( x = size.width - textSize.value.first - 16.dp.toPx(), y = (size.height - textSize.value.second) / 2f @@ -356,11 +371,12 @@ fun SeekbarLayout2( (paddingAnimate + thumbWidth - 16.dp.toPx() - textSize.value.first) .let { accumulator.accumulate(it) } .coerceAtLeast(16.dp.roundToPx()) + // 绘制实时进度文本(移动) drawText( - textMeasurer = textMeasurer, - text = currentValueText, - style = textStyle, + textLayoutResult = currentTextResult, + color = Color.White, + alpha = textAlpha.value, topLeft = Offset( x = textX.toFloat(), y = (size.height - textSize.value.second) / 2f From b7c2d0810bcb64c16ba59c35c9a40889dbf56a6d Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 19 Nov 2024 03:09:45 +0800 Subject: [PATCH 113/213] =?UTF-8?q?[refactor]=E5=B0=9D=E8=AF=95=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=E8=8E=B7=E5=8F=96duration=E5=BC=82=E5=B8=B8=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index ba9fc1aca..18dfaef0d 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -1,6 +1,7 @@ package com.lalilu.lplayer import android.content.ComponentName +import androidx.annotation.OptIn import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -9,6 +10,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaBrowser import androidx.media3.session.SessionToken import com.blankj.utilcode.util.LogUtils @@ -22,6 +24,7 @@ import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext +@OptIn(UnstableApi::class) object MPlayer : CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO private val sessionToken by lazy { @@ -131,10 +134,9 @@ object MPlayer : CoroutineScope { this@MPlayer.isPlaying = isPlaying } + @OptIn(UnstableApi::class) override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_READY) { - currentDuration = browser.contentDuration.coerceAtLeast(0) - } + } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -144,6 +146,8 @@ object MPlayer : CoroutineScope { override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { currentMediaMetadata = mediaMetadata + currentDuration = mediaMetadata.durationMs ?: browser.duration + // TODO 此处获取到的duration仍然可能是上一首歌曲的时长 } override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) { From 14c02148e322bc5d974c379544e6d1cf1739fc22 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 19 Nov 2024 09:25:53 +0800 Subject: [PATCH 114/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E8=BF=9B=E5=BA=A6=E6=9D=A1=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/SeekbarLayout2.kt | 407 ++++++++++-------- 1 file changed, 232 insertions(+), 175 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt index 1f05a3a8d..2ce329e45 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt @@ -6,18 +6,21 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberDraggable2DState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -27,7 +30,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -54,6 +56,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.blankj.utilcode.util.LogUtils import com.lalilu.common.AccumulatedValue import com.lalilu.lmusic.utils.extension.durationToTime import kotlinx.coroutines.launch @@ -64,11 +67,6 @@ sealed class SeekbarVerticalState { data object Dispatcher : SeekbarVerticalState() } -sealed class SeekbarHorizontalState { - data object Idle : SeekbarHorizontalState() - data object Follow : SeekbarHorizontalState() -} - sealed interface ClickPart { data object Start : ClickPart data object Middle : ClickPart @@ -93,32 +91,48 @@ fun SeekbarLayout2( val haptic = LocalHapticFeedback.current val density = LocalDensity.current val scope = rememberCoroutineScope() + val textMeasurer = rememberTextMeasurer() + val bgColor = MaterialTheme.colors.background + val accumulator = remember { AccumulatedValue() } + + LaunchedEffect(maxValue()) { + LogUtils.i("maxValue: ${maxValue()}") + } val scrollSensitivity = remember { 1.3f } val scrollThreadHold = remember { 200f } val seekbarPaddingBottom = remember { density.run { 156.dp.toPx() } } - val currentValue = remember { mutableFloatStateOf(0f) } - val seekbarOffsetY = remember { mutableFloatStateOf(0f) } var boxSize by remember { mutableStateOf(IntSize.Zero) } - val seekbarVerticalState = - remember { mutableStateOf(SeekbarVerticalState.ProgressBar) } - val seekbarHorizontalState = - remember { mutableStateOf(SeekbarHorizontalState.Idle) } + val progressKeeper = rememberSeekbarProgressKeeper( + sizeWidth = { boxSize.width.toFloat() }, + minValue = minValue, + maxValue = maxValue, + dataValue = dataValue, + scrollSensitivity = scrollSensitivity + ) + + val seekbarOffsetY = remember { mutableFloatStateOf(0f) } + val switchModeX = remember { mutableFloatStateOf(0f) } + val switchMode = remember { mutableStateOf(false) } + val seekbarVerticalState = remember { + mutableStateOf(SeekbarVerticalState.ProgressBar) + } - val moved = remember { mutableStateOf(false) } - val isTouching = remember { mutableStateOf(false) } - val isCanceled = remember { - derivedStateOf { seekbarVerticalState.value != SeekbarVerticalState.ProgressBar } + var isMoved by remember { mutableStateOf(false) } + var isTouching by remember { mutableStateOf(false) } + val isCanceled by remember { + derivedStateOf { + seekbarVerticalState.value != SeekbarVerticalState.ProgressBar + } } val resultValue = remember { derivedStateOf { - val value = if (isTouching.value && !isCanceled.value) currentValue.floatValue + if (switchMode.value) dataValue() + else if (isTouching && !isCanceled) progressKeeper.nowValue else dataValue() - - value.coerceIn(minValue(), maxValue()) } } @@ -126,10 +140,44 @@ fun SeekbarLayout2( val animateValue = animateFloatAsState( targetValue = resultValue.value, visibilityThreshold = 0.005f, - animationSpec = if (isTouching.value && !isCanceled.value) snap() else spring(stiffness = Spring.StiffnessLow), + animationSpec = if (isTouching && !isCanceled) snap() else spring(stiffness = Spring.StiffnessLow), label = "" ) + val bgAlpha = animateFloatAsState( + targetValue = if (isTouching) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "" + ) + val textAlpha = animateFloatAsState( + targetValue = if (switchMode.value) 0f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "" + ) + val textStyle = remember { + TextStyle.Default.copy( + fontSize = 16.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold, + baselineShift = BaselineShift.None, + textAlign = TextAlign.End, + color = Color.White + ) + } + val textSize = remember { + derivedStateOf { + // 获取最大时长,并使用0替换其内部的数字,计算其最大宽度 + val text = maxValue().toLong() + .durationToTime() + .replace(Regex("[0-9]"), "0") + val result = textMeasurer.measure( + text = text, + style = textStyle + ) + result.size.width to result.size.height + } + } + val draggableState = rememberDraggable2DState { offset -> val oldState = seekbarVerticalState.value val deltaY = offset.y @@ -139,11 +187,14 @@ fun SeekbarLayout2( seekbarOffsetY.floatValue += deltaY // 根据当前状态控制进度变量 - currentValue.floatValue = if (oldState == SeekbarVerticalState.ProgressBar) { - (currentValue.floatValue + deltaX / boxSize.width * (maxValue() - minValue()) * scrollSensitivity) - .coerceIn(minValue(), maxValue()) - } else { - animateValue.value + when { + switchMode.value -> { + switchModeX.floatValue = offset.x + } + + oldState == SeekbarVerticalState.ProgressBar -> { + progressKeeper.updateProgressByDelta(delta = deltaX) + } } // 根据Y轴滚动距离决定新的状态 @@ -194,7 +245,7 @@ fun SeekbarLayout2( modifier = modifier .padding(bottom = 100.dp) .fillMaxWidth(0.7f) - .wrapContentHeight() + .height(IntrinsicSize.Max) .onPlaced { boxSize = it.size } .pointerInput(Unit) { awaitPointerEventScope { @@ -204,16 +255,16 @@ fun SeekbarLayout2( when (event.type) { PointerEventType.Press -> { // 开始触摸时,将当前可见的进度值记录下来 - currentValue.floatValue = animateValue.value - isTouching.value = true - moved.value = false + progressKeeper.updateProgress(animateValue.value) + isTouching = true + isMoved = false } PointerEventType.Release -> { - if (moved.value && !isCanceled.value) { - onSeekTo(currentValue.floatValue) + if (isMoved && !isCanceled) { + onSeekTo(progressKeeper.nowValue) } - isTouching.value = false + isTouching = false } } } @@ -234,11 +285,11 @@ fun SeekbarLayout2( .combineDetectDrag( onLongClickStart = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - seekbarHorizontalState.value = SeekbarHorizontalState.Follow - seekbarVerticalState.value = SeekbarVerticalState.Cancel + switchModeX.floatValue = it.x + switchMode.value = true }, onDragStart = { - moved.value = true + isMoved = true seekbarVerticalState.value = SeekbarVerticalState.ProgressBar seekbarOffsetY.floatValue = it.y @@ -246,160 +297,127 @@ fun SeekbarLayout2( }, onDragEnd = { seekbarVerticalState.value = SeekbarVerticalState.ProgressBar - seekbarHorizontalState.value = SeekbarHorizontalState.Idle + switchMode.value = false scope.launch { onDragStop(0) } }, - onDrag = { change, dragAmount -> + onDrag = { _, dragAmount -> draggableState.dispatchRawDelta(dragAmount) } ) ) { - val textMeasurer = rememberTextMeasurer() - val bgColor = MaterialTheme.colors.background - val bgAlpha = animateFloatAsState( - targetValue = if (isTouching.value) 1f else 0f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "" - ) - val textAlpha = animateFloatAsState( - targetValue = if (seekbarHorizontalState.value is SeekbarHorizontalState.Idle) 1f else 0f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "" - ) - val textStyle = remember { - TextStyle.Default.copy( - fontSize = 16.sp, - lineHeight = 16.sp, - fontWeight = FontWeight.Bold, - baselineShift = BaselineShift.None, - textAlign = TextAlign.End, - color = Color.White - ) - } - val accumulator = remember { AccumulatedValue() } - val textSize = remember { - derivedStateOf { - // 获取最大时长,并使用0替换其内部的数字,计算其最大宽度 - val text = maxValue().toLong() - .durationToTime() - .replace(Regex("[0-9]"), "0") - val result = textMeasurer.measure( - text = text, - style = textStyle - ) - result.size.width to result.size.height - } - } - - Box( + Canvas( modifier = Modifier - .height(56.dp) .fillMaxWidth() + .height(56.dp) .clip(RoundedCornerShape(16.dp)) - .drawWithCache { - val innerPath = Path() - val currentValueText = animateValue.value - .toLong() - .durationToTime() - val maxValueText = maxValue() - .toLong() - .durationToTime() - - val currentTextResult = textMeasurer.measure( - text = currentValueText, - style = textStyle - ) - val maxTextResult = textMeasurer.measure( - text = maxValueText, - style = textStyle - ) + ) { + + val innerPath = Path() + val currentValueText = animateValue.value + .toLong() + .durationToTime() + val maxValueText = maxValue() + .toLong() + .durationToTime() + + val currentTextResult = textMeasurer + .measure(text = currentValueText, style = textStyle) + val maxTextResult = textMeasurer + .measure(text = maxValueText, style = textStyle) + + val maxPadding = 4.dp.toPx() + val paddingAnimate = maxPadding * bgAlpha.value + + val innerRadius = 16.dp.toPx() - paddingAnimate + val innerHeight = size.height - (paddingAnimate * 2f) + val innerWidth = size.width - (paddingAnimate * 2f) + + innerPath.reset() + innerPath.addRoundRect( + RoundRect( + rect = Rect( + offset = Offset(x = paddingAnimate, y = paddingAnimate), + size = Size(width = innerWidth, height = innerHeight) + ), + cornerRadius = CornerRadius(innerRadius, innerRadius) + ) + ) + + val actualValue = animateValue.value + val actualProgress = actualValue.normalize(minValue(), maxValue()) + onValueChange(actualValue) + +// // 通过Value计算Progress,从而获取滑块应有的宽度 +// thumbWidth = normalize(nowValue, minValue, maxValue) * actualWidth +// thumbWidth = lerp(thumbWidth, actualWidth / thumbCount, switchModeProgress) - val maxPadding = 4.dp.toPx() - val paddingAnimate = maxPadding * bgAlpha.value + val thumbWidth = innerWidth * actualProgress + val thumbHeight = innerHeight - val innerRadius = 16.dp.toPx() - paddingAnimate - val innerHeight = size.height - (paddingAnimate * 2f) - val innerWidth = size.width - (paddingAnimate * 2f) + // 纯色背景 + drawRect(color = bgColor, alpha = bgAlpha.value) - val actualValue = animateValue.value - val actualProgress = actualValue.normalize(minValue(), maxValue()) + // 圆角裁切 + clipPath(innerPath) { + drawRect(color = Color(100, 100, 100, 50)) - val thumbWidth = innerWidth * actualProgress - val thumbHeight = innerHeight + // 绘制总时长文本(固定右侧) + drawText( + textLayoutResult = maxTextResult, + color = Color.White, + alpha = textAlpha.value, + topLeft = Offset( + x = size.width - textSize.value.first - 16.dp.toPx(), + y = (size.height - textSize.value.second) / 2f + ) + ) - innerPath.reset() - innerPath.addRoundRect( - RoundRect( - rect = Rect( - offset = Offset(x = paddingAnimate, y = paddingAnimate), - size = Size(width = innerWidth, height = innerHeight) - ), - cornerRadius = CornerRadius(innerRadius, innerRadius) - ) + // 绘制滑块 + drawRoundRect( + color = animateColor(), + cornerRadius = CornerRadius(innerRadius, innerRadius), + topLeft = Offset(x = paddingAnimate, y = paddingAnimate), + size = Size(width = thumbWidth, height = thumbHeight) + ) + + val textX = + (paddingAnimate + thumbWidth - 16.dp.toPx() - textSize.value.first) + .let { accumulator.accumulate(it) } + .coerceAtLeast(16.dp.roundToPx()) + + // 绘制实时进度文本(移动) + drawText( + textLayoutResult = currentTextResult, + color = Color.White, + alpha = textAlpha.value, + topLeft = Offset( + x = textX.toFloat(), + y = (size.height - textSize.value.second) / 2f ) + ) - onDrawBehind { - // 纯色背景 - drawRect(color = bgColor, alpha = bgAlpha.value) - - // 圆角裁切 - clipPath(innerPath) { - drawRect(color = Color(100, 100, 100, 50)) - onValueChange(actualValue) - - // 绘制总时长文本(固定右侧) - drawText( - textLayoutResult = maxTextResult, - color = Color.White, - alpha = textAlpha.value, - topLeft = Offset( - x = size.width - textSize.value.first - 16.dp.toPx(), - y = (size.height - textSize.value.second) / 2f - ) - ) - - // 绘制滑块 - drawRoundRect( - color = animateColor(), - cornerRadius = CornerRadius(innerRadius, innerRadius), - topLeft = Offset(x = paddingAnimate, y = paddingAnimate), - size = Size(width = thumbWidth, height = thumbHeight) - ) - - val textX = - (paddingAnimate + thumbWidth - 16.dp.toPx() - textSize.value.first) - .let { accumulator.accumulate(it) } - .coerceAtLeast(16.dp.roundToPx()) - - // 绘制实时进度文本(移动) - drawText( - textLayoutResult = currentTextResult, - color = Color.White, - alpha = textAlpha.value, - topLeft = Offset( - x = textX.toFloat(), - y = (size.height - textSize.value.second) / 2f - ) - ) - -// // 绘制把手元素 -// drawRoundRect( -// color = Color.White, -// alpha = alpha.value, -// cornerRadius = CornerRadius(50f), -// topLeft = Offset( -// x = innerWidth * actualProgress + paddingAnimate - 8.dp.toPx(), -// y = (size.height - (innerHeight * 0.5f)) / 2f -// ), -// size = Size( -// width = 4.dp.toPx(), -// height = innerHeight * 0.5f -// ) -// ) - } - } - } + // 绘制把手元素 +// drawRoundRect( +// color = Color.White, +// alpha = alpha.value, +// cornerRadius = CornerRadius(50f), +// topLeft = Offset( +// x = innerWidth * actualProgress + paddingAnimate - 8.dp.toPx(), +// y = (size.height - (innerHeight * 0.5f)) / 2f +// ), +// size = Size( +// width = 4.dp.toPx(), +// height = innerHeight * 0.5f +// ) +// ) + } + } + + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() ) { // Row { // Icon( @@ -427,7 +445,7 @@ private fun Modifier.combineDetectDrag( onDragStart: (Offset) -> Unit = { }, onDragEnd: () -> Unit = { }, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, - onLongClickStart: () -> Unit = {} + onLongClickStart: (Offset) -> Unit = {} ) = this.then( Modifier .pointerInput(key) { @@ -436,7 +454,7 @@ private fun Modifier.combineDetectDrag( .pointerInput(key) { detectDragGesturesAfterLongPress( onDragStart = { - onLongClickStart() + onLongClickStart(it) onDragStart(it) }, onDragEnd = onDragEnd, @@ -447,6 +465,45 @@ private fun Modifier.combineDetectDrag( ) +class SeekbarProgressKeeper( + private val minValue: () -> Float, + private val maxValue: () -> Float, + private val sizeWidth: () -> Float, + private val dataValue: () -> Float, + private val scrollSensitivity: Float, +) { + var nowValue: Float by mutableFloatStateOf(0f) + private set + + fun updateProgress(value: Float) { + nowValue = value.coerceIn(minValue(), maxValue()) + } + + fun updateProgressByDelta(delta: Float) { + val value = nowValue + delta / sizeWidth() * (maxValue() - minValue()) * scrollSensitivity + updateProgress(value) + } +} + +@Composable +fun rememberSeekbarProgressKeeper( + minValue: () -> Float, + maxValue: () -> Float, + sizeWidth: () -> Float, + dataValue: () -> Float, + scrollSensitivity: Float = 1f +): SeekbarProgressKeeper { + return remember { + SeekbarProgressKeeper( + minValue = minValue, + maxValue = maxValue, + sizeWidth = sizeWidth, + dataValue = dataValue, + scrollSensitivity = scrollSensitivity + ) + } +} + private fun Float.normalize(minValue: Float, maxValue: Float): Float { val min = minOf(minValue, maxValue) val max = maxOf(minValue, maxValue) From a200e3af8edb6105cea19247b5bd1a4ff9cc8090 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 20 Nov 2024 09:35:39 +0800 Subject: [PATCH 115/213] =?UTF-8?q?[refactor]=E6=B7=BB=E5=8A=A0=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1=E4=B8=8A=E6=BB=91=E6=97=B6=E7=9A=84=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=95=88=E6=9E=9C=EF=BC=8C=E8=A7=A3=E5=86=B3=E9=95=BF?= =?UTF-8?q?=E6=8C=89=E6=97=B6=E4=BC=9A=E5=8D=A1=E9=A1=BF=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/SeekbarLayout2.kt | 153 +++++++++++------- 1 file changed, 91 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt index 2ce329e45..067025bec 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateTo -import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures @@ -13,14 +12,12 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberDraggable2DState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -38,6 +35,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType @@ -56,15 +54,17 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.blankj.utilcode.util.LogUtils import com.lalilu.common.AccumulatedValue import com.lalilu.lmusic.utils.extension.durationToTime import kotlinx.coroutines.launch - -sealed class SeekbarVerticalState { - data object ProgressBar : SeekbarVerticalState() - data object Cancel : SeekbarVerticalState() - data object Dispatcher : SeekbarVerticalState() +import kotlin.math.absoluteValue + +sealed class SeekbarState { + data object Idle : SeekbarState() + data object ProgressBar : SeekbarState() + data object Switcher : SeekbarState() + data object Cancel : SeekbarState() + data object Dispatcher : SeekbarState() } sealed interface ClickPart { @@ -95,10 +95,6 @@ fun SeekbarLayout2( val bgColor = MaterialTheme.colors.background val accumulator = remember { AccumulatedValue() } - LaunchedEffect(maxValue()) { - LogUtils.i("maxValue: ${maxValue()}") - } - val scrollSensitivity = remember { 1.3f } val scrollThreadHold = remember { 200f } val seekbarPaddingBottom = remember { density.run { 156.dp.toPx() } } @@ -114,23 +110,27 @@ fun SeekbarLayout2( ) val seekbarOffsetY = remember { mutableFloatStateOf(0f) } - val switchModeX = remember { mutableFloatStateOf(0f) } val switchMode = remember { mutableStateOf(false) } - val seekbarVerticalState = remember { - mutableStateOf(SeekbarVerticalState.ProgressBar) - } + val switchModeX = remember { mutableFloatStateOf(0f) } + val seekbarState = remember { mutableStateOf(SeekbarState.Idle) } var isMoved by remember { mutableStateOf(false) } var isTouching by remember { mutableStateOf(false) } - val isCanceled by remember { + val isCanceled by remember { derivedStateOf { seekbarState.value != SeekbarState.ProgressBar } } + val isSwitching by remember { derivedStateOf { seekbarState.value is SeekbarState.Switcher } } + + val seekbarValue = remember { derivedStateOf { - seekbarVerticalState.value != SeekbarVerticalState.ProgressBar + when { + seekbarState.value is SeekbarState.ProgressBar -> progressKeeper.nowValue + else -> dataValue() + } } } val resultValue = remember { derivedStateOf { - if (switchMode.value) dataValue() + if (isSwitching) dataValue() else if (isTouching && !isCanceled) progressKeeper.nowValue else dataValue() } @@ -140,7 +140,7 @@ fun SeekbarLayout2( val animateValue = animateFloatAsState( targetValue = resultValue.value, visibilityThreshold = 0.005f, - animationSpec = if (isTouching && !isCanceled) snap() else spring(stiffness = Spring.StiffnessLow), + animationSpec = spring(stiffness = Spring.StiffnessLow), label = "" ) @@ -150,7 +150,7 @@ fun SeekbarLayout2( label = "" ) val textAlpha = animateFloatAsState( - targetValue = if (switchMode.value) 0f else 1f, + targetValue = if (isSwitching) 0f else 1f, animationSpec = spring(stiffness = Spring.StiffnessLow), label = "" ) @@ -179,7 +179,8 @@ fun SeekbarLayout2( } val draggableState = rememberDraggable2DState { offset -> - val oldState = seekbarVerticalState.value + val oldState = seekbarState.value + val deltaY = offset.y val deltaX = offset.x @@ -188,33 +189,33 @@ fun SeekbarLayout2( // 根据当前状态控制进度变量 when { - switchMode.value -> { + isSwitching -> { switchModeX.floatValue = offset.x } - oldState == SeekbarVerticalState.ProgressBar -> { + oldState == SeekbarState.ProgressBar -> { progressKeeper.updateProgressByDelta(delta = deltaX) } } // 根据Y轴滚动距离决定新的状态 - seekbarVerticalState.value = when { - seekbarOffsetY.floatValue < -200f -> SeekbarVerticalState.Dispatcher - seekbarOffsetY.floatValue < -100f -> SeekbarVerticalState.Cancel - else -> SeekbarVerticalState.ProgressBar + seekbarState.value = when { + seekbarOffsetY.floatValue < -scrollThreadHold -> SeekbarState.Dispatcher + seekbarOffsetY.floatValue < -(scrollThreadHold / 2f) -> SeekbarState.Cancel + else -> if (switchMode.value) SeekbarState.Switcher else SeekbarState.ProgressBar } // 当状态发生变化的时候,进行震动 - if (oldState != seekbarVerticalState.value) { + if (oldState != seekbarState.value) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } when (oldState) { - seekbarVerticalState.value -> {} - SeekbarVerticalState.Dispatcher -> scope.launch { onDragStop(-1) } + seekbarState.value -> {} + SeekbarState.Dispatcher -> scope.launch { onDragStop(-1) } - SeekbarVerticalState.Cancel -> when (seekbarVerticalState.value) { - SeekbarVerticalState.Dispatcher -> { + SeekbarState.Cancel -> when (seekbarState.value) { + SeekbarState.Dispatcher -> { val animationState = AnimationState( initialValue = 0f, initialVelocity = 100f, @@ -236,7 +237,7 @@ fun SeekbarLayout2( } // 若当前状态为Dispatcher,则将滚动的位移量向外分发 - if (seekbarVerticalState.value == SeekbarVerticalState.Dispatcher) { + if (seekbarState.value is SeekbarState.Dispatcher) { onDispatchDragOffset(deltaY) } } @@ -290,15 +291,18 @@ fun SeekbarLayout2( }, onDragStart = { isMoved = true - seekbarVerticalState.value = SeekbarVerticalState.ProgressBar + seekbarState.value = if (switchMode.value) SeekbarState.Switcher + else SeekbarState.ProgressBar seekbarOffsetY.floatValue = it.y scope.launch { onDragStart(it) } }, onDragEnd = { - seekbarVerticalState.value = SeekbarVerticalState.ProgressBar switchMode.value = false + seekbarState.value = SeekbarState.Idle + switchModeX.floatValue = 0f + seekbarOffsetY.floatValue = 0f scope.launch { onDragStop(0) } }, onDrag = { _, dragAmount -> @@ -306,13 +310,36 @@ fun SeekbarLayout2( } ) ) { + val yProgressValue = remember { + derivedStateOf { + val value = seekbarOffsetY.floatValue.coerceAtMost(0f) + .absoluteValue + .takeIf { it < (scrollThreadHold / 2f) } + ?: 0f + + (value / (scrollThreadHold / 2f)).coerceIn(0f, 1f) + } + } + val yTranslationAnimateValue = animateFloatAsState( + targetValue = yProgressValue.value, + animationSpec = spring( + stiffness = Spring.StiffnessLow, + dampingRatio = Spring.DampingRatioMediumBouncy + ), + label = "" + ) + Canvas( modifier = Modifier .fillMaxWidth() .height(56.dp) + .graphicsLayer { + translationY = -yTranslationAnimateValue.value * (scrollThreadHold / 2f) + scaleX = 1f - (yTranslationAnimateValue.value * 0.1f) + scaleY = scaleX + } .clip(RoundedCornerShape(16.dp)) ) { - val innerPath = Path() val currentValueText = animateValue.value .toLong() @@ -414,11 +441,11 @@ fun SeekbarLayout2( } } - Box( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth() - ) { +// Box( +// modifier = Modifier +// .fillMaxHeight() +// .fillMaxWidth() +// ) { // Row { // Icon( // imageVector = RemixIcon.Media.repeatFill, @@ -435,34 +462,36 @@ fun SeekbarLayout2( // contentDescription = null // ) // } - } +// } } } -@Composable private fun Modifier.combineDetectDrag( key: Any = Unit, onDragStart: (Offset) -> Unit = { }, onDragEnd: () -> Unit = { }, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, onLongClickStart: (Offset) -> Unit = {} -) = this.then( - Modifier - .pointerInput(key) { - detectDragGestures(onDragStart, onDragEnd, onDragEnd, onDrag) - } - .pointerInput(key) { - detectDragGesturesAfterLongPress( - onDragStart = { - onLongClickStart(it) - onDragStart(it) - }, - onDragEnd = onDragEnd, - onDragCancel = onDragEnd, - onDrag = onDrag - ) - } -) +): Modifier = this + .pointerInput(key) { + detectDragGestures( + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragEnd, + onDrag = onDrag + ) + } + .pointerInput(key) { + detectDragGesturesAfterLongPress( + onDragStart = { + onLongClickStart(it) + onDragStart(it) + }, + onDragEnd = onDragEnd, + onDragCancel = onDragEnd, + onDrag = onDrag + ) + } class SeekbarProgressKeeper( From 0bc5b4743e2bd90c6f37e232ce6f51f7a58e4650 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 24 Nov 2024 03:07:11 +0800 Subject: [PATCH 116/213] =?UTF-8?q?[refactor]=E6=9B=B4=E6=96=B0Compose?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC=EF=BC=8C=E8=A7=A3=E5=86=B3?= =?UTF-8?q?basicMarquee=E5=AF=BC=E8=87=B4=E9=95=BF=E6=8C=89=E5=90=8E?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=8D=A1=E4=BD=8F=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/component/base/AutoSizeText.kt | 3 ++- .../lmusic/compose/component/playing/PlayingHeader.kt | 2 -- gradle/libs.versions.toml | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/base/AutoSizeText.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/base/AutoSizeText.kt index 9ee4fedfc..8b666ec61 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/base/AutoSizeText.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/base/AutoSizeText.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit @@ -74,7 +75,7 @@ fun AutoSizeText( spanStyles = listOf(), placeholders = listOf(), maxLines = maxLines, - ellipsis = false + overflow = TextOverflow.Ellipsis ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/PlayingHeader.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/PlayingHeader.kt index b121a56e5..8c9b91909 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/PlayingHeader.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/PlayingHeader.kt @@ -1,7 +1,6 @@ package com.lalilu.lmusic.compose.component.playing import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.MarqueeSpacing import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement @@ -25,7 +24,6 @@ import androidx.compose.ui.unit.dp import com.lalilu.component.card.PlayingTipIcon import com.lalilu.lmusic.utils.extension.slideTransition -@OptIn(ExperimentalFoundationApi::class) @Composable fun PlayingHeader( modifier: Modifier = Modifier, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13c31b6e9..fda25fec7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,9 +9,9 @@ ksp_version = "2.0.0-1.0.22" #serialization_json_version = "1.6.0" koin_version = "4.0.0" -koin_ksp_version = "1.3.1" -compose_bom_alpha_version = "2024.10.00" -compose_bom_version = "2024.10.00" +koin_ksp_version = "1.4.0" +compose_bom_alpha_version = "2024.11.00" +compose_bom_version = "2024.11.00" accompanist_version = "0.32.0" voyager = "1.1.0-beta03" lottie-compose = "5.2.0" From e9c328b0259ca247413678ce5bd35729499fa3cd Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 24 Nov 2024 17:21:29 +0800 Subject: [PATCH 117/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1=E6=81=A2=E5=A4=8D=E6=BB=9A=E5=8A=A8=E7=9A=84?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E5=8E=BB=E9=99=A4=E6=97=A7=E7=9A=84?= =?UTF-8?q?SeekbarLayout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/guiding/GuidingScreen.kt | 4 +- .../screen/guiding/PermissionsScreen.kt | 35 ++- .../screen/guiding/SeekbarGuidingScreen.kt | 257 ------------------ .../compose/screen/playing/SeekbarLayout.kt | 161 ----------- .../compose/screen/playing/SeekbarLayout2.kt | 51 ++-- 5 files changed, 48 insertions(+), 460 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/SeekbarGuidingScreen.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt index ed4ae4211..b0cf04b6f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt @@ -124,9 +124,7 @@ fun GuidingScreen() { } Navigator( AgreementScreen( - nextScreen = PermissionsScreen( - nextScreen = SeekbarGuidingScreen - ) + nextScreen = PermissionsScreen() ) ) { navigator -> navigatorState.value = navigator diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt index e9019a9c1..03c51f95a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt @@ -5,22 +5,23 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.navigator.currentOrThrow +import com.blankj.utilcode.util.ActivityUtils import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.lalilu.R -import com.lalilu.lmusic.Config.REQUIRE_PERMISSIONS import com.lalilu.component.base.CustomScreen import com.lalilu.component.base.ScreenInfo +import com.lalilu.lmusic.Config.REQUIRE_PERMISSIONS +import com.lalilu.lmusic.MainActivity +import com.lalilu.lmusic.datastore.SettingsSp +import com.lalilu.lmusic.utils.extension.getActivity +import org.koin.compose.koinInject import kotlin.system.exitProcess class PermissionsScreen( - private val nextScreen: Screen ) : CustomScreen { override fun getScreenInfo(): ScreenInfo = ScreenInfo( title = R.string.screen_title_permissions @@ -28,19 +29,18 @@ class PermissionsScreen( @Composable override fun Content() { - PermissionsPage( - nextScreen = nextScreen - ) + PermissionsPage() } } @OptIn(ExperimentalPermissionsApi::class) @Composable private fun PermissionsPage( - nextScreen: Screen, - navigator: Navigator = LocalNavigator.currentOrThrow + settingsSp: SettingsSp = koinInject() ) { val permission = rememberPermissionState(permission = REQUIRE_PERMISSIONS) + var isGuidingOver by settingsSp.isGuidingOver + val context = LocalContext.current Column( modifier = Modifier @@ -50,8 +50,17 @@ private fun PermissionsPage( when (permission.status) { PermissionStatus.Granted -> { ActionCard( - confirmTitle = "已授权,下一步", - onConfirm = { navigator.push(nextScreen) } + confirmTitle = "已授权,进入", + onConfirm = { + context.getActivity()?.apply { + isGuidingOver = true + + if (!ActivityUtils.isActivityExistsInStack(MainActivity::class.java)) { + ActivityUtils.startActivity(MainActivity::class.java) + } + finishAfterTransition() + } + } ) { """ 本应用需要获取本地存储权限,以访问本机存储的所有歌曲文件 diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/SeekbarGuidingScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/SeekbarGuidingScreen.kt deleted file mode 100644 index 2efaf2a22..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/SeekbarGuidingScreen.kt +++ /dev/null @@ -1,257 +0,0 @@ -package com.lalilu.lmusic.compose.screen.guiding - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import com.blankj.utilcode.util.ActivityUtils -import com.lalilu.R -import com.lalilu.common.HapticUtils -import com.lalilu.lmusic.MainActivity -import com.lalilu.component.base.CustomScreen -import com.lalilu.component.base.ScreenInfo -import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lmusic.utils.extension.getActivity -import com.lalilu.ui.CLICK_PART_LEFT -import com.lalilu.ui.CLICK_PART_MIDDLE -import com.lalilu.ui.CLICK_PART_RIGHT -import com.lalilu.ui.ClickPart -import com.lalilu.ui.NewSeekBar -import com.lalilu.ui.OnSeekBarCancelListener -import com.lalilu.ui.OnSeekBarClickListener -import com.lalilu.ui.OnSeekBarScrollToThresholdListener -import com.lalilu.ui.OnSeekBarSeekToListener -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.koin.compose.koinInject - -object SeekbarGuidingScreen : CustomScreen { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_guiding - ) - - @Composable - override fun Content() { - SeekbarGuidingPage() - } -} - -@Composable -private fun SeekbarGuidingPage( - settingsSp: SettingsSp = koinInject() -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val showLyric = remember { mutableStateOf(false) } - val playPause = remember { mutableStateOf(false) } - val seekToNext = remember { mutableStateOf(false) } - val seekToPrevious = remember { mutableStateOf(false) } - val seekToPosition = remember { mutableStateOf(false) } - val cancelSeekToPosition = remember { mutableStateOf(false) } - val expendLibrary = remember { mutableStateOf(false) } - - var isGuidingOver by settingsSp.isGuidingOver - val reUpdateDelay = 200L - - LaunchedEffect(Unit) { - showLyric.value = false - playPause.value = false - seekToNext.value = false - seekToPrevious.value = false - seekToPosition.value = false - seekToPosition.value = false - cancelSeekToPosition.value = false - expendLibrary.value = false - } - - val complete: () -> Unit = { - context.getActivity()?.apply { - isGuidingOver = true - - if (!ActivityUtils.isActivityExistsInStack(MainActivity::class.java)) { - ActivityUtils.startActivity(MainActivity::class.java) - } - finishAfterTransition() - } - } - - Box( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 140.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - item { - ActionCard( - confirmTitle = "跳过", - onConfirm = complete - ) { - // TODO 修改说明文本 - """ - 来看看这个神奇的进度条,三种切歌方式任君挑选,选好之后照着下面的提示来体验一下吧,当然你也可以每一种都试一试。 - """ - } - } - item { - CheckActionCard(isPassed = seekToPrevious.value) { - Text( - fontSize = 14.sp, - lineHeight = 20.sp, - text = "[切换至上一首歌曲]: 单击进度条左侧" - ) - } - } - item { - CheckActionCard(isPassed = seekToNext.value) { - Text( - fontSize = 14.sp, - lineHeight = 20.sp, - text = "[切换至下一首歌曲]: 单击进度条右侧" - ) - } - } - item { - CheckActionCard(isPassed = playPause.value) { - Text( - fontSize = 14.sp, - lineHeight = 20.sp, - text = "[播放/暂停]: 单击进度条中部" - ) - } - } -// item { -// CheckActionCard(isPassed = showLyric.value) { -// Text( -// fontSize = 14.sp, -// lineHeight = 20.sp, -// text = "[展开歌词页]: 长按进度条中部" -// ) -// } -// } - item { - CheckActionCard(isPassed = seekToPosition.value) { - Text( - fontSize = 14.sp, - lineHeight = 20.sp, - text = "[调整进度]: 左右滑动" - ) - } - } - item { - CheckActionCard(isPassed = cancelSeekToPosition.value) { - Text( - fontSize = 14.sp, - lineHeight = 20.sp, - text = "[取消调节进度]: 进度条上滑(振动第一下)" - ) - } - } - item { - CheckActionCard(isPassed = expendLibrary.value) { - Text( - fontSize = 14.sp, - lineHeight = 20.sp, - text = "[打开曲库]: 进度条上滑(振动第二下)" - ) - } - } - item { - ActionCard( - confirmTitle = "结束", - onConfirm = complete - ) { - """ - 进度条平分为三个区域,这样设计其实为了在一个进度条上,尽可能的方便操作和避免误触,可能也能算的上是某种意义上的简陋,不理解的话请多试试看。 - - 重试该教程在: - 【曲库->设置->其他->新手引导】 - """ - } - } - } - - AndroidView( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(horizontal = 50.dp, vertical = 72.dp) - .height(48.dp), - factory = { - NewSeekBar(it).apply { - maxValue = 4 * 45 * 1000F - cancelListeners.add(OnSeekBarCancelListener { - HapticUtils.haptic(this) - delayReUpdate(scope, cancelSeekToPosition, reUpdateDelay) - }) - - scrollListeners.add(object : OnSeekBarScrollToThresholdListener({ 300f }) { - override fun onScrollToThreshold() { - HapticUtils.haptic(this@apply) - delayReUpdate(scope, expendLibrary, reUpdateDelay) - } - - override fun onScrollRecover() { - HapticUtils.haptic(this@apply) - } - }) - - seekToListeners.add(OnSeekBarSeekToListener { - delayReUpdate(scope, seekToPosition, reUpdateDelay) - }) - - clickListeners.add(object : OnSeekBarClickListener { - override fun onClick(@ClickPart clickPart: Int, action: Int) { - HapticUtils.haptic(this@apply) - when (clickPart) { - CLICK_PART_LEFT -> delayReUpdate( - scope, - seekToPrevious, - reUpdateDelay - ) - - CLICK_PART_MIDDLE -> delayReUpdate(scope, playPause, reUpdateDelay) - CLICK_PART_RIGHT -> delayReUpdate(scope, seekToNext, reUpdateDelay) - else -> {} - } - } - - override fun onLongClick(@ClickPart clickPart: Int, action: Int) { - HapticUtils.haptic(this@apply) - } - - override fun onDoubleClick(@ClickPart clickPart: Int, action: Int) { - HapticUtils.doubleHaptic(this@apply) - } - }) - } - }) - } -} - -fun delayReUpdate(scope: CoroutineScope, updateValue: MutableState, delay: Long) = - scope.launch { - if (updateValue.value) { - updateValue.value = false - delay(delay) - } - updateValue.value = true - } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt deleted file mode 100644 index f70275b98..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.lalilu.lmusic.compose.screen.playing - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.lalilu.R -import com.lalilu.common.HapticUtils -import com.lalilu.component.extension.DynamicTipsItem -import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lmusic.utils.extension.durationToTime -import com.lalilu.lmusic.utils.extension.getActivity -import com.lalilu.lplayer.extensions.PlayerAction -import com.lalilu.lplayer.extensions.PlayMode -import com.lalilu.ui.CLICK_PART_LEFT -import com.lalilu.ui.CLICK_PART_MIDDLE -import com.lalilu.ui.CLICK_PART_RIGHT -import com.lalilu.ui.ClickPart -import com.lalilu.ui.NewSeekBar -import com.lalilu.ui.OnSeekBarCancelListener -import com.lalilu.ui.OnSeekBarClickListener -import com.lalilu.ui.OnSeekBarScrollToThresholdListener -import com.lalilu.ui.OnSeekBarSeekToListener -import com.lalilu.ui.OnValueChangeListener -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koin.compose.koinInject - -@Composable -fun BoxScope.SeekbarLayout( - modifier: Modifier = Modifier, - seekBarModifier: Modifier = Modifier, - onValueChange: ((Long) -> Unit)? = null, - settingsSp: SettingsSp = koinInject(), - animateColor: State -) { - val haptic = LocalHapticFeedback.current - - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(bottom = 72.dp, start = 50.dp, end = 50.dp) - .align(Alignment.BottomCenter) - ) { - AndroidView( - modifier = seekBarModifier - .fillMaxWidth() - .height(48.dp), - factory = { context -> - val activity = context.getActivity()!! - - NewSeekBar(context).apply { - setSwitchToCallback( - ContextCompat.getDrawable(context, R.drawable.ic_shuffle_line)!! to { - settingsSp.playMode.value = PlayMode.Shuffle.value - DynamicTipsItem.Static( - title = "随机播放", - subTitle = "随机播放将触发列表重排序" - ).show() - }, - ContextCompat.getDrawable(context, R.drawable.ic_order_play_line)!! to { - settingsSp.playMode.value = PlayMode.ListRecycle.value - DynamicTipsItem.Static( - title = "列表循环", - subTitle = "循环循环循环" - ).show() - }, - ContextCompat.getDrawable(context, R.drawable.ic_repeat_one_line)!! to { - settingsSp.playMode.value = PlayMode.RepeatOne.value - DynamicTipsItem.Static( - title = "单曲循环", - subTitle = "循环循环循环" - ).show() - } - ) - - switchIndexUpdateCallback = { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - } - - valueToText = { it.toLong().durationToTime() } - - scrollListeners.add(object : - OnSeekBarScrollToThresholdListener({ 300f }) { - override fun onScrollToThreshold() { - HapticUtils.haptic(this@apply) -// NavigationWrapper.navigator?.show() - } - - override fun onScrollRecover() { - HapticUtils.haptic(this@apply) -// NavigationWrapper.navigator?.hide() - } - }) - - clickListeners.add(object : OnSeekBarClickListener { - override fun onClick(@ClickPart clickPart: Int, action: Int) { - HapticUtils.haptic(this@apply) - when (clickPart) { - CLICK_PART_LEFT -> PlayerAction.SkipToPrevious.action() - CLICK_PART_MIDDLE -> PlayerAction.PlayOrPause.action() - CLICK_PART_RIGHT -> PlayerAction.SkipToNext.action() - else -> { - } - } - } - - override fun onLongClick(@ClickPart clickPart: Int, action: Int) { - HapticUtils.haptic(this@apply) - } - - override fun onDoubleClick(@ClickPart clickPart: Int, action: Int) { - HapticUtils.doubleHaptic(this@apply) - } - }) - - seekToListeners.add(OnSeekBarSeekToListener { value -> - PlayerAction.SeekTo(value.toLong()).action() - }) - - cancelListeners.add(OnSeekBarCancelListener { - HapticUtils.haptic(this@apply) - }) - - if (onValueChange != null) { - onValueChangeListener.add(OnValueChangeListener { - onValueChange(it.toLong()) - }) - } - -// LPlayer.runtime.info.durationFlow.collectWithLifeCycleOwner(activity) { -// maxValue = it.takeIf { it > 0f }?.toFloat() ?: 0f -// } -// LPlayer.runtime.info.positionFlow.collectWithLifeCycleOwner(activity) { -// updateValue(it.toFloat()) -// } - - snapshotFlow { animateColor.value } - .onEach { thumbColor = it.toArgb() } - .launchIn(activity.lifecycleScope) - } - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt index 067025bec..d1b6213cb 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures @@ -98,14 +99,12 @@ fun SeekbarLayout2( val scrollSensitivity = remember { 1.3f } val scrollThreadHold = remember { 200f } val seekbarPaddingBottom = remember { density.run { 156.dp.toPx() } } - var boxSize by remember { mutableStateOf(IntSize.Zero) } val progressKeeper = rememberSeekbarProgressKeeper( - sizeWidth = { boxSize.width.toFloat() }, minValue = minValue, maxValue = maxValue, - dataValue = dataValue, + sizeWidth = { boxSize.width.toFloat() }, scrollSensitivity = scrollSensitivity ) @@ -116,36 +115,38 @@ fun SeekbarLayout2( var isMoved by remember { mutableStateOf(false) } var isTouching by remember { mutableStateOf(false) } - val isCanceled by remember { derivedStateOf { seekbarState.value != SeekbarState.ProgressBar } } val isSwitching by remember { derivedStateOf { seekbarState.value is SeekbarState.Switcher } } - - val seekbarValue = remember { - derivedStateOf { - when { - seekbarState.value is SeekbarState.ProgressBar -> progressKeeper.nowValue - else -> dataValue() - } - } + val isCanceled by remember { + derivedStateOf { seekbarState.value is SeekbarState.Cancel || seekbarState.value is SeekbarState.Dispatcher } } val resultValue = remember { derivedStateOf { - if (isSwitching) dataValue() - else if (isTouching && !isCanceled) progressKeeper.nowValue - else dataValue() + when { + isSwitching -> { + progressKeeper.updateValue(dataValue()) + false to dataValue() + } + + isTouching && !isCanceled -> true to progressKeeper.nowValue + else -> { + progressKeeper.updateValue(dataValue()) + false to dataValue() + } + } } } // 使值的变化平滑 val animateValue = animateFloatAsState( - targetValue = resultValue.value, + targetValue = resultValue.value.second, + animationSpec = if (resultValue.value.first) snap() else spring(stiffness = Spring.StiffnessLow), visibilityThreshold = 0.005f, - animationSpec = spring(stiffness = Spring.StiffnessLow), label = "" ) val bgAlpha = animateFloatAsState( - targetValue = if (isTouching) 1f else 0f, + targetValue = if (isTouching && !isCanceled) 1f else 0f, animationSpec = spring(stiffness = Spring.StiffnessLow), label = "" ) @@ -194,7 +195,7 @@ fun SeekbarLayout2( } oldState == SeekbarState.ProgressBar -> { - progressKeeper.updateProgressByDelta(delta = deltaX) + progressKeeper.updateValueByDelta(delta = deltaX) } } @@ -256,7 +257,7 @@ fun SeekbarLayout2( when (event.type) { PointerEventType.Press -> { // 开始触摸时,将当前可见的进度值记录下来 - progressKeeper.updateProgress(animateValue.value) + progressKeeper.updateValue(animateValue.value) isTouching = true isMoved = false } @@ -288,6 +289,7 @@ fun SeekbarLayout2( haptic.performHapticFeedback(HapticFeedbackType.LongPress) switchModeX.floatValue = it.x switchMode.value = true + seekbarState.value = SeekbarState.Switcher }, onDragStart = { isMoved = true @@ -498,19 +500,18 @@ class SeekbarProgressKeeper( private val minValue: () -> Float, private val maxValue: () -> Float, private val sizeWidth: () -> Float, - private val dataValue: () -> Float, private val scrollSensitivity: Float, ) { var nowValue: Float by mutableFloatStateOf(0f) private set - fun updateProgress(value: Float) { + fun updateValue(value: Float) { nowValue = value.coerceIn(minValue(), maxValue()) } - fun updateProgressByDelta(delta: Float) { + fun updateValueByDelta(delta: Float) { val value = nowValue + delta / sizeWidth() * (maxValue() - minValue()) * scrollSensitivity - updateProgress(value) + updateValue(value) } } @@ -519,7 +520,6 @@ fun rememberSeekbarProgressKeeper( minValue: () -> Float, maxValue: () -> Float, sizeWidth: () -> Float, - dataValue: () -> Float, scrollSensitivity: Float = 1f ): SeekbarProgressKeeper { return remember { @@ -527,7 +527,6 @@ fun rememberSeekbarProgressKeeper( minValue = minValue, maxValue = maxValue, sizeWidth = sizeWidth, - dataValue = dataValue, scrollSensitivity = scrollSensitivity ) } From d76c7960a6de41e19e9e1b43e6e57f90f6aaa0d4 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 24 Nov 2024 23:53:18 +0800 Subject: [PATCH 118/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1=E7=9A=84=E5=88=87=E6=8D=A2=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/PlayingLayout.kt | 4 +- .../SeekbarLayout.kt} | 112 +++++++----------- .../playing/seekbar/SeekbarProgressKeeper.kt | 43 +++++++ 3 files changed, 92 insertions(+), 67 deletions(-) rename app/src/main/java/com/lalilu/lmusic/compose/screen/playing/{SeekbarLayout2.kt => seekbar/SeekbarLayout.kt} (87%) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarProgressKeeper.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index ac44be68f..e6dfb4f36 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -50,6 +50,8 @@ import com.lalilu.lmedia.lyric.LyricSourceEmbedded import com.lalilu.lmedia.lyric.LyricUtils import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar +import com.lalilu.lmusic.compose.screen.playing.seekbar.ClickPart +import com.lalilu.lmusic.compose.screen.playing.seekbar.SeekbarLayout import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction @@ -314,7 +316,7 @@ fun PlayingLayout( translationY = (1f - animateProgress.value / 100f) * 500f } ) { - SeekbarLayout2( + SeekbarLayout( modifier = Modifier.hideControl(enable = { hideComponent.value }), animateColor = { animateColor.value }, onValueChange = { seekbarTime.longValue = it.toLong() }, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt similarity index 87% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt rename to app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt index d1b6213cb..50bfda90a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout2.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.screen.playing +package com.lalilu.lmusic.compose.screen.playing.seekbar import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.Spring @@ -55,12 +55,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp import com.lalilu.common.AccumulatedValue import com.lalilu.lmusic.utils.extension.durationToTime import kotlinx.coroutines.launch import kotlin.math.absoluteValue -sealed class SeekbarState { +private sealed class SeekbarState { data object Idle : SeekbarState() data object ProgressBar : SeekbarState() data object Switcher : SeekbarState() @@ -76,17 +77,19 @@ sealed interface ClickPart { @Preview @Composable -fun SeekbarLayout2( +fun SeekbarLayout( modifier: Modifier = Modifier, minValue: () -> Float = { 0f }, maxValue: () -> Float = { 0f }, dataValue: () -> Float = { 0f }, + switchIndex: () -> Int = { 0 }, animateColor: () -> Color = { Color.DarkGray }, onDragStart: suspend (Offset) -> Unit = {}, onDragStop: suspend (Int) -> Unit = {}, onDispatchDragOffset: (Float) -> Unit = {}, onValueChange: (Float) -> Unit = {}, onSeekTo: (Float) -> Unit = {}, + onSwitchTo: (Int) -> Unit = {}, onClick: (ClickPart) -> Unit = {} ) { val haptic = LocalHapticFeedback.current @@ -144,15 +147,16 @@ fun SeekbarLayout2( visibilityThreshold = 0.005f, label = "" ) - - val bgAlpha = animateFloatAsState( + val touchingProgress = animateFloatAsState( targetValue = if (isTouching && !isCanceled) 1f else 0f, animationSpec = spring(stiffness = Spring.StiffnessLow), + visibilityThreshold = 0.001f, label = "" ) - val textAlpha = animateFloatAsState( - targetValue = if (isSwitching) 0f else 1f, + val switchingProgress = animateFloatAsState( + targetValue = if (isSwitching) 1f else 0f, animationSpec = spring(stiffness = Spring.StiffnessLow), + visibilityThreshold = 0.001f, label = "" ) val textStyle = remember { @@ -191,7 +195,7 @@ fun SeekbarLayout2( // 根据当前状态控制进度变量 when { isSwitching -> { - switchModeX.floatValue = offset.x + switchModeX.floatValue += deltaX } oldState == SeekbarState.ProgressBar -> { @@ -263,7 +267,7 @@ fun SeekbarLayout2( } PointerEventType.Release -> { - if (isMoved && !isCanceled) { + if (isMoved && !isCanceled && !isSwitching) { onSeekTo(progressKeeper.nowValue) } isTouching = false @@ -303,7 +307,6 @@ fun SeekbarLayout2( switchMode.value = false seekbarState.value = SeekbarState.Idle - switchModeX.floatValue = 0f seekbarOffsetY.floatValue = 0f scope.launch { onDragStop(0) } }, @@ -356,17 +359,17 @@ fun SeekbarLayout2( .measure(text = maxValueText, style = textStyle) val maxPadding = 4.dp.toPx() - val paddingAnimate = maxPadding * bgAlpha.value + val paddingValue = maxPadding * touchingProgress.value - val innerRadius = 16.dp.toPx() - paddingAnimate - val innerHeight = size.height - (paddingAnimate * 2f) - val innerWidth = size.width - (paddingAnimate * 2f) + val innerRadius = 16.dp.toPx() - paddingValue + val innerHeight = size.height - (paddingValue * 2f) + val innerWidth = size.width - (paddingValue * 2f) innerPath.reset() innerPath.addRoundRect( RoundRect( rect = Rect( - offset = Offset(x = paddingAnimate, y = paddingAnimate), + offset = Offset(x = paddingValue, y = paddingValue), size = Size(width = innerWidth, height = innerHeight) ), cornerRadius = CornerRadius(innerRadius, innerRadius) @@ -377,15 +380,34 @@ fun SeekbarLayout2( val actualProgress = actualValue.normalize(minValue(), maxValue()) onValueChange(actualValue) -// // 通过Value计算Progress,从而获取滑块应有的宽度 -// thumbWidth = normalize(nowValue, minValue, maxValue) * actualWidth -// thumbWidth = lerp(thumbWidth, actualWidth / thumbCount, switchModeProgress) - - val thumbWidth = innerWidth * actualProgress + val thumbWidth = lerp( + start = innerWidth * actualProgress, // 根据进度计算的宽度 + stop = innerWidth / 3f, // 进度条均分宽度 + fraction = switchingProgress.value // 根据切换进度进行插值 + ).coerceIn(0f, innerWidth) + + val thumbLeft = lerp( + start = paddingValue, + stop = paddingValue + switchModeX.floatValue - (innerWidth / 3f) / 2f, + fraction = switchingProgress.value + ).coerceIn( + paddingValue, + paddingValue + innerWidth - (innerWidth / 3f) + ) // 限制滑块位置,确保其始终处于可见范围内 + + val thumbTop = paddingValue val thumbHeight = innerHeight + val textX = + (paddingValue + (innerWidth * actualProgress) - 16.dp.toPx() - textSize.value.first) + .let { accumulator.accumulate(it) } + .coerceAtLeast(16.dp.roundToPx()) + // 纯色背景 - drawRect(color = bgColor, alpha = bgAlpha.value) + drawRect( + color = bgColor, + alpha = touchingProgress.value + ) // 圆角裁切 clipPath(innerPath) { @@ -395,7 +417,7 @@ fun SeekbarLayout2( drawText( textLayoutResult = maxTextResult, color = Color.White, - alpha = textAlpha.value, + alpha = 1f - switchingProgress.value, topLeft = Offset( x = size.width - textSize.value.first - 16.dp.toPx(), y = (size.height - textSize.value.second) / 2f @@ -406,20 +428,15 @@ fun SeekbarLayout2( drawRoundRect( color = animateColor(), cornerRadius = CornerRadius(innerRadius, innerRadius), - topLeft = Offset(x = paddingAnimate, y = paddingAnimate), + topLeft = Offset(x = thumbLeft, y = thumbTop), size = Size(width = thumbWidth, height = thumbHeight) ) - val textX = - (paddingAnimate + thumbWidth - 16.dp.toPx() - textSize.value.first) - .let { accumulator.accumulate(it) } - .coerceAtLeast(16.dp.roundToPx()) - // 绘制实时进度文本(移动) drawText( textLayoutResult = currentTextResult, color = Color.White, - alpha = textAlpha.value, + alpha = 1f - switchingProgress.value, topLeft = Offset( x = textX.toFloat(), y = (size.height - textSize.value.second) / 2f @@ -495,43 +512,6 @@ private fun Modifier.combineDetectDrag( ) } - -class SeekbarProgressKeeper( - private val minValue: () -> Float, - private val maxValue: () -> Float, - private val sizeWidth: () -> Float, - private val scrollSensitivity: Float, -) { - var nowValue: Float by mutableFloatStateOf(0f) - private set - - fun updateValue(value: Float) { - nowValue = value.coerceIn(minValue(), maxValue()) - } - - fun updateValueByDelta(delta: Float) { - val value = nowValue + delta / sizeWidth() * (maxValue() - minValue()) * scrollSensitivity - updateValue(value) - } -} - -@Composable -fun rememberSeekbarProgressKeeper( - minValue: () -> Float, - maxValue: () -> Float, - sizeWidth: () -> Float, - scrollSensitivity: Float = 1f -): SeekbarProgressKeeper { - return remember { - SeekbarProgressKeeper( - minValue = minValue, - maxValue = maxValue, - sizeWidth = sizeWidth, - scrollSensitivity = scrollSensitivity - ) - } -} - private fun Float.normalize(minValue: Float, maxValue: Float): Float { val min = minOf(minValue, maxValue) val max = maxOf(minValue, maxValue) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarProgressKeeper.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarProgressKeeper.kt new file mode 100644 index 000000000..98b79d470 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarProgressKeeper.kt @@ -0,0 +1,43 @@ +package com.lalilu.lmusic.compose.screen.playing.seekbar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +internal class SeekbarProgressKeeper( + private val minValue: () -> Float, + private val maxValue: () -> Float, + private val sizeWidth: () -> Float, + private val scrollSensitivity: Float, +) { + var nowValue: Float by mutableFloatStateOf(0f) + private set + + fun updateValue(value: Float) { + nowValue = value.coerceIn(minValue(), maxValue()) + } + + fun updateValueByDelta(delta: Float) { + val value = nowValue + delta / sizeWidth() * (maxValue() - minValue()) * scrollSensitivity + updateValue(value) + } +} + +@Composable +internal fun rememberSeekbarProgressKeeper( + minValue: () -> Float, + maxValue: () -> Float, + sizeWidth: () -> Float, + scrollSensitivity: Float = 1f +): SeekbarProgressKeeper { + return remember { + SeekbarProgressKeeper( + minValue = minValue, + maxValue = maxValue, + sizeWidth = sizeWidth, + scrollSensitivity = scrollSensitivity + ) + } +} \ No newline at end of file From 576f6d4b6c0a08bd089eb78fce9c6b47a56c2af0 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 2 Dec 2024 02:09:09 +0800 Subject: [PATCH 119/213] =?UTF-8?q?[refactor]=E5=8E=BB=E9=99=A4FastKV?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E6=96=B0=E5=AE=9E=E7=8E=B0kv=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 17 +- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 4 +- common/build.gradle.kts | 3 +- .../main/java/com/lalilu/common/kv/BaseKV.kt | 70 ++--- .../main/java/com/lalilu/common/kv/KVImpl.kt | 280 +++++------------- .../main/java/com/lalilu/common/kv/KVItem.kt | 2 + .../java/com/lalilu/common/kv/KVListItem.kt | 168 ++++++++--- .../java/com/lalilu/common/kv/KVMapItem.kt | 54 ---- .../main/java/com/lalilu/lplayer/MPlayer.kt | 17 +- .../main/java/com/lalilu/lplayer/MPlayerKV.kt | 7 + .../com/lalilu/lplayer/service/MService.kt | 42 ++- .../com/lalilu/lplaylist/PlaylistModule.kt | 3 - .../com/lalilu/lplaylist/entity/LPlaylist.kt | 46 +-- .../lalilu/lplaylist/repository/PlaylistKV.kt | 6 +- .../repository/PlaylistRepositoryImpl.kt | 7 +- 15 files changed, 308 insertions(+), 418 deletions(-) delete mode 100644 common/src/main/java/com/lalilu/common/kv/KVMapItem.kt create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index d4dfc3d96..af5c1a966 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -9,14 +9,12 @@ import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.transitionFactory -import coil3.util.DebugLogger import com.lalilu.R import com.lalilu.common.base.SourceType import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.indexer.Filter import com.lalilu.lmedia.indexer.FilterGroup -import com.lalilu.lmedia.repository.LSongFastEncoder import com.lalilu.lmusic.Config.LRCSHARE_BASEURL import com.lalilu.lmusic.api.lrcshare.LrcShareApi import com.lalilu.lmusic.datastore.LastPlayedSp @@ -35,8 +33,6 @@ import com.lalilu.lmusic.utils.extension.toBitmap import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchLyricViewModel import com.lalilu.lmusic.viewmodel.SearchViewModel -import com.lalilu.lplaylist.entity.LPlaylistFastEncoder -import io.fastkv.FastKV import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext @@ -72,24 +68,13 @@ fun provideImageLoaderFactory( add(MediaItemFetcher.MediaItemFetcherFactory()) } .transitionFactory(CrossfadeTransitionFactory()) - .logger(DebugLogger()) +// .logger(DebugLogger()) .build() } } val AppModule = module { single { androidApplication() as ViewModelStoreOwner } - single { - FastKV.Builder(androidApplication(), "LMusic") - .encoder( - arrayOf( - LSongFastEncoder, - LPlaylistFastEncoder - ) - ) - .build() - } - single { SettingsSp(androidApplication()) } single { LastPlayedSp(androidApplication()) } single { TempSp(androidApplication()) } diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index c476b05ed..3af11a172 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -14,6 +14,7 @@ import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.indexer.FilterGroup import com.lalilu.lmedia.indexer.FilterProvider import com.lalilu.lmusic.utils.extension.ignoreSSLVerification +import com.lalilu.lplayer.MPlayer import com.lalilu.lplaylist.PlaylistModule import com.lalilu.lplaylist.PlaylistModule2 import com.zhangke.krouter.KRouter @@ -52,7 +53,8 @@ class LMusicApp : Application(), FilterProvider, ViewModelStoreOwner { ArtistModule.module, AlbumModule.module, DictionaryModule, - LMedia.module + LMedia.module, + MPlayer.module, ) SingletonImageLoader diff --git a/common/build.gradle.kts b/common/build.gradle.kts index a00cd54e6..29506cecf 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -32,9 +32,8 @@ dependencies { api(libs.dynamicanimation.ktx) api(libs.media) + api("com.russhwolf:multiplatform-settings:1.3.0") api("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0") - api("io.github.billywei01:fastkv:2.4.2") - api("io.github.billywei01:packable:1.1.0") api(libs.bundles.koin) api(libs.krouter.core) diff --git a/common/src/main/java/com/lalilu/common/kv/BaseKV.kt b/common/src/main/java/com/lalilu/common/kv/BaseKV.kt index ff0b9bfe6..6e56339c7 100644 --- a/common/src/main/java/com/lalilu/common/kv/BaseKV.kt +++ b/common/src/main/java/com/lalilu/common/kv/BaseKV.kt @@ -1,45 +1,39 @@ package com.lalilu.common.kv -import io.fastkv.FastKV -import io.fastkv.interfaces.FastEncoder +import java.io.Serializable @Suppress("UNCHECKED_CAST") -abstract class BaseKV { +abstract class BaseKV(val prefix: String = "") { val kvMap = LinkedHashMap>() - abstract val fastKV: FastKV - - inline fun obtain(key: String): KVItem = kvMap.getOrPut(key) { - when { - T::class.java.isAssignableFrom(Int::class.java) -> IntKVItem(key, fastKV) - T::class.java.isAssignableFrom(Long::class.java) -> LongKVItem(key, fastKV) - T::class.java.isAssignableFrom(Float::class.java) -> FloatKVItem(key, fastKV) - T::class.java.isAssignableFrom(Double::class.java) -> DoubleKVItem(key, fastKV) - T::class.java.isAssignableFrom(Boolean::class.java) -> BooleanKVItem(key, fastKV) - T::class.java.isAssignableFrom(String::class.java) -> StringKVItem(key, fastKV) - T::class.java.isAssignableFrom(ByteArray::class.java) -> ByteArrayKVItem(key, fastKV) - else -> throw IllegalArgumentException("Unsupported type") - } - } as KVItem - - inline fun obtainList(key: String): KVListItem = kvMap.getOrPut(key) { - when { - T::class.java.isAssignableFrom(Int::class.java) -> IntListKVItem(key, fastKV) - T::class.java.isAssignableFrom(Long::class.java) -> LongListKVItem(key, fastKV) - T::class.java.isAssignableFrom(Float::class.java) -> FloatListKVItem(key, fastKV) - T::class.java.isAssignableFrom(Double::class.java) -> DoubleListKVItem(key, fastKV) - T::class.java.isAssignableFrom(Boolean::class.java) -> BooleanListKVItem(key, fastKV) - T::class.java.isAssignableFrom(String::class.java) -> StringListKVItem(key, fastKV) - else -> throw IllegalArgumentException("Unsupported type") - } - } as KVListItem - - inline fun obtain(key: String, encoder: FastEncoder): KVItem = - kvMap.getOrPut(key) { ObjectKVItem(key, fastKV, encoder) } as KVItem - - inline fun obtainList(key: String, encoder: FastEncoder): KVListItem = - kvMap.getOrPut(key) { ObjectListKVItem(key, fastKV, encoder) } as KVListItem - - inline fun obtainMap(key: String, encoder: FastEncoder): KVMapItem = - kvMap.getOrPut(key) { ObjectMapKVItem(key, fastKV, encoder) } as KVMapItem + inline fun obtain(key: String): KVItem { + val actualKey = if (prefix.isNotBlank()) "${prefix}_$key" else key + return kvMap.getOrPut(actualKey) { + when { + T::class == Boolean::class -> BoolKVImpl(actualKey) + T::class == Int::class -> IntKVImpl(actualKey) + T::class == Long::class -> LongKVImpl(actualKey) + T::class == Float::class -> FloatKVImpl(actualKey) + T::class == Double::class -> DoubleKVImpl(actualKey) + T::class == String::class -> StringKVImpl(actualKey) + else -> ObjectKVImpl(actualKey, T::class.java) + } + } as KVItem + } + + inline fun obtainList(key: String): KVListItem { + val actualKey = if (prefix.isNotBlank()) "${prefix}_$key" else key + + return kvMap.getOrPut(actualKey) { + when { + T::class == Boolean::class -> BoolListKVImpl(actualKey) + T::class == Int::class -> IntListKVImpl(actualKey) + T::class == Long::class -> LongListKVImpl(actualKey) + T::class == Float::class -> FloatListKVImpl(actualKey) + T::class == Double::class -> DoubleListKVImpl(actualKey) + T::class == String::class -> StringListKVImpl(actualKey) + else -> throw IllegalArgumentException("Unsupported type") + } + } as KVListItem + } } diff --git a/common/src/main/java/com/lalilu/common/kv/KVImpl.kt b/common/src/main/java/com/lalilu/common/kv/KVImpl.kt index 3dd880660..79e802df4 100644 --- a/common/src/main/java/com/lalilu/common/kv/KVImpl.kt +++ b/common/src/main/java/com/lalilu/common/kv/KVImpl.kt @@ -1,258 +1,138 @@ package com.lalilu.common.kv -import io.fastkv.FastKV -import io.fastkv.interfaces.FastEncoder +import com.blankj.utilcode.util.GsonUtils +import com.blankj.utilcode.util.SPUtils +import java.io.Serializable -class IntKVItem( + +class BoolKVImpl( val key: String, - private val fastKV: FastKV -) : KVItem() { - override fun get(): Int? { - return if (!fastKV.contains(key)) null - else fastKV.getInt(key) +) : KVItem() { + override fun get(): Boolean? { + if (!SPUtils.getInstance().contains(key)) return null + return SPUtils.getInstance().getBoolean(key) } - override fun set(value: Int?) { - if (value == null) fastKV.remove(key) - else fastKV.putInt(key, value) + override fun set(value: Boolean?) { + super.set(value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + SPUtils.getInstance().put(key, value) + } } } - -class LongKVItem( +class IntKVImpl( val key: String, - private val fastKV: FastKV -) : KVItem() { - - override fun get(): Long? { - return if (!fastKV.contains(key)) null - else fastKV.getLong(key) +) : KVItem() { + override fun get(): Int? { + if (!SPUtils.getInstance().contains(key)) return null + return SPUtils.getInstance().getInt(key) } - override fun set(value: Long?) { - if (value == null) fastKV.remove(key) - else fastKV.putLong(key, value) + override fun set(value: Int?) { + super.set(value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + SPUtils.getInstance().put(key, value) + } } } -class BooleanKVItem( +class LongKVImpl( val key: String, - private val fastKV: FastKV -) : KVItem() { - override fun get(): Boolean? { - return if (!fastKV.contains(key)) null - else fastKV.getBoolean(key) +) : KVItem() { + override fun get(): Long? { + if (!SPUtils.getInstance().contains(key)) return null + return SPUtils.getInstance().getLong(key) } - override fun set(value: Boolean?) { + override fun set(value: Long?) { super.set(value) - if (value == null) fastKV.remove(key) - else fastKV.putBoolean(key, value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + SPUtils.getInstance().put(key, value) + } } } -class FloatKVItem( +class FloatKVImpl( val key: String, - private val fastKV: FastKV ) : KVItem() { override fun get(): Float? { - return if (!fastKV.contains(key)) null - else fastKV.getFloat(key) + if (!SPUtils.getInstance().contains(key)) return null + return SPUtils.getInstance().getFloat(key) } override fun set(value: Float?) { super.set(value) - if (value == null) fastKV.remove(key) - else fastKV.putFloat(key, value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + SPUtils.getInstance().put(key, value) + } } } - -class DoubleKVItem( +class StringKVImpl( val key: String, - private val fastKV: FastKV -) : KVItem() { - override fun get(): Double? { - return if (!fastKV.contains(key)) null - else fastKV.getDouble(key) - } - - override fun set(value: Double?) { - super.set(value) - if (value == null) fastKV.remove(key) - else fastKV.putDouble(key, value) - } -} - -class StringKVItem( - val key: String, - private val fastKV: FastKV ) : KVItem() { override fun get(): String? { - return if (!fastKV.contains(key)) null - else fastKV.getString(key) + if (!SPUtils.getInstance().contains(key)) return null + return SPUtils.getInstance().getString(key) } override fun set(value: String?) { super.set(value) - if (value == null) fastKV.remove(key) - else fastKV.putString(key, value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + SPUtils.getInstance().put(key, value) + } } } -class ByteArrayKVItem( +class DoubleKVImpl( val key: String, - private val fastKV: FastKV -) : KVItem() { - override fun get(): ByteArray? { - return if (!fastKV.contains(key)) null - else fastKV.getArray(key) +) : KVItem() { + override fun get(): Double? { + if (!SPUtils.getInstance().contains(key)) return null + val bitValue = SPUtils.getInstance().getLong(key) + return Double.fromBits(bitValue) } - override fun set(value: ByteArray?) { + override fun set(value: Double?) { super.set(value) - if (value == null) fastKV.remove(key) - else fastKV.putArray(key, value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val bitValue = value.toRawBits() + SPUtils.getInstance().put(key, bitValue) + } } } -class ObjectKVItem( - val key: String, - private val fastKV: FastKV, - private val encoder: FastEncoder +class ObjectKVImpl( + private val key: String, + private val clazz: Class ) : KVItem() { override fun get(): T? { - return if (!fastKV.contains(key)) null - else fastKV.getObject(key) + if (!SPUtils.getInstance().contains(key)) return null + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson(json, clazz) } override fun set(value: T?) { super.set(value) - if (value == null) fastKV.remove(key) - else fastKV.putObject(key, value, encoder) - } -} - - -class IntListKVItem( - val key: String, - private val fastKV: FastKV -) : KVListItem(key, fastKV) { - override fun set(key: String, value: Int?) { - if (value == null) fastKV.remove(key) - else fastKV.putInt(key, value) - } - - override fun get(key: String): Int? { - return if (!fastKV.contains(key)) null - else fastKV.getInt(key) - } -} - -class LongListKVItem( - val key: String, - private val fastKV: FastKV -) : KVListItem(key, fastKV) { - override fun set(key: String, value: Long?) { - if (value == null) fastKV.remove(key) - else fastKV.putLong(key, value) - } - - override fun get(key: String): Long? { - return if (!fastKV.contains(key)) null - else fastKV.getLong(key) - } -} - -class BooleanListKVItem( - val key: String, - private val fastKV: FastKV -) : KVListItem(key, fastKV) { - override fun set(key: String, value: Boolean?) { - if (value == null) fastKV.remove(key) - else fastKV.putBoolean(key, value) - } - - override fun get(key: String): Boolean? { - return if (!fastKV.contains(key)) null - else fastKV.getBoolean(key) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) + } } } -class FloatListKVItem( - val key: String, - private val fastKV: FastKV -) : KVListItem(key, fastKV) { - override fun set(key: String, value: Float?) { - if (value == null) fastKV.remove(key) - else fastKV.putFloat(key, value) - } - - override fun get(key: String): Float? { - return if (!fastKV.contains(key)) null - else fastKV.getFloat(key) - } -} - -class DoubleListKVItem( - val key: String, - private val fastKV: FastKV -) : KVListItem(key, fastKV) { - override fun set(key: String, value: Double?) { - if (value == null) fastKV.remove(key) - else fastKV.putDouble(key, value) - } - - override fun get(key: String): Double? { - return if (!fastKV.contains(key)) null - else fastKV.getDouble(key) - } -} - -class StringListKVItem( - val key: String, - private val fastKV: FastKV -) : KVListItem(key, fastKV) { - override fun set(key: String, value: String?) { - if (value == null) fastKV.remove(key) - else fastKV.putString(key, value) - } - - override fun get(key: String): String? { - return if (!fastKV.contains(key)) null - else fastKV.getString(key) - } -} - - -class ObjectListKVItem( - val key: String, - private val fastKV: FastKV, - private val encoder: FastEncoder -) : KVListItem(key, fastKV) { - override fun set(key: String, value: T?) { - if (value == null) fastKV.remove(key) - else fastKV.putObject(key, value, encoder) - } - - override fun get(key: String): T? { - return if (!fastKV.contains(key)) null - else fastKV.getObject(key) - } -} - -class ObjectMapKVItem( - val key: String, - private val fastKV: FastKV, - private val encoder: FastEncoder -) : KVMapItem(key, fastKV) { - override fun set(key: String, value: T?) { - if (value == null) fastKV.remove(key) - else fastKV.putObject(key, value, encoder) - } - - override fun get(key: String): T? { - return if (!fastKV.contains(key)) null - else fastKV.getObject(key) - } -} \ No newline at end of file diff --git a/common/src/main/java/com/lalilu/common/kv/KVItem.kt b/common/src/main/java/com/lalilu/common/kv/KVItem.kt index e141d5477..d78a0a3dc 100644 --- a/common/src/main/java/com/lalilu/common/kv/KVItem.kt +++ b/common/src/main/java/com/lalilu/common/kv/KVItem.kt @@ -1,5 +1,6 @@ package com.lalilu.common.kv +import androidx.annotation.CallSuper import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import kotlinx.coroutines.flow.Flow @@ -36,6 +37,7 @@ abstract class KVItem : MutableState, ReadWriteProperty, T?>, U override fun enableAutoSave() = run { autoSave = true } override fun disableAutoSave() = run { autoSave = false } + @CallSuper override fun set(value: T?) { flowInternal.tryEmit(value) } diff --git a/common/src/main/java/com/lalilu/common/kv/KVListItem.kt b/common/src/main/java/com/lalilu/common/kv/KVListItem.kt index 6f39cef71..efc7f46c3 100644 --- a/common/src/main/java/com/lalilu/common/kv/KVListItem.kt +++ b/common/src/main/java/com/lalilu/common/kv/KVListItem.kt @@ -1,70 +1,154 @@ package com.lalilu.common.kv -import com.blankj.utilcode.util.EncryptUtils -import com.blankj.utilcode.util.LogUtils -import io.fastkv.FastKV +import com.blankj.utilcode.util.GsonUtils +import com.blankj.utilcode.util.SPUtils +import com.google.common.reflect.TypeToken +import java.io.Serializable -abstract class KVListItem( - private val key: String, - private val fastKV: FastKV -) : KVItem>() { - private val identityKey by lazy { - if (key.length <= 20) return@lazy key +abstract class KVListItem : KVItem>() - key.take(20) + EncryptUtils.encryptMD5ToString(key).take(8) +@Suppress("UnstableApiUsage") +class StringListKVImpl( + private val key: String +) : KVListItem() { + companion object { + val typeToken by lazy { object : TypeToken>() {} } + } + + override fun get(): List? { + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson>(json, typeToken.type) + } + + override fun set(value: List?) { + super.set(value) + + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) + } } +} +@Suppress("UnstableApiUsage") +class IntListKVImpl( + private val key: String +) : KVListItem() { companion object { - const val countKeyTemplate = "#COUNT_%s" - const val valueKeyTemplate = "#INDEX_%s_%d" + val typeToken by lazy { object : TypeToken>() {} } } - protected abstract fun set(key: String, value: T?) - protected abstract fun get(key: String): T? + override fun get(): List? { + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson>(json, typeToken.type) + } - override fun set(value: List?) { + override fun set(value: List?) { super.set(value) - val countKey = countKeyTemplate.format(identityKey) - // 若列表为null,则删除计数键 if (value == null) { - fastKV.remove(countKey) - return + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) } + } +} - // 若列表元素为空,则设计数键为0 - if (value.isEmpty()) { - fastKV.putInt(countKey, 0) - return - } +@Suppress("UnstableApiUsage") +class LongListKVImpl( + private val key: String +) : KVListItem() { + companion object { + val typeToken by lazy { object : TypeToken>() {} } + } + + override fun get(): List? { + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson>(json, typeToken.type) + } - // 先进行数据存入 - for (index in value.indices) { - val valueKey = valueKeyTemplate.format(identityKey, index) - set(valueKey, value[index]) + override fun set(value: List?) { + super.set(value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) } + } +} - // 后更新计数键值,避免数据更新失败时导致计数键丢失,进而导致后期读取时异常 - fastKV.putInt(countKey, value.size) +@Suppress("UnstableApiUsage") +class FloatListKVImpl( + private val key: String +) : KVListItem() { + companion object { + val typeToken by lazy { object : TypeToken>() {} } } - override fun get(): List? { - val countKey = countKeyTemplate.format(identityKey) + override fun get(): List? { + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson>(json, typeToken.type) + } - if (!fastKV.contains(countKey)) { - LogUtils.i("[$key] is undefined, return null [countKey: $countKey]") - return null + override fun set(value: List?) { + super.set(value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) } + } +} + +@Suppress("UnstableApiUsage") +class DoubleListKVImpl( + private val key: String +) : KVListItem() { + companion object { + val typeToken by lazy { object : TypeToken>() {} } + } - val count = fastKV.getInt(countKey) - if (count == 0) { - LogUtils.i("[$key] is empty, return emptyList [countKey: $countKey]") - return emptyList() + override fun get(): List? { + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson>(json, typeToken.type) + } + + override fun set(value: List?) { + super.set(value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) } + } +} + +@Suppress("UnstableApiUsage") +class BoolListKVImpl( + private val key: String +) : KVListItem() { + companion object { + val typeToken by lazy { object : TypeToken>() {} } + } - return (0..count).mapNotNull { - val valueKey = valueKeyTemplate.format(identityKey, it) - get(valueKey) + override fun get(): List? { + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson>(json, typeToken.type) + } + + override fun set(value: List?) { + super.set(value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) } } } \ No newline at end of file diff --git a/common/src/main/java/com/lalilu/common/kv/KVMapItem.kt b/common/src/main/java/com/lalilu/common/kv/KVMapItem.kt deleted file mode 100644 index cb7c0b13e..000000000 --- a/common/src/main/java/com/lalilu/common/kv/KVMapItem.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.lalilu.common.kv - -import com.blankj.utilcode.util.EncryptUtils -import io.fastkv.FastKV - -abstract class KVMapItem( - private val key: String, - private val fastKV: FastKV -) : KVItem>() { - private val identityKey by lazy { - if (key.length <= 20) return@lazy key - - key.take(20) + EncryptUtils.encryptMD5ToString(key).take(8) - } - private val mapKey by lazy { mapKeyTemplate.format(identityKey) } - - companion object { - const val mapKeyTemplate = "#MAP_%s" - const val itemKeyTemplate = "#ITEM_%s_%s" - } - - protected abstract fun set(key: String, value: T?) - protected abstract fun get(key: String): T? - - override fun get(): Map? { - return fastKV.getStringSet(mapKey) - .mapNotNull { str -> - str?.let { itemKeyTemplate.format(identityKey, it) } - ?.let { get(str) } - ?.let { str to it } - } - .toMap() - } - - override fun set(value: Map?) { - super.set(value) - - if (value == null) { - fastKV.remove(mapKey) - return - } - - if (value.isEmpty()) { - fastKV.putStringSet(mapKey, emptySet()) - return - } - - for (str in value.entries) { - val itemKey = itemKeyTemplate.format(identityKey, str.key) - set(itemKey, str.value) - } - fastKV.putStringSet(mapKey, value.keys) - } -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index 18dfaef0d..39ae572ce 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -17,11 +17,13 @@ import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.Utils import com.lalilu.lplayer.extensions.PlayerAction import com.lalilu.lplayer.service.MService -import com.lalilu.lplayer.service.MServiceCallback +import com.lalilu.lplayer.service.getHistoryItems +import com.lalilu.lplayer.service.saveHistoryIds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch +import org.koin.dsl.module import kotlin.coroutines.CoroutineContext @OptIn(UnstableApi::class) @@ -37,6 +39,9 @@ object MPlayer : CoroutineScope { .buildAsync() } + val module = module { + } + var isPlaying: Boolean by mutableStateOf(false) private set var currentMediaItem by mutableStateOf(null) @@ -68,11 +73,8 @@ object MPlayer : CoroutineScope { val browser = browserFuture.await() browser.addListener(getListener(browser)) - val items = browser.getChildren(MServiceCallback.ALL_SONGS, 0, Int.MAX_VALUE, null) - .await() - .value - - if (items.isNullOrEmpty()) { + val items = getHistoryItems() + if (items.isEmpty()) { LogUtils.i("No songs found") return@launch } @@ -172,6 +174,9 @@ object MPlayer : CoroutineScope { ) { val items = timeline.toMediaItems() currentTimelineItems = items.drop(currentIndex) + items.take(currentIndex) + + val ids = currentTimelineItems.map { it.mediaId } + saveHistoryIds(mediaIds = ids) } } } diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt new file mode 100644 index 000000000..2f516a5f4 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt @@ -0,0 +1,7 @@ +package com.lalilu.lplayer + +import com.lalilu.common.kv.BaseKV + +object MPlayerKV : BaseKV(prefix = "mplayer") { + val historyPlaylistIds = obtainList("history_playlist_ids") +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index 29093f083..33c24dff5 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -1,5 +1,8 @@ package com.lalilu.lplayer.service +import android.app.PendingIntent +import android.content.Context +import android.content.Intent import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata @@ -11,14 +14,18 @@ import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession import androidx.media3.session.SessionError +import com.blankj.utilcode.util.ActivityUtils +import com.blankj.utilcode.util.AppUtils import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.lalilu.lmedia.LMedia +import com.lalilu.lplayer.MPlayerKV import com.lalilu.lplayer.extensions.FadeTransitionRenderersFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asExecutor import kotlin.coroutines.CoroutineContext @OptIn(UnstableApi::class) @@ -42,6 +49,7 @@ class MService : MediaLibraryService(), CoroutineScope { mediaSession = MediaLibrarySession .Builder(this, exoPlayer!!, MServiceCallback()) + .setSessionActivity(getLauncherPendingIntent()) .build() } @@ -159,9 +167,35 @@ class MServiceCallback : MediaLibrarySession.Callback { mediaSession: MediaSession, controller: MediaSession.ControllerInfo ): ListenableFuture { - // TODO 待完成继续播放的逻辑 - return Futures.immediateFuture( - MediaSession.MediaItemsWithStartPosition(emptyList(), 0, 0L) - ) + return Futures.submitAsync({ + Futures.immediateFuture( + MediaSession.MediaItemsWithStartPosition(getHistoryItems(), 0, 0L) + ) + }, Dispatchers.IO.asExecutor()) } +} + +private fun Context.getLauncherPendingIntent(): PendingIntent { + return PendingIntent.getActivity( + this, + 0, + Intent().apply { + setClassName( + AppUtils.getAppPackageName(), + ActivityUtils.getLauncherActivity() + ) + }, + PendingIntent.FLAG_IMMUTABLE + ) +} + +internal fun getHistoryItems(): List { + val history = MPlayerKV.historyPlaylistIds.get() + + return if (history != null) LMedia.mapItems(history) + else LMedia.getChildren(MServiceCallback.ALL_SONGS) +} + +internal fun saveHistoryIds(mediaIds: List) { + MPlayerKV.historyPlaylistIds.set(mediaIds) } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt index 895fa74b1..75d8a6ca2 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt @@ -1,6 +1,5 @@ package com.lalilu.lplaylist -import com.lalilu.lplaylist.repository.PlaylistKV import com.lalilu.lplaylist.repository.PlaylistRepository import com.lalilu.lplaylist.repository.PlaylistRepositoryImpl import com.lalilu.lplaylist.screen.create.PlaylistCreateOrEditScreenModel @@ -16,8 +15,6 @@ import org.koin.dsl.module object PlaylistModule2 val PlaylistModule = module { - singleOf(::PlaylistKV) - singleOf(::PlaylistRepositoryImpl) factoryOf(::PlaylistDetailScreenModel) factoryOf(::PlaylistCreateOrEditScreenModel) diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/entity/LPlaylist.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/entity/LPlaylist.kt index aa071a957..03eb942e3 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/entity/LPlaylist.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/entity/LPlaylist.kt @@ -1,10 +1,6 @@ package com.lalilu.lplaylist.entity -import io.fastkv.interfaces.FastEncoder -import io.packable.PackCreator -import io.packable.PackDecoder -import io.packable.PackEncoder -import io.packable.Packable +import java.io.Serializable data class LPlaylist( val id: String, @@ -12,42 +8,4 @@ data class LPlaylist( val subTitle: String, val coverUri: String, val mediaIds: List -) : Packable { - override fun encode(encoder: PackEncoder) { - encoder.putString(0, id) - .putString(1, title) - .putString(2, subTitle) - .putString(3, coverUri) - .putStringList(4, mediaIds) - } - - companion object CREATOR : PackCreator { - override fun decode(decoder: PackDecoder): LPlaylist? { - val id = decoder.getString(0) ?: return null - val title = decoder.getString(1) ?: "Unknown" - val subTitle = decoder.getString(2) ?: "" - val coverUri = decoder.getString(3) ?: "" - val mediaIds = decoder.getStringList(4) ?: emptyList() - - return LPlaylist( - id = id, - title = title, - subTitle = subTitle, - coverUri = coverUri, - mediaIds = mediaIds - ) - } - } -} - -object LPlaylistFastEncoder : FastEncoder { - override fun tag(): String = "LPlaylistFastEncoder" - - override fun decode(bytes: ByteArray, offset: Int, length: Int): LPlaylist { - return PackDecoder.unmarshal(bytes, offset, length, LPlaylist.CREATOR) - } - - override fun encode(obj: LPlaylist): ByteArray { - return PackEncoder.marshal(obj) - } -} \ No newline at end of file +) : Serializable \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistKV.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistKV.kt index 85569325e..3dfadfd4b 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistKV.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistKV.kt @@ -2,10 +2,8 @@ package com.lalilu.lplaylist.repository import com.lalilu.common.kv.BaseKV import com.lalilu.lplaylist.entity.LPlaylist -import com.lalilu.lplaylist.entity.LPlaylistFastEncoder -import io.fastkv.FastKV -class PlaylistKV(override val fastKV: FastKV) : BaseKV() { - val playlistList = obtainList(key = "PLAYLIST", LPlaylistFastEncoder) +object PlaylistKV : BaseKV() { + val playlistList = obtainList(key = "PLAYLIST") .apply { disableAutoSave() } } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt index 499ecd419..54addbcfa 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt @@ -10,23 +10,22 @@ import kotlinx.coroutines.flow.mapLatest @OptIn(ExperimentalCoroutinesApi::class) internal class PlaylistRepositoryImpl( - private val kv: PlaylistKV, private val context: Application, ) : PlaylistRepository { override fun getPlaylistsFlow(): Flow> { - return kv.playlistList.flow() + return PlaylistKV.playlistList.flow() .mapLatest { playlists -> playlists?.distinctBy { it.id } ?: emptyList() } } override fun getPlaylists(): List { - return kv.playlistList.value ?: emptyList() + return PlaylistKV.playlistList.value ?: emptyList() } override fun setPlaylists(playlists: List) { - kv.playlistList.apply { + PlaylistKV.playlistList.apply { value = playlists.distinctBy { it.id } if (!autoSave) save() } From 50c4a85b474ef3bf4a58d2237f15e4f8dc647858 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 2 Dec 2024 04:41:13 +0800 Subject: [PATCH 120/213] =?UTF-8?q?[refactor]=E5=8E=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E4=BB=A3=E7=A0=81=EF=BC=8C=E6=9B=B4=E6=96=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E7=89=88=E6=9C=AC=EF=BC=8C=E4=BF=AE=E6=AD=A3=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=E5=A4=B1=E8=B4=A5=E5=AF=BC=E8=87=B4=E9=97=AA?= =?UTF-8?q?=E9=80=80=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=92=AD=E6=94=BE=E5=99=A8=E7=9A=84kv?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 - .../compose/screen/playing/PlaylistLayout.kt | 4 +- .../util/BatchingListUpdateCallback.java | 121 +++ .../compose/screen/playing/util/DiffUtil.java | 887 ++++++++++++++++++ .../playing/util/ListUpdateCallback.java | 59 ++ .../lmusic/utils/extension/Extensions.kt | 14 - .../main/java/com/lalilu/common/kv/BaseKV.kt | 2 +- .../java/com/lalilu/common/kv/KVListItem.kt | 21 + component/build.gradle.kts | 24 +- gradle/libs.versions.toml | 45 +- lmedia | 2 +- lplayer/build.gradle.kts | 4 +- .../main/java/com/lalilu/lplayer/MPlayerKV.kt | 2 + .../com/lalilu/lplayer/service/MService.kt | 34 +- .../lalilu/lplaylist/screen/PlaylistScreen.kt | 3 +- settings.gradle.kts | 7 +- ui/.gitignore | 1 - ui/build.gradle.kts | 34 - ui/proguard-rules.pro | 21 - ui/src/main/AndroidManifest.xml | 4 - .../main/java/com/lalilu/ui/NewProgressBar.kt | 353 ------- ui/src/main/java/com/lalilu/ui/NewSeekBar.kt | 426 --------- ui/src/main/res/values/attrs.xml | 17 - 23 files changed, 1160 insertions(+), 926 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/BatchingListUpdateCallback.java create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/DiffUtil.java create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/ListUpdateCallback.java delete mode 100644 ui/.gitignore delete mode 100644 ui/build.gradle.kts delete mode 100644 ui/proguard-rules.pro delete mode 100644 ui/src/main/AndroidManifest.xml delete mode 100644 ui/src/main/java/com/lalilu/ui/NewProgressBar.kt delete mode 100644 ui/src/main/java/com/lalilu/ui/NewSeekBar.kt delete mode 100644 ui/src/main/res/values/attrs.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0fbf65a5..7b98977bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,7 +166,6 @@ composeCompiler { } dependencies { - implementation(project(":ui")) implementation(project(":crash")) implementation(project(":component")) implementation(project(":lplaylist")) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 62c8f76f9..4d6fcf1ca 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -33,10 +33,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.MediaItem -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback import coil3.compose.AsyncImage import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmusic.compose.screen.playing.util.DiffUtil +import com.lalilu.lmusic.compose.screen.playing.util.ListUpdateCallback import com.lalilu.lplayer.extensions.PlayerAction diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/BatchingListUpdateCallback.java b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/BatchingListUpdateCallback.java new file mode 100644 index 000000000..0eb6d60fc --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/BatchingListUpdateCallback.java @@ -0,0 +1,121 @@ +package com.lalilu.lmusic.compose.screen.playing.util; + +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; + + +public class BatchingListUpdateCallback implements ListUpdateCallback { + private static final int TYPE_NONE = 0; + private static final int TYPE_ADD = 1; + private static final int TYPE_REMOVE = 2; + private static final int TYPE_CHANGE = 3; + + final ListUpdateCallback mWrapped; + + int mLastEventType = TYPE_NONE; + int mLastEventPosition = -1; + int mLastEventCount = -1; + Object mLastEventPayload = null; + + public BatchingListUpdateCallback(@NonNull ListUpdateCallback callback) { + mWrapped = callback; + } + + /** + * BatchingListUpdateCallback holds onto the last event to see if it can be merged with the + * next one. When stream of events finish, you should call this method to dispatch the last + * event. + */ + public void dispatchLastEvent() { + if (mLastEventType == TYPE_NONE) { + return; + } + switch (mLastEventType) { + case TYPE_ADD: + mWrapped.onInserted(mLastEventPosition, mLastEventCount); + break; + case TYPE_REMOVE: + mWrapped.onRemoved(mLastEventPosition, mLastEventCount); + break; + case TYPE_CHANGE: + mWrapped.onChanged(mLastEventPosition, mLastEventCount, mLastEventPayload); + break; + } + mLastEventPayload = null; + mLastEventType = TYPE_NONE; + } + + /** {@inheritDoc} */ + @Override + public void onInserted(int position, int count) { + if (mLastEventType == TYPE_ADD && position >= mLastEventPosition + && position <= mLastEventPosition + mLastEventCount) { + mLastEventCount += count; + mLastEventPosition = Math.min(position, mLastEventPosition); + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventType = TYPE_ADD; + } + + /** {@inheritDoc} */ + @Override + public void onRemoved(int position, int count) { + if (mLastEventType == TYPE_REMOVE && mLastEventPosition >= position && + mLastEventPosition <= position + count) { + mLastEventCount += count; + mLastEventPosition = position; + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventType = TYPE_REMOVE; + } + + /** {@inheritDoc} */ + @Override + public void onMoved(int fromPosition, int toPosition) { + dispatchLastEvent(); // moves are not merged + mWrapped.onMoved(fromPosition, toPosition); + } + + /** {@inheritDoc} */ + @Override + @SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly + public void onChanged(int position, int count, Object payload) { + if (mLastEventType == TYPE_CHANGE && + !(position > mLastEventPosition + mLastEventCount + || position + count < mLastEventPosition || mLastEventPayload != payload)) { + // take potential overlap into account + int previousEnd = mLastEventPosition + mLastEventCount; + mLastEventPosition = Math.min(position, mLastEventPosition); + mLastEventCount = Math.max(previousEnd, position + count) - mLastEventPosition; + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventPayload = payload; + mLastEventType = TYPE_CHANGE; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/DiffUtil.java b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/DiffUtil.java new file mode 100644 index 000000000..25508874f --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/DiffUtil.java @@ -0,0 +1,887 @@ +package com.lalilu.lmusic.compose.screen.playing.util; + +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; + +public class DiffUtil { + private DiffUtil() { + // utility class, no instance. + } + + private static final Comparator DIAGONAL_COMPARATOR = new Comparator() { + @Override + public int compare(Diagonal o1, Diagonal o2) { + return o1.x - o2.x; + } + }; + + // Myers' algorithm uses two lists as axis labels. In DiffUtil's implementation, `x` axis is + // used for old list and `y` axis is used for new list. + + /** + * Calculates the list of update operations that can covert one list into the other one. + * + * @param cb The callback that acts as a gateway to the backing list data + * @return A DiffResult that contains the information about the edit sequence to convert the + * old list into the new list. + */ + @NonNull + public static DiffResult calculateDiff(@NonNull Callback cb) { + return calculateDiff(cb, true); + } + + /** + * Calculates the list of update operations that can covert one list into the other one. + *

+ * If your old and new lists are sorted by the same constraint and items never move (swap + * positions), you can disable move detection which takes O(N^2) time where + * N is the number of added, moved, removed items. + * + * @param cb The callback that acts as a gateway to the backing list data + * @param detectMoves True if DiffUtil should try to detect moved items, false otherwise. + * @return A DiffResult that contains the information about the edit sequence to convert the + * old list into the new list. + */ + @NonNull + public static DiffResult calculateDiff(@NonNull Callback cb, boolean detectMoves) { + final int oldSize = cb.getOldListSize(); + final int newSize = cb.getNewListSize(); + + final List diagonals = new ArrayList<>(); + + // instead of a recursive implementation, we keep our own stack to avoid potential stack + // overflow exceptions + final List stack = new ArrayList<>(); + + stack.add(new Range(0, oldSize, 0, newSize)); + + final int max = (oldSize + newSize + 1) / 2; + // allocate forward and backward k-lines. K lines are diagonal lines in the matrix. (see the + // paper for details) + // These arrays lines keep the max reachable position for each k-line. + final CenteredArray forward = new CenteredArray(max * 2 + 1); + final CenteredArray backward = new CenteredArray(max * 2 + 1); + + // We pool the ranges to avoid allocations for each recursive call. + final List rangePool = new ArrayList<>(); + while (!stack.isEmpty()) { + final Range range = stack.remove(stack.size() - 1); + final Snake snake = midPoint(range, cb, forward, backward); + if (snake != null) { + // if it has a diagonal, save it + if (snake.diagonalSize() > 0) { + diagonals.add(snake.toDiagonal()); + } + // add new ranges for left and right + final Range left = rangePool.isEmpty() ? new Range() : rangePool.remove( + rangePool.size() - 1); + left.oldListStart = range.oldListStart; + left.newListStart = range.newListStart; + left.oldListEnd = snake.startX; + left.newListEnd = snake.startY; + stack.add(left); + + // re-use range for right + //noinspection UnnecessaryLocalVariable + final Range right = range; + right.oldListEnd = range.oldListEnd; + right.newListEnd = range.newListEnd; + right.oldListStart = snake.endX; + right.newListStart = snake.endY; + stack.add(right); + } else { + rangePool.add(range); + } + + } + // sort snakes + Collections.sort(diagonals, DIAGONAL_COMPARATOR); + + return new DiffResult(cb, diagonals, + forward.backingData(), backward.backingData(), + detectMoves); + } + + /** + * Finds a middle snake in the given range. + */ + @Nullable + private static Snake midPoint( + Range range, + Callback cb, + CenteredArray forward, + CenteredArray backward) { + if (range.oldSize() < 1 || range.newSize() < 1) { + return null; + } + int max = (range.oldSize() + range.newSize() + 1) / 2; + forward.set(1, range.oldListStart); + backward.set(1, range.oldListEnd); + for (int d = 0; d < max; d++) { + Snake snake = forward(range, cb, forward, backward, d); + if (snake != null) { + return snake; + } + snake = backward(range, cb, forward, backward, d); + if (snake != null) { + return snake; + } + } + return null; + } + + @Nullable + private static Snake forward( + Range range, + Callback cb, + CenteredArray forward, + CenteredArray backward, + int d) { + boolean checkForSnake = Math.abs(range.oldSize() - range.newSize()) % 2 == 1; + int delta = range.oldSize() - range.newSize(); + for (int k = -d; k <= d; k += 2) { + // we either come from d-1, k-1 OR d-1. k+1 + // as we move in steps of 2, array always holds both current and previous d values + // k = x - y and each array value holds the max X, y = x - k + final int startX; + final int startY; + int x, y; + if (k == -d || (k != d && forward.get(k + 1) > forward.get(k - 1))) { + // picking k + 1, incrementing Y (by simply not incrementing X) + x = startX = forward.get(k + 1); + } else { + // picking k - 1, incrementing X + startX = forward.get(k - 1); + x = startX + 1; + } + y = range.newListStart + (x - range.oldListStart) - k; + startY = (d == 0 || x != startX) ? y : y - 1; + // now find snake size + while (x < range.oldListEnd + && y < range.newListEnd + && cb.areItemsTheSame(x, y)) { + x++; + y++; + } + // now we have furthest reaching x, record it + forward.set(k, x); + if (checkForSnake) { + // see if we did pass over a backwards array + // mapping function: delta - k + int backwardsK = delta - k; + // if backwards K is calculated and it passed me, found match + if (backwardsK >= -d + 1 + && backwardsK <= d - 1 + && backward.get(backwardsK) <= x) { + // match + Snake snake = new Snake(); + snake.startX = startX; + snake.startY = startY; + snake.endX = x; + snake.endY = y; + snake.reverse = false; + return snake; + } + } + } + return null; + } + + @Nullable + private static Snake backward( + Range range, + Callback cb, + CenteredArray forward, + CenteredArray backward, + int d) { + boolean checkForSnake = (range.oldSize() - range.newSize()) % 2 == 0; + int delta = range.oldSize() - range.newSize(); + // same as forward but we go backwards from end of the lists to be beginning + // this also means we'll try to optimize for minimizing x instead of maximizing it + for (int k = -d; k <= d; k += 2) { + // we either come from d-1, k-1 OR d-1, k+1 + // as we move in steps of 2, array always holds both current and previous d values + // k = x - y and each array value holds the MIN X, y = x - k + // when x's are equal, we prioritize deletion over insertion + final int startX; + final int startY; + int x, y; + + if (k == -d || (k != d && backward.get(k + 1) < backward.get(k - 1))) { + // picking k + 1, decrementing Y (by simply not decrementing X) + x = startX = backward.get(k + 1); + } else { + // picking k - 1, decrementing X + startX = backward.get(k - 1); + x = startX - 1; + } + y = range.newListEnd - ((range.oldListEnd - x) - k); + startY = (d == 0 || x != startX) ? y : y + 1; + // now find snake size + while (x > range.oldListStart + && y > range.newListStart + && cb.areItemsTheSame(x - 1, y - 1)) { + x--; + y--; + } + // now we have furthest point, record it (min X) + backward.set(k, x); + if (checkForSnake) { + // see if we did pass over a backwards array + // mapping function: delta - k + int forwardsK = delta - k; + // if forwards K is calculated and it passed me, found match + if (forwardsK >= -d + && forwardsK <= d + && forward.get(forwardsK) >= x) { + // match + Snake snake = new Snake(); + // assignment are reverse since we are a reverse snake + snake.startX = x; + snake.startY = y; + snake.endX = startX; + snake.endY = startY; + snake.reverse = true; + return snake; + } + } + } + return null; + } + + /** + * A Callback class used by DiffUtil while calculating the diff between two lists. + */ + public abstract static class Callback { + /** + * Returns the size of the old list. + * + * @return The size of the old list. + */ + public abstract int getOldListSize(); + + /** + * Returns the size of the new list. + * + * @return The size of the new list. + */ + public abstract int getNewListSize(); + + /** + * Called by the DiffUtil to decide whether two object represent the same Item. + *

+ * For example, if your items have unique ids, this method should check their id equality. + * + * @param oldItemPosition The position of the item in the old list + * @param newItemPosition The position of the item in the new list + * @return True if the two items represent the same object or false if they are different. + */ + public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition); + + public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition); + + + @Nullable + public Object getChangePayload(int oldItemPosition, int newItemPosition) { + return null; + } + } + + /** + * Callback for calculating the diff between two non-null items in a list. + *

+ * {@link Callback} serves two roles - list indexing, and item diffing. ItemCallback handles + * just the second of these, which allows separation of code that indexes into an array or List + * from the presentation-layer and content specific diffing code. + * + * @param Type of items to compare. + */ + public abstract static class ItemCallback { + /** + * Called to check whether two objects represent the same item. + *

+ * For example, if your items have unique ids, this method should check their id equality. + *

+ * Note: {@code null} items in the list are assumed to be the same as another {@code null} + * item and are assumed to not be the same as a non-{@code null} item. This callback will + * not be invoked for either of those cases. + * + * @param oldItem The item in the old list. + * @param newItem The item in the new list. + * @return True if the two items represent the same object or false if they are different. + * @see Callback#areItemsTheSame(int, int) + */ + public abstract boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem); + + + public abstract boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem); + + + @SuppressWarnings({"unused"}) + @Nullable + public Object getChangePayload(@NonNull T oldItem, @NonNull T newItem) { + return null; + } + } + + /** + * A diagonal is a match in the graph. + * Rather than snakes, we only record the diagonals in the path. + */ + static class Diagonal { + public final int x; + public final int y; + public final int size; + + Diagonal(int x, int y, int size) { + this.x = x; + this.y = y; + this.size = size; + } + + int endX() { + return x + size; + } + + int endY() { + return y + size; + } + } + + /** + * Snakes represent a match between two lists. It is optionally prefixed or postfixed with an + * add or remove operation. See the Myers' paper for details. + */ + @SuppressWarnings("WeakerAccess") + static class Snake { + /** + * Position in the old list + */ + public int startX; + + /** + * Position in the new list + */ + public int startY; + + /** + * End position in the old list, exclusive + */ + public int endX; + + /** + * End position in the new list, exclusive + */ + public int endY; + + /** + * True if this snake was created in the reverse search, false otherwise. + */ + public boolean reverse; + + boolean hasAdditionOrRemoval() { + return endY - startY != endX - startX; + } + + boolean isAddition() { + return endY - startY > endX - startX; + } + + int diagonalSize() { + return Math.min(endX - startX, endY - startY); + } + + /** + * Extract the diagonal of the snake to make reasoning easier for the rest of the + * algorithm where we try to produce a path and also find moves. + */ + @NonNull + Diagonal toDiagonal() { + if (hasAdditionOrRemoval()) { + if (reverse) { + // snake edge it at the end + return new Diagonal(startX, startY, diagonalSize()); + } else { + // snake edge it at the beginning + if (isAddition()) { + return new Diagonal(startX, startY + 1, diagonalSize()); + } else { + return new Diagonal(startX + 1, startY, diagonalSize()); + } + } + } else { + // we are a pure diagonal + return new Diagonal(startX, startY, endX - startX); + } + } + } + + /** + * Represents a range in two lists that needs to be solved. + *

+ * This internal class is used when running Myers' algorithm without recursion. + *

+ * Ends are exclusive + */ + static class Range { + + int oldListStart, oldListEnd; + + int newListStart, newListEnd; + + public Range() { + } + + public Range(int oldListStart, int oldListEnd, int newListStart, int newListEnd) { + this.oldListStart = oldListStart; + this.oldListEnd = oldListEnd; + this.newListStart = newListStart; + this.newListEnd = newListEnd; + } + + int oldSize() { + return oldListEnd - oldListStart; + } + + int newSize() { + return newListEnd - newListStart; + } + } + + public static class DiffResult { + /** + * Signifies an item not present in the list. + */ + public static final int NO_POSITION = -1; + + + /** + * While reading the flags below, keep in mind that when multiple items move in a list, + * Myers's may pick any of them as the anchor item and consider that one NOT_CHANGED while + * picking others as additions and removals. This is completely fine as we later detect + * all moves. + *

+ * Below, when an item is mentioned to stay in the same "location", it means we won't + * dispatch a move/add/remove for it, it DOES NOT mean the item is still in the same + * position. + */ + // item stayed the same. + private static final int FLAG_NOT_CHANGED = 1; + // item stayed in the same location but changed. + private static final int FLAG_CHANGED = FLAG_NOT_CHANGED << 1; + // Item has moved and also changed. + private static final int FLAG_MOVED_CHANGED = FLAG_CHANGED << 1; + // Item has moved but did not change. + private static final int FLAG_MOVED_NOT_CHANGED = FLAG_MOVED_CHANGED << 1; + // Item moved + private static final int FLAG_MOVED = FLAG_MOVED_CHANGED | FLAG_MOVED_NOT_CHANGED; + + // since we are re-using the int arrays that were created in the Myers' step, we mask + // change flags + private static final int FLAG_OFFSET = 4; + + private static final int FLAG_MASK = (1 << FLAG_OFFSET) - 1; + + // The diagonals extracted from The Myers' snakes. + private final List mDiagonals; + + // The list to keep oldItemStatuses. As we traverse old items, we assign flags to them + // which also includes whether they were a real removal or a move (and its new index). + private final int[] mOldItemStatuses; + // The list to keep newItemStatuses. As we traverse new items, we assign flags to them + // which also includes whether they were a real addition or a move(and its old index). + private final int[] mNewItemStatuses; + // The callback that was given to calculate diff method. + private final Callback mCallback; + + private final int mOldListSize; + + private final int mNewListSize; + + private final boolean mDetectMoves; + + /** + * @param callback The callback that was used to calculate the diff + * @param diagonals Matches between the two lists + * @param oldItemStatuses An int[] that can be re-purposed to keep metadata + * @param newItemStatuses An int[] that can be re-purposed to keep metadata + * @param detectMoves True if this DiffResult will try to detect moved items + */ + DiffResult(Callback callback, List diagonals, int[] oldItemStatuses, + int[] newItemStatuses, boolean detectMoves) { + mDiagonals = diagonals; + mOldItemStatuses = oldItemStatuses; + mNewItemStatuses = newItemStatuses; + Arrays.fill(mOldItemStatuses, 0); + Arrays.fill(mNewItemStatuses, 0); + mCallback = callback; + mOldListSize = callback.getOldListSize(); + mNewListSize = callback.getNewListSize(); + mDetectMoves = detectMoves; + addEdgeDiagonals(); + findMatchingItems(); + } + + /** + * Add edge diagonals so that we can iterate as long as there are diagonals w/o lots of + * null checks around + */ + private void addEdgeDiagonals() { + Diagonal first = mDiagonals.isEmpty() ? null : mDiagonals.get(0); + // see if we should add 1 to the 0,0 + if (first == null || first.x != 0 || first.y != 0) { + mDiagonals.add(0, new Diagonal(0, 0, 0)); + } + // always add one last + mDiagonals.add(new Diagonal(mOldListSize, mNewListSize, 0)); + } + + /** + * Find position mapping from old list to new list. + * If moves are requested, we'll also try to do an n^2 search between additions and + * removals to find moves. + */ + private void findMatchingItems() { + for (Diagonal diagonal : mDiagonals) { + for (int offset = 0; offset < diagonal.size; offset++) { + int posX = diagonal.x + offset; + int posY = diagonal.y + offset; + final boolean theSame = mCallback.areContentsTheSame(posX, posY); + final int changeFlag = theSame ? FLAG_NOT_CHANGED : FLAG_CHANGED; + mOldItemStatuses[posX] = (posY << FLAG_OFFSET) | changeFlag; + mNewItemStatuses[posY] = (posX << FLAG_OFFSET) | changeFlag; + } + } + // now all matches are marked, lets look for moves + if (mDetectMoves) { + // traverse each addition / removal from the end of the list, find matching + // addition removal from before + findMoveMatches(); + } + } + + private void findMoveMatches() { + // for each removal, find matching addition + int posX = 0; + for (Diagonal diagonal : mDiagonals) { + while (posX < diagonal.x) { + if (mOldItemStatuses[posX] == 0) { + // there is a removal, find matching addition from the rest + findMatchingAddition(posX); + } + posX++; + } + // snap back for the next diagonal + posX = diagonal.endX(); + } + } + + /** + * Search the whole list to find the addition for the given removal of position posX + * + * @param posX position in the old list + */ + private void findMatchingAddition(int posX) { + int posY = 0; + final int diagonalsSize = mDiagonals.size(); + for (int i = 0; i < diagonalsSize; i++) { + final Diagonal diagonal = mDiagonals.get(i); + while (posY < diagonal.y) { + // found some additions, evaluate + if (mNewItemStatuses[posY] == 0) { // not evaluated yet + boolean matching = mCallback.areItemsTheSame(posX, posY); + if (matching) { + // yay found it, set values + boolean contentsMatching = mCallback.areContentsTheSame(posX, posY); + final int changeFlag = contentsMatching ? FLAG_MOVED_NOT_CHANGED + : FLAG_MOVED_CHANGED; + // once we process one of these, it will mark the other one as ignored. + mOldItemStatuses[posX] = (posY << FLAG_OFFSET) | changeFlag; + mNewItemStatuses[posY] = (posX << FLAG_OFFSET) | changeFlag; + return; + } + } + posY++; + } + posY = diagonal.endY(); + } + } + + /** + * Given a position in the old list, returns the position in the new list, or + * {@code NO_POSITION} if it was removed. + * + * @param oldListPosition Position of item in old list + * @return Position of item in new list, or {@code NO_POSITION} if not present. + * @see #NO_POSITION + * @see #convertNewPositionToOld(int) + */ + public int convertOldPositionToNew(@IntRange(from = 0) int oldListPosition) { + if (oldListPosition < 0 || oldListPosition >= mOldListSize) { + throw new IndexOutOfBoundsException("Index out of bounds - passed position = " + + oldListPosition + ", old list size = " + mOldListSize); + } + final int status = mOldItemStatuses[oldListPosition]; + if ((status & FLAG_MASK) == 0) { + return NO_POSITION; + } else { + return status >> FLAG_OFFSET; + } + } + + /** + * Given a position in the new list, returns the position in the old list, or + * {@code NO_POSITION} if it was removed. + * + * @param newListPosition Position of item in new list + * @return Position of item in old list, or {@code NO_POSITION} if not present. + * @see #NO_POSITION + * @see #convertOldPositionToNew(int) + */ + public int convertNewPositionToOld(@IntRange(from = 0) int newListPosition) { + if (newListPosition < 0 || newListPosition >= mNewListSize) { + throw new IndexOutOfBoundsException("Index out of bounds - passed position = " + + newListPosition + ", new list size = " + mNewListSize); + } + final int status = mNewItemStatuses[newListPosition]; + if ((status & FLAG_MASK) == 0) { + return NO_POSITION; + } else { + return status >> FLAG_OFFSET; + } + } + + + public void dispatchUpdatesTo(@NonNull ListUpdateCallback updateCallback) { + final BatchingListUpdateCallback batchingCallback; + + if (updateCallback instanceof BatchingListUpdateCallback) { + batchingCallback = (BatchingListUpdateCallback) updateCallback; + } else { + batchingCallback = new BatchingListUpdateCallback(updateCallback); + // replace updateCallback with a batching callback and override references to + // updateCallback so that we don't call it directly by mistake + //noinspection UnusedAssignment + updateCallback = batchingCallback; + } + // track up to date current list size for moves + // when a move is found, we record its position from the end of the list (which is + // less likely to change since we iterate in reverse). + // Later when we find the match of that move, we dispatch the update + int currentListSize = mOldListSize; + // list of postponed moves + final Collection postponedUpdates = new ArrayDeque<>(); + // posX and posY are exclusive + int posX = mOldListSize; + int posY = mNewListSize; + // iterate from end of the list to the beginning. + // this just makes offsets easier since changes in the earlier indices has an effect + // on the later indices. + for (int diagonalIndex = mDiagonals.size() - 1; diagonalIndex >= 0; diagonalIndex--) { + final Diagonal diagonal = mDiagonals.get(diagonalIndex); + int endX = diagonal.endX(); + int endY = diagonal.endY(); + // dispatch removals and additions until we reach to that diagonal + // first remove then add so that it can go into its place and we don't need + // to offset values + while (posX > endX) { + posX--; + // REMOVAL + int status = mOldItemStatuses[posX]; + if ((status & FLAG_MOVED) != 0) { + int newPos = status >> FLAG_OFFSET; + // get postponed addition + PostponedUpdate postponedUpdate = getPostponedUpdate(postponedUpdates, + newPos, false); + if (postponedUpdate != null) { + // this is an addition that was postponed. Now dispatch it. + int updatedNewPos = currentListSize - postponedUpdate.currentPos; + batchingCallback.onMoved(posX, updatedNewPos - 1); + if ((status & FLAG_MOVED_CHANGED) != 0) { + Object changePayload = mCallback.getChangePayload(posX, newPos); + batchingCallback.onChanged(updatedNewPos - 1, 1, changePayload); + } + } else { + // first time we are seeing this, we'll see a matching addition + postponedUpdates.add(new PostponedUpdate( + posX, + currentListSize - posX - 1, + true + )); + } + } else { + // simple removal + batchingCallback.onRemoved(posX, 1); + currentListSize--; + } + } + while (posY > endY) { + posY--; + // ADDITION + int status = mNewItemStatuses[posY]; + if ((status & FLAG_MOVED) != 0) { + // this is a move not an addition. + // see if this is postponed + int oldPos = status >> FLAG_OFFSET; + // get postponed removal + PostponedUpdate postponedUpdate = getPostponedUpdate(postponedUpdates, + oldPos, true); + // empty size returns 0 for indexOf + if (postponedUpdate == null) { + // postpone it until we see the removal + postponedUpdates.add(new PostponedUpdate( + posY, + currentListSize - posX, + false + )); + } else { + // oldPosFromEnd = foundListSize - posX + // we can find posX if we swap the list sizes + // posX = listSize - oldPosFromEnd + int updatedOldPos = currentListSize - postponedUpdate.currentPos - 1; + batchingCallback.onMoved(updatedOldPos, posX); + if ((status & FLAG_MOVED_CHANGED) != 0) { + Object changePayload = mCallback.getChangePayload(oldPos, posY); + batchingCallback.onChanged(posX, 1, changePayload); + } + } + } else { + // simple addition + batchingCallback.onInserted(posX, 1); + currentListSize++; + } + } + // now dispatch updates for the diagonal + posX = diagonal.x; + posY = diagonal.y; + for (int i = 0; i < diagonal.size; i++) { + // dispatch changes + if ((mOldItemStatuses[posX] & FLAG_MASK) == FLAG_CHANGED) { + Object changePayload = mCallback.getChangePayload(posX, posY); + batchingCallback.onChanged(posX, 1, changePayload); + } + posX++; + posY++; + } + // snap back for the next diagonal + posX = diagonal.x; + posY = diagonal.y; + } + batchingCallback.dispatchLastEvent(); + } + + @Nullable + private static PostponedUpdate getPostponedUpdate( + Collection postponedUpdates, + int posInList, + boolean removal) { + PostponedUpdate postponedUpdate = null; + Iterator itr = postponedUpdates.iterator(); + while (itr.hasNext()) { + PostponedUpdate update = itr.next(); + if (update.posInOwnerList == posInList && update.removal == removal) { + postponedUpdate = update; + itr.remove(); + break; + } + } + while (itr.hasNext()) { + // re-offset all others + PostponedUpdate update = itr.next(); + if (removal) { + update.currentPos--; + } else { + update.currentPos++; + } + } + return postponedUpdate; + } + } + + /** + * Represents an update that we skipped because it was a move. + *

+ * When an update is skipped, it is tracked as other updates are dispatched until the matching + * add/remove operation is found at which point the tracked position is used to dispatch the + * update. + */ + private static class PostponedUpdate { + /** + * position in the list that owns this item + */ + int posInOwnerList; + + /** + * position wrt to the end of the list + */ + int currentPos; + + /** + * true if this is a removal, false otherwise + */ + boolean removal; + + PostponedUpdate(int posInOwnerList, int currentPos, boolean removal) { + this.posInOwnerList = posInOwnerList; + this.currentPos = currentPos; + this.removal = removal; + } + } + + /** + * Array wrapper w/ negative index support. + * We use this array instead of a regular array so that algorithm is easier to read without + * too many offsets when accessing the "k" array in the algorithm. + */ + static class CenteredArray { + private final int[] mData; + private final int mMid; + + CenteredArray(int size) { + mData = new int[size]; + mMid = mData.length / 2; + } + + int get(int index) { + return mData[index + mMid]; + } + + int[] backingData() { + return mData; + } + + void set(int index, int value) { + mData[index + mMid] = value; + } + + public void fill(int value) { + Arrays.fill(mData, value); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/ListUpdateCallback.java b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/ListUpdateCallback.java new file mode 100644 index 000000000..bdac414b0 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/ListUpdateCallback.java @@ -0,0 +1,59 @@ +package com.lalilu.lmusic.compose.screen.playing.util; + +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.annotation.Nullable; + +/** + * An interface that can receive Update operations that are applied to a list. + *

+ * This class can be used together with DiffUtil to detect changes between two lists. + */ +public interface ListUpdateCallback { + /** + * Called when {@code count} number of items are inserted at the given position. + * + * @param position The position of the new item. + * @param count The number of items that have been added. + */ + void onInserted(int position, int count); + + /** + * Called when {@code count} number of items are removed from the given position. + * + * @param position The position of the item which has been removed. + * @param count The number of items which have been removed. + */ + void onRemoved(int position, int count); + + /** + * Called when an item changes its position in the list. + * + * @param fromPosition The previous position of the item before the move. + * @param toPosition The new position of the item. + */ + void onMoved(int fromPosition, int toPosition); + + /** + * Called when {@code count} number of items are updated at the given position. + * + * @param position The position of the item which has been updated. + * @param count The number of items which has changed. + * @param payload The payload for the changed items. + */ + void onChanged(int position, int count, @Nullable Object payload); +} diff --git a/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt b/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt index 893fa3371..25fdc1c12 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt @@ -24,8 +24,6 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import androidx.activity.ComponentActivity import androidx.annotation.RequiresApi -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.blankj.utilcode.util.GsonUtils import com.blankj.utilcode.util.LogUtils import com.google.gson.reflect.TypeToken @@ -230,18 +228,6 @@ fun List.removeAt(index: Int): List { } } -fun calculateExtraLayoutSpace(context: Context, size: Int): LinearLayoutManager { - return object : LinearLayoutManager(context) { - override fun calculateExtraLayoutSpace( - state: RecyclerView.State, - extraLayoutSpace: IntArray - ) { - extraLayoutSpace[0] = size - extraLayoutSpace[1] = size - } - } -} - /** * 简易的防抖实现 */ diff --git a/common/src/main/java/com/lalilu/common/kv/BaseKV.kt b/common/src/main/java/com/lalilu/common/kv/BaseKV.kt index 6e56339c7..3f20c05c9 100644 --- a/common/src/main/java/com/lalilu/common/kv/BaseKV.kt +++ b/common/src/main/java/com/lalilu/common/kv/BaseKV.kt @@ -32,7 +32,7 @@ abstract class BaseKV(val prefix: String = "") { T::class == Float::class -> FloatListKVImpl(actualKey) T::class == Double::class -> DoubleListKVImpl(actualKey) T::class == String::class -> StringListKVImpl(actualKey) - else -> throw IllegalArgumentException("Unsupported type") + else -> ObjectListKVImpl(actualKey, T::class.java) } } as KVListItem } diff --git a/common/src/main/java/com/lalilu/common/kv/KVListItem.kt b/common/src/main/java/com/lalilu/common/kv/KVListItem.kt index efc7f46c3..c8c7c5f46 100644 --- a/common/src/main/java/com/lalilu/common/kv/KVListItem.kt +++ b/common/src/main/java/com/lalilu/common/kv/KVListItem.kt @@ -144,6 +144,27 @@ class BoolListKVImpl( override fun set(value: List?) { super.set(value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) + } + } +} + +class ObjectListKVImpl( + private val key: String, + private val clazz: Class +) : KVListItem() { + override fun get(): List? { + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson>(json, clazz) + } + + override fun set(value: List?) { + super.set(value) + if (value == null) { SPUtils.getInstance().remove(key) } else { diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 7b277dc99..c35d703dc 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -35,27 +35,28 @@ composeCompiler { } dependencies { - api(libs.lottie.compose) - - api(libs.bundles.voyager) + // compose + api(platform(libs.compose.bom.alpha)) + api(libs.activity.compose) + api(libs.bundles.compose) + api(libs.bundles.compose.debug) // accompanist // https://google.github.io/accompanist api(libs.bundles.accompanist) + api(libs.bundles.voyager) + api(libs.bundles.coil3) + api(libs.lottie.compose) api(project(":lmedia")) api(project(":common")) api(project(":lplayer")) - api(libs.bundles.coil3) - // https://github.com/Calvin-LL/Reorderable // Apache-2.0 license - api("sh.calvin.reorderable:reorderable:1.1.0") + api("sh.calvin.reorderable:reorderable:2.4.0") api("com.github.cy745:AnyPopDialog-Compose:cb92c5b6dc") api("me.rosuh:AndroidFilePicker:1.0.1") - api("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha13") - api("com.github.cy745.KRouter:core:fcf40f4b15") api("com.cheonjaeung.compose.grid:grid:2.0.0") api("com.github.cy745.RemixIcon-Kmp:core:1a3c554a35") api("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") @@ -64,11 +65,4 @@ dependencies { // https://mvnrepository.com/artifact/org.jetbrains.androidx.navigation/navigation-compose api("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10") api("androidx.compose.material3:material3-adaptive-navigation-suite") - - // compose -// api(platform(libs.compose.bom)) - api(platform(libs.compose.bom.alpha)) - api(libs.activity.compose) - api(libs.bundles.compose) - api(libs.bundles.compose.debug) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fda25fec7..6520fb92e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,11 +2,9 @@ compile_version = "35" min_sdk_version = "21" -agp_version = "8.5.0" +agp_version = "8.5.2" kotlin_version = "2.0.0" -coroutines_version = "1.8.1" ksp_version = "2.0.0-1.0.22" -#serialization_json_version = "1.6.0" koin_version = "4.0.0" koin_ksp_version = "1.4.0" @@ -16,35 +14,26 @@ accompanist_version = "0.32.0" voyager = "1.1.0-beta03" lottie-compose = "5.2.0" -kotlinpoet = "1.14.2" coil3_version = "3.0.0-alpha07" utilcodex_version = "1.31.1" # androidx appcompat = "1.7.0" -core-ktx = "1.13.1" +core-ktx = "1.15.0" palette-ktx = "1.0.0" dynamicanimation-ktx = "1.0.0-alpha03" startup-runtime = "1.2.0" -constraintlayout = "2.1.4" -coordinatorlayout = "1.2.0" -gridlayout = "1.0.0" -recyclerview = "1.3.2" -activity-compose = "1.8.2" -lifecycle_version = "2.6.2" -navigation_version = "2.7.4" +activity-compose = "1.9.3" room_version = "2.5.2" media = "1.7.0" +media3 = "1.5.0" flyjingfish-aop = "1.9.7" krouter_version = "0.0.1" [libraries] # kotlin -kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin_version" } kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines_version" } -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines_version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.3" } # compose @@ -65,7 +54,6 @@ compose-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie-compose" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } -voyager-bottomSheetNavigator = { module = "cafe.adriel.voyager:voyager-bottom-sheet-navigator", version.ref = "voyager" } voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } @@ -89,21 +77,16 @@ coil3-android = { module = "io.coil-kt.coil3:coil-android", version.ref = "coil3 coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3_version" } coil3-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3_version" } +# media3 +media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } + # androidx appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "palette-ktx" } dynamicanimation-ktx = { module = "androidx.dynamicanimation:dynamicanimation-ktx", version.ref = "dynamicanimation-ktx" } -constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } -coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "coordinatorlayout" } -gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayout" } -recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "startup-runtime" } -lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle_version" } -lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle_version" } -lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle_version" } -lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle_version" } -navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation_version" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room_version" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room_version" } @@ -133,8 +116,16 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " [bundles] -common = ["kotlin-stdlib", "kotlinx-coroutines-core", "kotlinx-coroutines-android"] -accompanist = ["accompanist-flowlayout", "accompanist-permissions", "accompanist-systemuicontroller"] +accompanist = [ + "accompanist-flowlayout", + "accompanist-permissions", + "accompanist-systemuicontroller" +] +media3 = [ + "media3-session", + "media3-exoplayer" +] + compose-debug = ["compose-tooling", "compose-tooling-preview"] compose = [ "compose-bom", diff --git a/lmedia b/lmedia index 94feeb64c..40d9fb5e1 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 94feeb64c480d5fc0b40e5fd9a15a47f213c5763 +Subproject commit 40d9fb5e14c1bf48ce28c8c9e2e8b86942622056 diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index 0a590bd3e..108c2ceb8 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -29,8 +29,6 @@ dependencies { implementation(project(":common")) implementation(project(":lmedia")) implementation(libs.startup.runtime) - implementation("com.github.cy745:AndroidVideoCache:2.7.2") - implementation("androidx.media3:media3-exoplayer:1.4.1") - api("androidx.media3:media3-session:1.4.1") + api(libs.bundles.media3) } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt index 2f516a5f4..4cc3175de 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt @@ -4,4 +4,6 @@ import com.lalilu.common.kv.BaseKV object MPlayerKV : BaseKV(prefix = "mplayer") { val historyPlaylistIds = obtainList("history_playlist_ids") + val handleAudioFocus = obtain("handleAudioFocus") + val handleBecomeNoisy = obtain("handleBecomeNoisy") } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index 33c24dff5..ff5488c38 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -4,6 +4,8 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi @@ -26,6 +28,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext @OptIn(UnstableApi::class) @@ -34,6 +40,14 @@ class MService : MediaLibraryService(), CoroutineScope { private var exoPlayer: ExoPlayer? = null private var mediaSession: MediaLibrarySession? = null + private val defaultAudioAttributes by lazy { + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setSpatializationBehavior(C.SPATIALIZATION_BEHAVIOR_AUTO) + .setAllowedCapturePolicy(C.ALLOW_CAPTURE_BY_ALL) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build() + } override fun onCreate() { super.onCreate() @@ -45,12 +59,16 @@ class MService : MediaLibraryService(), CoroutineScope { exoPlayer = ExoPlayer .Builder(this) .setRenderersFactory(FadeTransitionRenderersFactory(this, this)) + .setHandleAudioBecomingNoisy(MPlayerKV.handleBecomeNoisy.value ?: true) + .setAudioAttributes(defaultAudioAttributes, MPlayerKV.handleAudioFocus.value ?: true) .build() mediaSession = MediaLibrarySession .Builder(this, exoPlayer!!, MServiceCallback()) .setSessionActivity(getLauncherPendingIntent()) .build() + + startListenForValuesUpdate() } override fun onDestroy() { @@ -66,6 +84,20 @@ class MService : MediaLibraryService(), CoroutineScope { override fun onGetSession( controllerInfo: MediaSession.ControllerInfo ): MediaLibrarySession? = mediaSession + + private fun startListenForValuesUpdate() = launch { + MPlayerKV.handleAudioFocus.flow().onEach { + withContext(Dispatchers.Main) { + exoPlayer?.setAudioAttributes(defaultAudioAttributes, it ?: true) + } + }.launchIn(this) + + MPlayerKV.handleBecomeNoisy.flow().onEach { + withContext(Dispatchers.Main) { + exoPlayer?.setHandleAudioBecomingNoisy(it ?: true) + } + }.launchIn(this) + } } @OptIn(UnstableApi::class) @@ -192,7 +224,7 @@ private fun Context.getLauncherPendingIntent(): PendingIntent { internal fun getHistoryItems(): List { val history = MPlayerKV.historyPlaylistIds.get() - return if (history != null) LMedia.mapItems(history) + return if (!history.isNullOrEmpty()) LMedia.mapItems(history) else LMedia.getChildren(MServiceCallback.ALL_SONGS) } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index e146b0b76..4e3966b1e 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -90,7 +90,6 @@ data object PlaylistScreen : TabScreen, ScreenBarFactory { } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun Screen.PlaylistScreen( playlistSM: PlaylistScreenModel = rememberScreenModel { PlaylistScreenModel() }, @@ -244,7 +243,7 @@ private fun Screen.PlaylistScreen( contentType = { LPlaylist::class.java } ) { playlist -> ReorderableItem( - reorderableLazyListState = reorderableState, + state = reorderableState, key = playlist.id ) { isDragging -> PlaylistCard( diff --git a/settings.gradle.kts b/settings.gradle.kts index 15454fca0..07c33817d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,14 +18,15 @@ dependencyResolutionManagement { rootProject.name = "LMusic" include(":app") -include(":ui") include(":common") +include(":component") +include(":crash") + include(":lmedia") include(":lplayer") + include(":lplaylist") include(":lhistory") include(":lartist") include(":lalbum") include(":ldictionary") -include(":crash") -include(":component") \ No newline at end of file diff --git a/ui/.gitignore b/ui/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/ui/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts deleted file mode 100644 index c032dfd7b..000000000 --- a/ui/build.gradle.kts +++ /dev/null @@ -1,34 +0,0 @@ -plugins { - id("com.android.library") - kotlin("android") -} - -android { - namespace = "com.lalilu.ui" - compileSdk = libs.versions.compile.version.get().toIntOrNull() - - defaultConfig { - minSdk = libs.versions.min.sdk.version.get().toIntOrNull() - } - buildTypes { - release { - consumerProguardFiles("proguard-rules.pro") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } -} - -dependencies { - api(libs.gridlayout) - api(libs.constraintlayout) - api(libs.coordinatorlayout) - api(libs.recyclerview) - - implementation(project(":common")) -} \ No newline at end of file diff --git a/ui/proguard-rules.pro b/ui/proguard-rules.pro deleted file mode 100644 index ff59496d8..000000000 --- a/ui/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml deleted file mode 100644 index 44008a433..000000000 --- a/ui/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/ui/src/main/java/com/lalilu/ui/NewProgressBar.kt b/ui/src/main/java/com/lalilu/ui/NewProgressBar.kt deleted file mode 100644 index 1c4600d51..000000000 --- a/ui/src/main/java/com/lalilu/ui/NewProgressBar.kt +++ /dev/null @@ -1,353 +0,0 @@ -package com.lalilu.ui - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Path -import android.graphics.RectF -import android.graphics.drawable.Drawable -import android.text.TextPaint -import android.util.AttributeSet -import android.view.View -import androidx.annotation.FloatRange -import androidx.annotation.IntRange -import com.blankj.utilcode.util.SizeUtils - -fun interface OnValueChangeListener { - fun onValueChange(value: Float) -} - -open class NewProgressBar @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : View(context, attrs) { - - var bgColor = Color.argb(50, 100, 100, 100) - set(value) { - field = value - invalidate() - } - - /** - * 圆角半径 - */ - var radius: Float = 30f - set(value) { - field = value - updatePath() - invalidate() - } - - - var padding: Float = 0f - set(value) { - field = value - updatePath() - invalidate() - } - - /** - * 记录最大值 - */ - var minValue: Float = 0f - set(value) { - field = value - invalidate() - } - - /** - * 记录最大值 - */ - var maxValue: Float = 0f - set(value) { - field = value - invalidate() - } - - /** - * 当前的数据 - */ - var nowValue: Float = 0f - set(value) { - field = value.coerceIn(minValue, maxValue) - onValueChange(value) - invalidate() - } - - protected open fun onValueChange(value: Float) { - onValueChangeListener.forEach { it.onValueChange(value) } - } - - var minIncrement: Float = 0f - - /** - * 当前值文字颜色 - */ - var nowTextDarkModeColor: Int? = null - var nowTextColor: Int = Color.WHITE - get() { - if (isDarkModeNow()) return nowTextDarkModeColor ?: field - return field - } - set(value) { - field = value - nowTextPaint.color = value - invalidate() - } - - /** - * 最大值文字颜色 - */ - var maxTextDarkModeColor: Int? = null - var maxTextColor: Int = Color.WHITE - get() { - if (isDarkModeNow()) return maxTextDarkModeColor ?: field - return field - } - set(value) { - field = value - maxTextPaint.color = value - invalidate() - } - - /** - * 上层滑块颜色 - */ - var thumbDarkModeColor: Int? = null - var thumbColor: Int = Color.DKGRAY - get() { - if (isDarkModeNow()) return thumbDarkModeColor ?: field - return field - } - set(value) { - field = value - thumbPaint.color = value - invalidate() - } - - /** - * 外部框背景颜色 - * 绘制时将忽略该值的透明度 - * 由 [outSideAlpha] 控制其透明度 - */ - var outSideDarkModeColor: Int? = Color.DKGRAY - var outSideColor: Int = Color.WHITE - get() { - if (isDarkModeNow()) return outSideDarkModeColor ?: field - return field - } - set(value) { - field = value - invalidate() - } - - /** - * 外部框背景透明度 - */ - @IntRange(from = 0, to = 255) - var outSideAlpha: Int = 0 - set(value) { - field = value - invalidate() - } - - @FloatRange(from = 0.0, to = 1.0) - var switchModeProgress: Float = 0f - set(value) { - if (thumbTabs.isEmpty()) return - field = value - invalidate() - } - - var switchMoveX: Float = 0f - set(value) { - field = value - invalidate() - } - - val onValueChangeListener = HashSet() - protected val thumbTabs = ArrayList() - private var thumbWidth: Float = 0f - private var maxValueText: String = "" - private var nowValueText: String = "" - private var maxValueTextWidth: Float = 0f - private var nowValueTextWidth: Float = 0f - private var nowValueTextOffset: Float = 0f - private var thumbLeft: Float = 0f - private var thumbRight: Float = 0f - private val thumbCount: Int - get() = if (thumbTabs.size > 0) thumbTabs.size else 3 - - private var textHeight: Float = SizeUtils.sp2px(18f).toFloat() - private var textPadding: Long = 40L - private var pathInside = Path() - private var pathOutside = Path() - private var rect = RectF() - - private var thumbPaint: Paint = - Paint(Paint.ANTI_ALIAS_FLAG).also { - it.color = Color.DKGRAY - } - private var maxTextPaint = - TextPaint(Paint.ANTI_ALIAS_FLAG).also { - it.textSize = textHeight - it.isSubpixelText = true - } - private var nowTextPaint = - TextPaint(Paint.ANTI_ALIAS_FLAG).also { - it.textSize = textHeight - it.isSubpixelText = true - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - updatePath() - } - - /** - * 将value转为String以用于绘制 - * 可按需求转成各种格式 - * eg. 00:00 - */ - open fun valueToText(value: Float): String { - return value.toString() - } - - /** - * 判断当前是否处于深色模式 - */ - open fun isDarkModeNow(): Boolean { - return false - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - val actualWidth = width - padding * 2f - - // 通过Value计算Progress,从而获取滑块应有的宽度 - thumbWidth = normalize(nowValue, minValue, maxValue) * actualWidth - thumbWidth = lerp(thumbWidth, actualWidth / thumbCount, switchModeProgress) - - maxValueText = valueToText(maxValue) - nowValueText = valueToText(nowValue) - maxValueTextWidth = maxTextPaint.measureText(maxValueText) - nowValueTextWidth = nowTextPaint.measureText(nowValueText) - - val textCenterHeight = height / 2f - (maxTextPaint.ascent() + maxTextPaint.descent()) / 2f - val offsetTemp = nowValueTextWidth + textPadding * 2 - - nowValueTextOffset = if (offsetTemp < thumbWidth) thumbWidth else offsetTemp - nowTextPaint.color = nowTextColor - maxTextPaint.color = maxTextColor - - nowTextPaint.alpha = lerp(0f, 255f, 1f - switchModeProgress).toInt() - maxTextPaint.alpha = nowTextPaint.alpha - thumbPaint.color = thumbColor - - thumbLeft = padding - val switchProgress = normalize(switchMoveX, thumbWidth / 2f, width - thumbWidth / 2f) - val switchOffset = lerp(thumbLeft, width - thumbLeft - thumbWidth, switchProgress) - thumbLeft = lerp(thumbLeft, switchOffset, switchModeProgress) - thumbRight = (thumbLeft + thumbWidth).coerceIn(0f, width.toFloat()) - - // 截取外部框范围 - canvas.clipPath(pathOutside) - - // 绘制外部框背景 - canvas.drawARGB( - outSideAlpha, - Color.red(outSideColor), - Color.green(outSideColor), - Color.blue(outSideColor) - ) - - // 只保留圆角矩形path部分 - canvas.clipPath(pathInside) - - // 绘制背景 - canvas.drawColor(bgColor) - - if (nowTextPaint.alpha != 0) { - // 绘制总时长文字 - canvas.drawText( - maxValueText, - width - maxValueTextWidth - textPadding, - textCenterHeight, - maxTextPaint - ) - } - - // 绘制进度条滑动块 - canvas.drawRoundRect( - thumbLeft, padding, - thumbRight, height - padding, - radius, radius, thumbPaint - ) - - if (nowTextPaint.alpha != 0) { - // 绘制进度时间文字 - canvas.drawText( - nowValueText, - nowValueTextOffset - nowValueTextWidth - textPadding, - textCenterHeight, - nowTextPaint - ) - } - - val switchThumbWidth = width / thumbCount - val switchModeAlpha = lerp(0f, 255f, switchModeProgress).toInt() - if (switchModeAlpha > 0) { - var drawX = 0f - for (tab in thumbTabs) { - tab.apply { - alpha = switchModeAlpha - - // 计算Drawable的原始宽高比 - val ratio = (intrinsicWidth.toFloat() / intrinsicHeight.toFloat()) - .takeIf { it > 0 } ?: 1f - - val itemHeight = textHeight * 1.2f - val itemWidth = itemHeight * ratio - - val itemLeft = drawX + (switchThumbWidth - itemWidth) / 2f - val itemTop = (height - itemHeight) / 2f - - setBounds( - itemLeft.toInt(), - itemTop.toInt(), - (itemLeft + itemWidth).toInt(), - (itemTop + itemHeight).toInt() - ) - draw(canvas) - } - drawX += switchThumbWidth - } - } - } - - private fun normalize(value: Float, min: Float, max: Float): Float { - return ((value - min) / (max - min)) - .coerceIn(0f, 1f) - } - - private fun lerp(from: Float, to: Float, fraction: Float): Float { - return (from + (to - from) * fraction) - .coerceIn(minOf(from, to), maxOf(from, to)) - } - - open fun updatePath() { - rect.set(0f, 0f, width.toFloat(), height.toFloat()) - pathOutside.reset() - pathOutside.addRoundRect(rect, radius * 1.2f, radius * 1.2f, Path.Direction.CW) - - rect.set(padding, padding, width - padding, height - padding) - pathInside.reset() - pathInside.addRoundRect(rect, radius, radius, Path.Direction.CW) - } - - init { - val attr = context.obtainStyledAttributes(attrs, R.styleable.NewProgressBar) - radius = attr.getDimension(R.styleable.NewProgressBar_radius, 30f) - attr.recycle() - } -} \ No newline at end of file diff --git a/ui/src/main/java/com/lalilu/ui/NewSeekBar.kt b/ui/src/main/java/com/lalilu/ui/NewSeekBar.kt deleted file mode 100644 index 52a0c09e8..000000000 --- a/ui/src/main/java/com/lalilu/ui/NewSeekBar.kt +++ /dev/null @@ -1,426 +0,0 @@ -package com.lalilu.ui - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.GestureDetector -import android.view.MotionEvent -import androidx.annotation.IntDef -import androidx.core.view.GestureDetectorCompat -import androidx.dynamicanimation.animation.SpringAnimation -import androidx.dynamicanimation.animation.SpringForce -import androidx.dynamicanimation.animation.springAnimationOf -import androidx.dynamicanimation.animation.withSpringForceProperties -import com.blankj.utilcode.util.SizeUtils -import com.blankj.utilcode.util.TimeUtils -import com.lalilu.common.SystemUiUtil -import kotlin.math.abs - -const val CLICK_PART_UNSPECIFIED = 0 -const val CLICK_PART_LEFT = 1 -const val CLICK_PART_MIDDLE = 2 -const val CLICK_PART_RIGHT = 3 - -@IntDef( - CLICK_PART_UNSPECIFIED, - CLICK_PART_LEFT, - CLICK_PART_MIDDLE, - CLICK_PART_RIGHT -) -@Retention(AnnotationRetention.SOURCE) -annotation class ClickPart - -const val THRESHOLD_STATE_UNREACHED = 0 -const val THRESHOLD_STATE_REACHED = 1 -const val THRESHOLD_STATE_RETURN = 2 - -@IntDef( - THRESHOLD_STATE_REACHED, - THRESHOLD_STATE_UNREACHED, - THRESHOLD_STATE_RETURN -) -@Retention(AnnotationRetention.SOURCE) -annotation class ThresholdState - -fun interface OnSeekBarScrollListener { - fun onScroll(scrollValue: Float) -} - -fun interface OnSeekBarCancelListener { - fun onCancel() -} - -fun interface OnSeekBarSeekToListener { - fun onSeekTo(value: Float) -} - -fun interface OnTapEventListener { - fun onTapEvent() -} - -interface OnSeekBarClickListener { - fun onClick( - @ClickPart clickPart: Int = CLICK_PART_UNSPECIFIED, - action: Int - ) - - fun onLongClick( - @ClickPart clickPart: Int = CLICK_PART_UNSPECIFIED, - action: Int - ) - - fun onDoubleClick( - @ClickPart clickPart: Int = CLICK_PART_UNSPECIFIED, - action: Int - ) -} - -abstract class OnSeekBarScrollToThresholdListener( - private val threshold: () -> Number -) : OnSeekBarScrollListener { - abstract fun onScrollToThreshold() - open fun onScrollRecover() {} - - @ThresholdState - var state: Int = THRESHOLD_STATE_UNREACHED - set(value) { - if (field == value) return - when (value) { - THRESHOLD_STATE_REACHED -> onScrollToThreshold() - THRESHOLD_STATE_RETURN -> onScrollRecover() - } - field = value - } - - override fun onScroll(scrollValue: Float) { - state = if (scrollValue >= threshold().toFloat()) { - THRESHOLD_STATE_REACHED - } else { - if (state == THRESHOLD_STATE_REACHED) THRESHOLD_STATE_RETURN - else THRESHOLD_STATE_UNREACHED - } - } -} - -class NewSeekBar @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : NewProgressBar(context, attrs) { - var cancelThreshold = 100f - - val scrollListeners = HashSet() - val clickListeners = HashSet() - val cancelListeners = HashSet() - val seekToListeners = HashSet() - val onTapLeaveListeners = HashSet() - val onTapEnterListeners = HashSet() - var valueToText: ((Float) -> String)? = null - private var switchToCallbacks = ArrayList Unit>>() - var switchIndexUpdateCallback: (Int) -> Unit = {} - - private var moved = false - private var canceled = true - private var touching = false - private var switchMode = false - - private var startValue: Float = nowValue - private var dataValue: Float = nowValue - private var sensitivity: Float = 1.3f - - private var downX: Float = 0f - private var downY: Float = 0f - private var lastX: Float = 0f - private var lastY: Float = 0f - - private val cancelScrollListener = - object : OnSeekBarScrollToThresholdListener(this::cancelThreshold) { - override fun onScrollToThreshold() { - animateValueTo(dataValue) - animateSwitchModeProgressTo(0f) - cancelListeners.forEach { it.onCancel() } - canceled = true - } - - override fun onScrollRecover() { - canceled = false - if (switchMode) { - animateSwitchModeProgressTo(100f) - } - } - } - - private val mProgressAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { updateProgress(it, false) }, - getter = { nowValue }, - finalPosition = nowValue - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - private val mPaddingAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { - padding = it - outSideAlpha = (it * 50f).toInt() - }, - getter = { padding }, - finalPosition = padding - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - private val mOutSideAlphaAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { outSideAlpha = it.toInt() }, - getter = { outSideAlpha.toFloat() }, - finalPosition = outSideAlpha.toFloat() - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - private val mAlphaAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { alpha = it / 100f }, - getter = { alpha * 100f }, - finalPosition = 100f - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - private val switchModeAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { switchModeProgress = it / 100f }, - getter = { switchModeProgress * 100f }, - finalPosition = 100f - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - override fun valueToText(value: Float): String { - return valueToText?.invoke(value) ?: TimeUtils.millis2String(value.toLong(), "mm:ss") - } - - override fun isDarkModeNow(): Boolean { - return SystemUiUtil.isDarkMode(context) - } - - /** - * 判断触摸事件所点击的部分位置 - */ - fun checkClickPart(e: MotionEvent): Int { - return when (e.x.toInt()) { - in 0..(width * 1 / 3) -> CLICK_PART_LEFT - in (width * 1 / 3)..(width * 2 / 3) -> CLICK_PART_MIDDLE - in (width * 2 / 3)..width -> CLICK_PART_RIGHT - else -> CLICK_PART_UNSPECIFIED - } - } - - private val gestureDetector = GestureDetectorCompat(context, - object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(e: MotionEvent): Boolean { - touching = true - moved = false - canceled = false - switchMode = false - startValue = nowValue - dataValue = nowValue - downX = e.x - downY = e.y - lastX = downX - lastY = downY - - animateScaleTo(SizeUtils.dp2px(3f).toFloat()) - animateOutSideAlphaTo(255f) - animateAlphaTo(100f) - - onTapEnterListeners.forEach(OnTapEventListener::onTapEvent) - return super.onDown(e) - } - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - clickListeners.forEach { it.onClick(checkClickPart(e), e.action) } - performClick() - return super.onSingleTapConfirmed(e) - } - - override fun onDoubleTap(e: MotionEvent): Boolean { - clickListeners.forEach { it.onDoubleClick(checkClickPart(e), e.action) } - return super.onDoubleTap(e) - } - - override fun onLongPress(e: MotionEvent) { - clickListeners.forEach { it.onLongClick(checkClickPart(e), e.action) } - animateValueTo(startValue) - updateSwitchMoveX(e.x) - animateSwitchModeProgressTo(100f) - switchMode = true - } - }) - - private fun updateValueByDelta(delta: Float) { - if (touching && !canceled && !switchMode) { - mProgressAnimation.cancel() - val value = nowValue + delta / width * (maxValue - minValue) * sensitivity - updateProgress(value, true) - } - } - - private var switchIndex: Int = 0 - set(value) { - if (field == value) return - field = value - switchIndexUpdateCallback(value) - } - - fun updateSwitchMoveX(moveX: Float) { - switchMoveX = moveX - } - - fun updateSwitchIndex() { - switchIndex = getIntervalIndex( - a = 0f, - b = width.toFloat(), - n = switchToCallbacks.size, - x = switchMoveX - ) - } - - fun updateValue(value: Float) { - if (value !in minValue..maxValue) return - - if (!touching || canceled) { - animateValueTo(value) - } - dataValue = value - } - - fun updateProgress(value: Float, fromUser: Boolean = false) { - nowValue = value - } - - override fun onValueChange(value: Float) { - val actualValue = if (touching) value else dataValue - super.onValueChange(actualValue) - } - - fun setSwitchToCallback(vararg callbackPair: Pair Unit>) { - switchToCallbacks.clear() - switchToCallbacks.addAll(callbackPair) - thumbTabs.clear() - thumbTabs.addAll(switchToCallbacks.map { it.first }) - } - - /** - * GestureDetector 没有抬起相关的事件回调, - * 在OnTouchView中自行处理抬起相关逻辑 - */ - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - gestureDetector.onTouchEvent(event) - when (event.action) { - MotionEvent.ACTION_UP, - MotionEvent.ACTION_POINTER_UP, - MotionEvent.ACTION_CANCEL -> { - onTapLeaveListeners.forEach(OnTapEventListener::onTapEvent) - - if (moved && !canceled) { - if (switchMode) { - updateSwitchIndex() - switchToCallbacks.getOrNull(switchIndex)?.second?.invoke() - } else if (abs(nowValue - startValue) > minIncrement) { - seekToListeners.forEach { it.onSeekTo(nowValue) } - } - } - animateScaleTo(0f) - animateOutSideAlphaTo(0f) - animateSwitchModeProgressTo(0f) - touching = false - canceled = false - switchMode = false - moved = false - - scrollListeners.forEach { - if (it is OnSeekBarScrollToThresholdListener) { - it.state = THRESHOLD_STATE_UNREACHED - } - } - - parent.requestDisallowInterceptTouchEvent(false) - } - - - // GestureDetector在OnLongPressed后不会再回调OnScrolled,所以自己处理ACTION_MOVE事件 - MotionEvent.ACTION_MOVE -> { - if (!touching) return true - - val deltaX: Float = event.x - lastX - val deltaY: Float = event.y - lastY - - moved = true - if (switchMode) { - updateSwitchMoveX(event.x) - updateSwitchIndex() - } else { - updateValueByDelta(deltaX) - } - - scrollListeners.forEach { it.onScroll((-event.y).coerceAtLeast(0f)) } - parent.requestDisallowInterceptTouchEvent(true) - - lastX = event.x - lastY = event.y - } - } - return true - } - - private fun getIntervalIndex(a: Float, b: Float, n: Int, x: Float): Int { - if (x < a) return 0 - if (x > b) return n - 1 - - val intervalSize = (b - a) / n // 区间大小 - val index = ((x - a) / intervalSize).toInt() // 计算区间索引 - return if (index >= n) n - 1 else index - } - - fun animateOutSideAlphaTo(value: Float) { - mOutSideAlphaAnimation.cancel() - mOutSideAlphaAnimation.animateToFinalPosition(value) - } - - fun animateScaleTo(value: Float) { - mPaddingAnimation.cancel() - mPaddingAnimation.animateToFinalPosition(value) - } - - fun animateValueTo(value: Float) { - mProgressAnimation.cancel() - mProgressAnimation.animateToFinalPosition(value) - } - - fun animateAlphaTo(value: Float) { - mAlphaAnimation.cancel() - mAlphaAnimation.animateToFinalPosition(value) - } - - fun animateSwitchModeProgressTo(value: Float) { - switchModeAnimation.cancel() - switchModeAnimation.animateToFinalPosition(value) - } - - init { - scrollListeners.add(cancelScrollListener) - } -} \ No newline at end of file diff --git a/ui/src/main/res/values/attrs.xml b/ui/src/main/res/values/attrs.xml deleted file mode 100644 index d757273be..000000000 --- a/ui/src/main/res/values/attrs.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file From 411afc58657bbe5323d432038c278008399c5ef5 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 2 Dec 2024 05:17:43 +0800 Subject: [PATCH 121/213] =?UTF-8?q?[refactor]=E4=BF=AE=E6=AD=A3=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=E5=A4=B1=E8=B4=A5=E5=AF=BC=E8=87=B4=E9=97=AA?= =?UTF-8?q?=E9=80=80=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../new_screen/detail/SongDetailContent.kt | 160 +++--------------- common/build.gradle.kts | 4 +- .../java/com/lalilu/common/kv/KVListItem.kt | 8 +- gradle/libs.versions.toml | 5 +- 4 files changed, 33 insertions(+), 144 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt index 1ad3bf2bb..a49e27906 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt @@ -4,15 +4,11 @@ import android.net.Uri import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -23,15 +19,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.constraintlayout.compose.Dimension -import androidx.constraintlayout.compose.MotionLayout -import androidx.constraintlayout.compose.MotionScene import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade @@ -74,105 +66,14 @@ fun SongDetailContent( song: LSong = lSong, progress: Float = 0f, ) { - val paddingTop = WindowInsets.statusBars.asPaddingValues() - .calculateTopPadding() - - val scene = remember { - MotionScene { - val coverRef = createRefFor("cover") - val titleRow = createRefFor("title") - val subTitleRow = createRefFor("subTitle") - val content = createRefFor("content") - - val collapsed = constraintSet { - constrain(coverRef) { - top.linkTo(parent.top, 16.dp) - start.linkTo(parent.start, 16.dp) - - width = Dimension.value(72.dp) - height = Dimension.value(72.dp) - } - - constrain(titleRow) { - top.linkTo(coverRef.top) - start.linkTo(coverRef.end, 16.dp) - end.linkTo(parent.end, 16.dp) - - width = Dimension.fillToConstraints - height = Dimension.wrapContent - } - - constrain(subTitleRow) { - top.linkTo(titleRow.bottom, 8.dp) - start.linkTo(coverRef.end, 16.dp) - end.linkTo(parent.end, 16.dp) - - width = Dimension.fillToConstraints - height = Dimension.wrapContent - } - - val barrier = createBottomBarrier(coverRef, subTitleRow) - - constrain(content) { - top.linkTo(barrier, 16.dp) - start.linkTo(parent.start, 16.dp) - end.linkTo(parent.end, 16.dp) - - width = Dimension.fillToConstraints - height = Dimension.wrapContent - } - } - val expended = constraintSet { - constrain(coverRef) { - top.linkTo(parent.top, 16.dp + paddingTop + 24.dp) - start.linkTo(parent.start, 16.dp) - end.linkTo(parent.end, 16.dp) - - width = Dimension.fillToConstraints - height = Dimension.preferredWrapContent - } - - constrain(titleRow) { - top.linkTo(coverRef.bottom, 16.dp) - start.linkTo(parent.start, 16.dp) - end.linkTo(parent.end, 16.dp) - - width = Dimension.fillToConstraints - height = Dimension.preferredWrapContent - } - - constrain(subTitleRow) { - top.linkTo(titleRow.bottom, 8.dp) - start.linkTo(parent.start, 16.dp) - end.linkTo(parent.end, 16.dp) - - width = Dimension.fillToConstraints - height = Dimension.wrapContent - } - - val barrier = createBottomBarrier(coverRef, subTitleRow) - - constrain(content) { - top.linkTo(barrier, 16.dp) - start.linkTo(parent.start, 16.dp) - end.linkTo(parent.end, 16.dp) - - width = Dimension.fillToConstraints - height = Dimension.wrapContent - } - } - - defaultTransition(from = collapsed, to = expended) - } - } - - MotionLayout( - modifier = modifier.fillMaxSize(), - motionScene = scene, - progress = progress + Column( + modifier = modifier + .statusBarsPadding() + .padding(bottom = LocalSmartBarPadding.current.value.calculateBottomPadding() + 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Surface( - modifier = Modifier.layoutId("cover"), + modifier = Modifier, elevation = 2.dp, shape = RoundedCornerShape(10.dp) ) { @@ -192,7 +93,6 @@ fun SongDetailContent( Text( modifier = Modifier - .layoutId("title") .graphicsLayer { scaleX = 1f + (0.1f * progress) scaleY = scaleX @@ -205,43 +105,27 @@ fun SongDetailContent( lineHeight = 16.sp ) - Text( - modifier = Modifier.layoutId("subTitle"), - text = song.name, - color = MaterialTheme.colors.onBackground.copy(0.6f), - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 12.sp + SongArtistsRow( + modifier = Modifier.fillMaxWidth(), + artists = song.artists ) - Column( - modifier = Modifier - .layoutId("content") - .padding(bottom = LocalSmartBarPadding.current.value.calculateBottomPadding() + 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - SongArtistsRow( + song.album?.let { + SongAlbumInfoCard( modifier = Modifier.fillMaxWidth(), - artists = song.artists + album = it ) + } - song.album?.let { - SongAlbumInfoCard( - modifier = Modifier.fillMaxWidth(), - album = it - ) - } - - SongActionsCard( - modifier = Modifier.fillMaxWidth(), - song = song - ) + SongActionsCard( + modifier = Modifier.fillMaxWidth(), + song = song + ) - SongInformationCard( - modifier = Modifier.fillMaxWidth(), - song = song - ) - } + SongInformationCard( + modifier = Modifier.fillMaxWidth(), + song = song + ) } } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 29506cecf..43e21139f 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -26,14 +26,14 @@ android { dependencies { api(libs.utilcodex) + api(libs.gson) api(libs.appcompat) api(libs.core.ktx) api(libs.palette.ktx) api(libs.dynamicanimation.ktx) api(libs.media) - api("com.russhwolf:multiplatform-settings:1.3.0") - api("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0") + api(libs.kotlinx.coroutines.guava) api(libs.bundles.koin) api(libs.krouter.core) diff --git a/common/src/main/java/com/lalilu/common/kv/KVListItem.kt b/common/src/main/java/com/lalilu/common/kv/KVListItem.kt index c8c7c5f46..717301889 100644 --- a/common/src/main/java/com/lalilu/common/kv/KVListItem.kt +++ b/common/src/main/java/com/lalilu/common/kv/KVListItem.kt @@ -2,7 +2,7 @@ package com.lalilu.common.kv import com.blankj.utilcode.util.GsonUtils import com.blankj.utilcode.util.SPUtils -import com.google.common.reflect.TypeToken +import com.google.gson.reflect.TypeToken import java.io.Serializable abstract class KVListItem : KVItem>() @@ -155,11 +155,13 @@ class BoolListKVImpl( class ObjectListKVImpl( private val key: String, - private val clazz: Class + clazz: Class ) : KVListItem() { + private val typeToken = TypeToken.getParameterized(List::class.java, clazz) + override fun get(): List? { val json = SPUtils.getInstance().getString(key) - return GsonUtils.fromJson>(json, clazz) + return GsonUtils.fromJson>(json, typeToken.type) } override fun set(value: List?) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6520fb92e..7d86ee2ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ compile_version = "35" min_sdk_version = "21" -agp_version = "8.5.2" +agp_version = "8.6.0" kotlin_version = "2.0.0" ksp_version = "2.0.0-1.0.22" @@ -27,6 +27,7 @@ activity-compose = "1.9.3" room_version = "2.5.2" media = "1.7.0" media3 = "1.5.0" +gson = "2.11.0" flyjingfish-aop = "1.9.7" krouter_version = "0.0.1" @@ -35,6 +36,7 @@ krouter_version = "0.0.1" # kotlin kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.3" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version = "1.9.0" } # compose compose-bom-alpha = { module = "androidx.compose:compose-bom-alpha", version.ref = "compose_bom_alpha_version" } @@ -95,6 +97,7 @@ media = { module = "androidx.media:media", version.ref = "media" } # [Apache-2.0 License] 安卓工具类库 https://github.com/Blankj/AndroidUtilCode/ utilcodex = { module = "com.blankj:utilcodex", version.ref = "utilcodex_version" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } # [Apache-2.0 License] AOP框架 https://github.com/FlyJingFish/AndroidAOP flyjingfish-aop-core = { module = "io.github.FlyJingFish.AndroidAop:android-aop-core", version.ref = "flyjingfish-aop" } From ac125d8f867d3678d43a750f957fa20a67cec937 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 2 Dec 2024 18:06:13 +0800 Subject: [PATCH 122/213] =?UTF-8?q?[refactor]=E9=87=8D=E6=9E=84=E4=BC=98?= =?UTF-8?q?=E5=8C=96Playlist=E5=88=97=E8=A1=A8=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/component/extension/ComposeExt.kt | 7 +- .../lplaylist/component/PlaylistCard.kt | 9 +- .../com/lalilu/lplaylist/entity/LPlaylist.kt | 10 +- .../lalilu/lplaylist/repository/PlaylistSp.kt | 17 -- .../lalilu/lplaylist/screen/PlaylistScreen.kt | 275 ++++-------------- .../lplaylist/screen/PlaylistScreenContent.kt | 177 +++++++++++ .../create/PlaylistCreateOrEditScreen.kt | 2 + .../screen/detail/PlaylistDetailScreen.kt | 2 + .../lalilu/lplaylist/viewmodel/PlaylistsVM.kt | 102 +++++++ 9 files changed, 361 insertions(+), 240 deletions(-) delete mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistSp.kt create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistsVM.kt diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt index 957c23de4..b2087e4b4 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt @@ -305,16 +305,19 @@ inline fun registerAndGetViewModel( scope: Scope = currentKoinScope(), noinline parameters: ParametersDefinition? = null, ): T { + val actualViewModelStoreOwner = registerMap[T::class.java]?.get() ?: viewModelStoreOwner + return koinViewModel( qualifier = qualifier, - viewModelStoreOwner = viewModelStoreOwner, + viewModelStoreOwner = actualViewModelStoreOwner, key = key, extras = extras, scope = scope, parameters = parameters - ).also { registerMap[T::class.java] = WeakReference(viewModelStoreOwner) } + ).also { registerMap[T::class.java] = WeakReference(actualViewModelStoreOwner) } } +@Deprecated(message = "弃用") @Composable inline fun getViewModel( qualifier: Qualifier? = null, diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt index 35a68c4d6..7db5e3430 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt @@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource @@ -52,7 +53,8 @@ fun PlaylistCard( Row( modifier = modifier - .heightIn(min = 56.dp) + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(8.dp)) .background(bgColor) .combinedClickable( onClick = { onClick(playlist) }, @@ -108,7 +110,8 @@ fun PlaylistCard( label = "DragHandleVisibility" ) { Icon( - modifier = draggingModifier, + modifier = draggingModifier + .padding(start = 16.dp), painter = painterResource(id = componentR.drawable.ic_draggable), contentDescription = "DragHandle", ) diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/entity/LPlaylist.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/entity/LPlaylist.kt index 03eb942e3..f6d3f0cb3 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/entity/LPlaylist.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/entity/LPlaylist.kt @@ -1,11 +1,17 @@ package com.lalilu.lplaylist.entity +import com.lalilu.lmedia.extension.Searchable import java.io.Serializable +import kotlin.String data class LPlaylist( val id: String, val title: String, val subTitle: String, val coverUri: String, - val mediaIds: List -) : Serializable \ No newline at end of file + val mediaIds: List, + val createTime: Long = System.currentTimeMillis(), + val modifyTime: Long = System.currentTimeMillis() +) : Serializable, Searchable { + override fun getMatchSource(): String = "$title$subTitle" +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistSp.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistSp.kt deleted file mode 100644 index 965a1729b..000000000 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistSp.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.lalilu.lplaylist.repository - -import android.app.Application -import android.content.SharedPreferences -import com.lalilu.common.base.BaseSp -import com.lalilu.lplaylist.entity.LPlaylist - -class PlaylistSp(private val context: Application) : BaseSp() { - override fun obtainSourceSp(): SharedPreferences { - return context.getSharedPreferences( - context.packageName + "_PLAYLIST", - Application.MODE_PRIVATE - ) - } - - val playlistList = obtainList(key = "PLAYLIST", autoSave = false) -} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index 4e3966b1e..186f7cc9c 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -1,76 +1,43 @@ package com.lalilu.lplaylist.screen -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.rememberScreenModel -import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ToastUtils import com.lalilu.RemixIcon -import com.lalilu.component.LLazyColumn import com.lalilu.component.LongClickableTextButton -import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.TabScreen +import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo -import com.lalilu.component.extension.SelectAction -import com.lalilu.component.extension.rememberItemSelectHelper +import com.lalilu.component.base.songs.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSelectorPanel +import com.lalilu.component.extension.registerAndGetViewModel import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent import com.lalilu.lplaylist.R -import com.lalilu.lplaylist.component.PlaylistCard -import com.lalilu.lplaylist.entity.LPlaylist -import com.lalilu.lplaylist.repository.PlaylistRepository -import com.lalilu.lplaylist.screen.create.PlaylistCreateOrEditScreen -import com.lalilu.lplaylist.screen.detail.PlaylistDetailScreen +import com.lalilu.lplaylist.viewmodel.PlaylistsAction +import com.lalilu.lplaylist.viewmodel.PlaylistsVM import com.lalilu.remixicon.Media +import com.lalilu.remixicon.System import com.lalilu.remixicon.media.playListFill +import com.lalilu.remixicon.system.deleteBinLine import com.zhangke.krouter.annotation.Destination -import org.koin.compose.koinInject -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyColumnState -import com.lalilu.component.R as ComponentR -class PlaylistScreenModel : ScreenModel { - val isSelecting = mutableStateOf(false) - val selectedItems = mutableStateOf>(emptyList()) -} @Destination("/pages/playlist") data object PlaylistScreen : TabScreen, ScreenBarFactory { @@ -86,188 +53,64 @@ data object PlaylistScreen : TabScreen, ScreenBarFactory { @Composable override fun Content() { - PlaylistScreen() - } -} - -@Composable -private fun Screen.PlaylistScreen( - playlistSM: PlaylistScreenModel = rememberScreenModel { PlaylistScreenModel() }, - playlistRepo: PlaylistRepository = koinInject(), -) { - val listState = rememberLazyListState() - val playlists by remember { derivedStateOf { playlistRepo.getPlaylists() } } - val playlistState = remember(playlists) { playlists.toMutableStateList() } - - val reorderableState = rememberReorderableLazyColumnState(listState) { from, to -> - playlistState.toMutableList().apply { - val toIndex = indexOfFirst { it.id == to.key } - val fromIndex = indexOfFirst { it.id == from.key } - if (toIndex < 0 || fromIndex < 0) return@rememberReorderableLazyColumnState - - add(toIndex, removeAt(fromIndex)) - playlistState.clear() - playlistState.addAll(this) - } - } - - LaunchedEffect(Unit) { - playlistRepo.checkFavouriteExist() - } - - val selectHelper = rememberItemSelectHelper( - isSelecting = playlistSM.isSelecting, - selected = playlistSM.selectedItems - ) - val selectActions = remember { - listOf() - } - - if (this is ScreenBarFactory) { - RegisterContent( - isVisible = { selectHelper.isSelecting.value }, - onDismiss = { selectHelper.isSelecting.value = false }, - onBackPressed = { selectHelper.clear() } - ) { - Row( - modifier = Modifier - .clickable(enabled = false) {} - .height(52.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(start = 16.dp, end = 24.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = Color(0x2F006E7C), - contentColor = Color(0xFF006E7C) - ), - onClick = { selectHelper.clear() } - ) { - Image( - painter = painterResource(id = com.lalilu.component.R.drawable.ic_close_line), - contentDescription = "cancelButton", - colorFilter = ColorFilter.tint(color = Color(0xFF006E7C)) - ) - Text( - text = "取消 [${selectHelper.selected.value.size}]", - fontSize = 14.sp - ) - } + val vm = registerAndGetViewModel() + val state by vm.state - LazyRow( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.End - ) { - items(items = selectActions) { - if (it is SelectAction.ComposeAction) { - it.content.invoke(selectHelper) - return@items - } + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(PlaylistsAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(PlaylistsAction.SearchFor(it)) } + ) - if (it is SelectAction.StaticAction) { - LongClickableTextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = 20.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = it.color.copy(alpha = 0.15f), - contentColor = it.color - ), - enableLongClickMask = it.forLongClick, - onLongClick = { if (it.forLongClick) it.onAction(selectHelper) }, - onClick = { - if (it.forLongClick) { - ToastUtils.showShort("请长按此按钮以继续") - } else { - it.onAction(selectHelper) - } - }, - ) { - it.icon?.let { icon -> - Image( - modifier = Modifier.size(20.dp), - painter = painterResource(id = icon), - contentDescription = stringResource(id = it.title), - colorFilter = ColorFilter.tint(color = it.color) - ) - Spacer(modifier = Modifier.width(6.dp)) - } - Text( - text = stringResource(id = it.title), - fontSize = 14.sp - ) - } - } - } - } - } - } - } + SongsSelectorPanel( + isVisible = { vm.selector.isSelecting.value }, + onDismiss = { vm.selector.isSelecting.value = false }, + screenActions = listOfNotNull( + ScreenAction.Dynamic { + val color = Color(0xFFFF3C3C) - LLazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - item { - NavigatorHeader( - modifier = Modifier - .statusBarsPadding() - .fillMaxWidth(), - title = stringResource(id = R.string.playlist_screen_title) - ) { - IconButton( - onClick = { - AppRouter.intent( - NavIntent.Push( - PlaylistCreateOrEditScreen() - ) + LongClickableTextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = 20.dp), + colors = ButtonDefaults.textButtonColors( + backgroundColor = color.copy(alpha = 0.15f), + contentColor = color + ), + enableLongClickMask = true, + onLongClick = { vm.intent(PlaylistsAction.TryRemovePlaylist(vm.selector.selected())) }, + onClick = { ToastUtils.showShort("请长按此按钮以继续") }, + ) { + Image( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.System.deleteBinLine, + contentDescription = "删除歌单", + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "删除歌单", + fontSize = 14.sp ) } - ) { - Icon( - painter = painterResource(ComponentR.drawable.ic_add_line), - contentDescription = null - ) } - } - } + ) + ) - items( - items = playlistState, - key = { it.id }, - contentType = { LPlaylist::class.java } - ) { playlist -> - ReorderableItem( - state = reorderableState, - key = playlist.id - ) { isDragging -> - PlaylistCard( - playlist = playlist, - draggingModifier = Modifier.draggableHandle( - onDragStopped = { playlistRepo.setPlaylists(playlistState) } - ), - isDragging = { isDragging }, - isSelected = { selectHelper.isSelected(playlist) }, - isSelecting = { selectHelper.isSelecting.value }, - onClick = { - if (selectHelper.isSelecting()) { - selectHelper.onSelect(playlist) - } else { - AppRouter.intent( - NavIntent.Push( - PlaylistDetailScreen(playlistId = playlist.id) - ) - ) - } - }, - onLongClick = { selectHelper.onSelect(playlist) } - ) + PlaylistScreenContent( + isSearching = { state.searchKeyWord.isNotBlank() && !state.showSearcherPanel }, + onStartSearch = { vm.intent(PlaylistsAction.ShowSearcherPanel) }, + isSelected = { vm.selector.isSelected(it) }, + isSelecting = { vm.selector.isSelecting.value }, + playlists = { vm.playlists.value }, + onUpdatePlaylist = { vm.intent(PlaylistsAction.UpdatePlaylist(it)) }, + onLongClickPlaylist = { vm.selector.onSelect(it) }, + onClickPlaylist = { + AppRouter.route("/pages/playlist/detail") + .with("playlistId", it.id) + .push() } - } + ) } } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt new file mode 100644 index 000000000..0697b822b --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt @@ -0,0 +1,177 @@ +package com.lalilu.lplaylist.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.lalilu.RemixIcon +import com.lalilu.component.base.NavigatorHeader +import com.lalilu.component.navigation.AppRouter +import com.lalilu.lplaylist.R +import com.lalilu.lplaylist.component.PlaylistCard +import com.lalilu.lplaylist.entity.LPlaylist +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.addLargeLine +import com.lalilu.remixicon.system.search2Line +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Composable +internal fun PlaylistScreenContent( + modifier: Modifier = Modifier, + isSearching: () -> Boolean = { true }, + onStartSearch: () -> Unit = {}, + isSelecting: () -> Boolean = { true }, + isSelected: (LPlaylist) -> Boolean = { false }, + playlists: () -> List = { emptyList() }, + onUpdatePlaylist: (List) -> Unit = {}, + onClickPlaylist: (LPlaylist) -> Unit = {}, + onLongClickPlaylist: (LPlaylist) -> Unit = {} +) { + val listState: LazyListState = rememberLazyListState() + val playlistState = remember(playlists()) { + playlists().toMutableStateList() + } + + val reorderableState = rememberReorderableLazyListState( + lazyListState = listState + ) { from, to -> + playlistState.toMutableList().apply { + val toIndex = indexOfFirst { it.id == to.key } + val fromIndex = indexOfFirst { it.id == from.key } + if (toIndex < 0 || fromIndex < 0) return@rememberReorderableLazyListState + + add(toIndex, removeAt(fromIndex)) + playlistState.clear() + playlistState.addAll(this) + } + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item(key = "HEADER") { + NavigatorHeader( + modifier = Modifier + .statusBarsPadding() + .fillMaxWidth(), + rowExtraSpace = 8.dp, + paddingValues = PaddingValues( + top = 26.dp, + bottom = 20.dp, + start = 20.dp, + end = 12.dp + ), + title = stringResource(id = R.string.playlist_screen_title) + ) { + IconButton(onClick = { AppRouter.route("/pages/playlist/create_or_edit").push() }) { + Icon( + imageVector = RemixIcon.System.addLargeLine, + contentDescription = null + ) + } + + Box { + IconButton(onClick = onStartSearch) { + Icon( + imageVector = RemixIcon.System.search2Line, + contentDescription = null + ) + } + + this@NavigatorHeader.AnimatedVisibility( + modifier = Modifier + .align(Alignment.TopStart) + .offset(8.dp, 8.dp), + enter = fadeIn(), + exit = fadeOut(), + visible = isSearching() + ) { + Spacer( + modifier = Modifier + .clip(CircleShape) + .background(color = Color.Red) + .size(8.dp) + ) + } + } + } + } + + items( + items = playlistState, + key = { it.id }, + contentType = { LPlaylist::class.java } + ) { playlist -> + ReorderableItem( + state = reorderableState, + key = playlist.id + ) { isDragging -> + PlaylistCard( + playlist = playlist, + draggingModifier = Modifier.draggableHandle( + onDragStopped = { onUpdatePlaylist(playlistState) } + ), + isDragging = { isDragging }, + isSelected = { isSelected(playlist) }, + isSelecting = isSelecting, + onClick = onClickPlaylist, + onLongClick = onLongClickPlaylist + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PlaylistScreenContentPreview() { + MaterialTheme { + PlaylistScreenContent( + playlists = { + buildList { + repeat(10) { + add( + LPlaylist( + id = "$it", + title = "Playlist $it", + subTitle = "Subtitle $it", + coverUri = "", + mediaIds = listOf("", "") + ) + ) + } + } + } + ) + } +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt index 85378208e..0849456c2 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt @@ -52,6 +52,7 @@ import com.lalilu.component.navigation.NavIntent import com.lalilu.lplaylist.R import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository +import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -136,6 +137,7 @@ class PlaylistCreateOrEditScreenModel( /** * [targetPlaylistId] 目标操作歌单的Id */ +@Destination("/pages/playlist/create_or_edit") data class PlaylistCreateOrEditScreen( private val targetPlaylistId: String? = null ) : DynamicScreen(), DialogScreen { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt index f6af26dbb..e44f376e8 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -17,10 +17,12 @@ import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lmedia.entity.LSong import com.lalilu.lplaylist.R import com.lalilu.lplaylist.repository.PlaylistRepository +import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import com.lalilu.component.R as componentR +@Destination("/pages/playlist/detail") data class PlaylistDetailScreen( val playlistId: String ) : Screen, ScreenInfoFactory, ScreenActionFactory { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistsVM.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistsVM.kt new file mode 100644 index 000000000..ecf1d6f65 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistsVM.kt @@ -0,0 +1,102 @@ +package com.lalilu.lplaylist.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lalilu.common.MviWithIntent +import com.lalilu.common.mviImplWithIntent +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.toState +import com.lalilu.lplaylist.entity.LPlaylist +import com.lalilu.lplaylist.repository.PlaylistRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel + +@Stable +@Immutable +data class PlaylistsState( + // control flags + val showSearcherPanel: Boolean = false, + + // control params + val searchKeyWord: String = "", +) { + val distinctKey: Int = searchKeyWord.hashCode() + + @OptIn(ExperimentalCoroutinesApi::class) + fun getPlaylistsFlow(playlistRepo: PlaylistRepository): Flow> { + val sources = playlistRepo.getPlaylistsFlow() + + val keywords: List = when { + searchKeyWord.isBlank() -> emptyList() + searchKeyWord.contains(' ') -> searchKeyWord.split(' ') + else -> listOf(searchKeyWord) + } + + val searchResult = sources.mapLatest { flow -> + flow.filter { item -> keywords.all { item.getMatchStr().contains(it) } } + } + + return searchResult + } +} + +sealed interface PlaylistsAction { + data class UpdatePlaylist(val playlists: List) : PlaylistsAction + data class TryRemovePlaylist(val playlists: Collection) : PlaylistsAction + data class SearchFor(val keyword: String) : PlaylistsAction + data object HideSearcherPanel : PlaylistsAction + data object ShowSearcherPanel : PlaylistsAction +} + +sealed interface PlaylistsEvent { + +} + +@KoinViewModel +class PlaylistsVM(private val playlistRepo: PlaylistRepository) : ViewModel(), + MviWithIntent + by mviImplWithIntent(PlaylistsState()) { + + val selector = ItemSelector() + + @OptIn(ExperimentalCoroutinesApi::class) + val playlists = stateFlow() + .distinctUntilChangedBy { it.distinctKey } + .flatMapLatest { it.getPlaylistsFlow(playlistRepo) } + .toState(emptyList(), viewModelScope) + + val state = stateFlow().toState(PlaylistsState(), viewModelScope) + + override fun intent(intent: PlaylistsAction) = viewModelScope.launch { + when (intent) { + is PlaylistsAction.UpdatePlaylist -> { + playlistRepo.setPlaylists(intent.playlists) + } + + is PlaylistsAction.TryRemovePlaylist -> { + playlistRepo.removeByIds(intent.playlists.map { it.id }) + } + + is PlaylistsAction.SearchFor -> { + reduce { it.copy(searchKeyWord = intent.keyword) } + } + + is PlaylistsAction.HideSearcherPanel -> { + reduce { it.copy(showSearcherPanel = false) } + } + + is PlaylistsAction.ShowSearcherPanel -> { + reduce { it.copy(showSearcherPanel = true) } + } + + else -> {} + } + } +} \ No newline at end of file From 5768f01132606fcaa5a4a50adfae1c04051c089e Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 2 Dec 2024 20:31:18 +0800 Subject: [PATCH 123/213] =?UTF-8?q?[refactor]=E9=87=8D=E6=9E=84=E4=BC=98?= =?UTF-8?q?=E5=8C=96Playlist=E5=88=9B=E5=BB=BA=E9=A1=B5=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3Playlist=E5=88=97=E8=A1=A8=E9=A1=B5=E7=9A=84=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=A1=A8=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/component/base/CustomScreen.kt | 7 - .../com/lalilu/lplaylist/PlaylistModule.kt | 8 - .../lplaylist/component/PlaylistCard.kt | 2 +- .../repository/PlaylistRepositoryImpl.kt | 2 + .../lalilu/lplaylist/screen/PlaylistScreen.kt | 10 +- .../lplaylist/screen/PlaylistScreenContent.kt | 5 +- .../screen/add/PlaylistAddToScreenContent.kt | 4 +- .../create/PlaylistCreateOrEditScreen.kt | 319 ------------------ .../screen/create/PlaylistEditScreen.kt | 107 ++++++ .../create/PlaylistEditScreenContent.kt | 178 ++++++++++ .../lplaylist/viewmodel/PlaylistEditVM.kt | 95 ++++++ 11 files changed, 396 insertions(+), 341 deletions(-) delete mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreenContent.kt create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt diff --git a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt index 6cc551f24..0cf1000d4 100644 --- a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt +++ b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt @@ -4,7 +4,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.base.screen.ScreenInfoFactory @@ -33,10 +32,6 @@ sealed interface ScreenAction { val isLongClickAction: Boolean = false, val onAction: () -> Unit ) : ScreenAction - - data class ComposeAction( - val content: @Composable (Modifier) -> Unit - ) : ScreenAction } data class ScreenBarComponent( @@ -49,8 +44,6 @@ interface CustomScreen : Screen { } interface TabScreen : Screen, ScreenInfoFactory -interface DialogScreen : CustomScreen - interface UiState interface UiAction diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt index 75d8a6ca2..3124ba05e 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt @@ -1,13 +1,9 @@ package com.lalilu.lplaylist -import com.lalilu.lplaylist.repository.PlaylistRepository -import com.lalilu.lplaylist.repository.PlaylistRepositoryImpl -import com.lalilu.lplaylist.screen.create.PlaylistCreateOrEditScreenModel import com.lalilu.lplaylist.screen.detail.PlaylistDetailScreenModel import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module import org.koin.core.module.dsl.factoryOf -import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @Module @@ -15,9 +11,5 @@ import org.koin.dsl.module object PlaylistModule2 val PlaylistModule = module { - singleOf(::PlaylistRepositoryImpl) factoryOf(::PlaylistDetailScreenModel) - factoryOf(::PlaylistCreateOrEditScreenModel) - - single { get() } } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt index 7db5e3430..6ce3f8f34 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt @@ -53,7 +53,7 @@ fun PlaylistCard( Row( modifier = modifier - .padding(horizontal = 12.dp) + .padding(horizontal = 16.dp) .clip(RoundedCornerShape(8.dp)) .background(bgColor) .combinedClickable( diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt index 54addbcfa..1d4de80a0 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt @@ -7,7 +7,9 @@ import com.lalilu.lplaylist.entity.LPlaylist import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapLatest +import org.koin.core.annotation.Single +@Single(binds = [PlaylistRepository::class]) @OptIn(ExperimentalCoroutinesApi::class) internal class PlaylistRepositoryImpl( private val context: Application, diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index 186f7cc9c..3b27a1135 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -107,9 +107,13 @@ data object PlaylistScreen : TabScreen, ScreenBarFactory { onUpdatePlaylist = { vm.intent(PlaylistsAction.UpdatePlaylist(it)) }, onLongClickPlaylist = { vm.selector.onSelect(it) }, onClickPlaylist = { - AppRouter.route("/pages/playlist/detail") - .with("playlistId", it.id) - .push() + if (vm.selector.isSelecting.value) { + vm.selector.onSelect(it) + } else { + AppRouter.route("/pages/playlist/detail") + .with("playlistId", it.id) + .push() + } } ) } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt index 0697b822b..15d7da637 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt @@ -93,7 +93,10 @@ internal fun PlaylistScreenContent( ), title = stringResource(id = R.string.playlist_screen_title) ) { - IconButton(onClick = { AppRouter.route("/pages/playlist/create_or_edit").push() }) { + IconButton(onClick = { + AppRouter.route("/pages/playlist/edit") + .push() + }) { Icon( imageVector = RemixIcon.System.addLargeLine, contentDescription = null diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreenContent.kt index 8160ab63f..e1d079cc4 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreenContent.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreenContent.kt @@ -20,7 +20,7 @@ import com.lalilu.component.navigation.NavIntent import com.lalilu.lplaylist.R import com.lalilu.lplaylist.component.PlaylistCard import com.lalilu.lplaylist.entity.LPlaylist -import com.lalilu.lplaylist.screen.create.PlaylistCreateOrEditScreen +import com.lalilu.lplaylist.screen.create.PlaylistEditScreen @Composable @@ -44,7 +44,7 @@ internal fun PlaylistAddToScreenContent( IconButton( onClick = { AppRouter.intent( - NavIntent.Push(PlaylistCreateOrEditScreen()) + NavIntent.Push(PlaylistEditScreen()) ) } ) { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt deleted file mode 100644 index 0849456c2..000000000 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistCreateOrEditScreen.kt +++ /dev/null @@ -1,319 +0,0 @@ -package com.lalilu.lplaylist.screen.create - -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.koin.getScreenModel -import com.blankj.utilcode.util.ToastUtils -import com.lalilu.common.toCachedFlow -import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.DialogScreen -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenAction -import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.toMutableState -import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.lplaylist.R -import com.lalilu.lplaylist.entity.LPlaylist -import com.lalilu.lplaylist.repository.PlaylistRepository -import com.zhangke.krouter.annotation.Destination -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.mapLatest -import java.util.UUID -import com.lalilu.component.R as componentR - -@OptIn(ExperimentalCoroutinesApi::class) -class PlaylistCreateOrEditScreenModel( - private val playlistRepo: PlaylistRepository -) : ScreenModel { - private val playlistId = MutableStateFlow("") - private val playlist = playlistId - .combine(playlistRepo.getPlaylistsFlow()) { playlistId, playlists -> - playlists.firstOrNull { it.id == playlistId } - }.toCachedFlow() - - val title = playlist.mapLatest { it?.title ?: "" } - .toMutableState(defaultValue = "", scope = screenModelScope) - val subTitle = playlist.mapLatest { it?.subTitle ?: "" } - .toMutableState(defaultValue = "", scope = screenModelScope) - - val createPlaylistAction = ScreenAction.StaticAction( - title = R.string.playlist_action_create_playlist, - icon = componentR.drawable.ic_check_line, - isLongClickAction = true, - color = Color(0xFF008521) - ) { - val title = title.value - val subTitle = subTitle.value - - if (title.isBlank()) { - ToastUtils.showShort("歌单名称不可为空") - } else { - playlistRepo.save( - LPlaylist( - id = UUID.randomUUID().toString(), - title = title, - subTitle = subTitle, - coverUri = "", - mediaIds = emptyList() - ) - ) - - AppRouter.intent(NavIntent.Pop) - } - } - - val updatePlaylistAction = ScreenAction.StaticAction( - title = R.string.playlist_action_update_playlist, - icon = componentR.drawable.ic_check_line, - isLongClickAction = true, - color = Color(0xFF008521) - ) { - val playlistId = playlistId.value - val title = title.value - val subTitle = subTitle.value - - if (title.isBlank()) { - ToastUtils.showShort("歌单名称不可为空") - } else { - val playlist = playlist.get() - - playlistRepo.save( - LPlaylist( - id = playlistId, - title = title, - subTitle = subTitle, - coverUri = playlist?.coverUri ?: "", - mediaIds = playlist?.mediaIds ?: emptyList() - ) - ) - AppRouter.intent(NavIntent.Pop) - } - } - - fun updateTargetPlaylistId(playlistId: String) { - this.playlistId.tryEmit(playlistId) - } -} - -/** - * [targetPlaylistId] 目标操作歌单的Id - */ -@Destination("/pages/playlist/create_or_edit") -data class PlaylistCreateOrEditScreen( - private val targetPlaylistId: String? = null -) : DynamicScreen(), DialogScreen { - override val key: ScreenKey = targetPlaylistId.toString() - - @Composable - override fun registerActions(): List { - val createOrEditSM = getScreenModel() - - return remember { - listOf( - if (targetPlaylistId == null) createOrEditSM.createPlaylistAction - else createOrEditSM.updatePlaylistAction - ) - } - } - - @Composable - override fun Content() { - val createOrEditSM = getScreenModel() - - if (targetPlaylistId != null) { - LaunchedEffect(Unit) { - createOrEditSM.updateTargetPlaylistId(playlistId = targetPlaylistId) - } - } - - PlaylistCreateOrEditScreen( - targetPlaylistId = targetPlaylistId, - createOrEditSM = createOrEditSM - ) - } -} - -@Composable -private fun DynamicScreen.PlaylistCreateOrEditScreen( - targetPlaylistId: String?, - createOrEditSM: PlaylistCreateOrEditScreenModel -) { - val state = rememberLazyListState() - - val onFocusCallback: (String) -> Unit = remember { - { -// scrollToHelper.scrollToItem( -// key = it, -// animateTo = true, -// scrollOffset = -300, -// delay = 100L -// ) - } - } - - val headerTitleRes = remember(targetPlaylistId) { - if (targetPlaylistId == null) R.string.playlist_action_create_playlist else R.string.playlist_action_update_playlist - } - val headerSubTitleRes = remember(targetPlaylistId) { - if (targetPlaylistId == null) R.string.playlist_action_create_playlist else R.string.playlist_action_update_playlist - } - - LLazyColumn( - modifier = Modifier - .fillMaxSize(), - state = state, - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - - item(key = "header") { - NavigatorHeader( - modifier = Modifier.statusBarsPadding(), - title = stringResource(id = headerTitleRes), - subTitle = stringResource(id = headerSubTitleRes), - ) - } - - editTextFor( - title = "主标题", - minLines = 1, - value = createOrEditSM.title, - onInit = { }, - onFocus = onFocusCallback - ) - - editTextFor( - title = "简介/备注", - minLines = 3, - value = createOrEditSM.subTitle, - onInit = { }, - onFocus = onFocusCallback - ) - } -} - -private fun LazyListScope.editTextFor( - title: String, - minLines: Int = 1, - value: MutableState, - onInit: (key: String) -> Unit = {}, - onFocus: (key: String) -> Unit = {} -) { - item(key = title) { - onInit(title) - - EditText( - title = title, - value = value, - minLines = minLines, - onFocus = { onFocus(title) } - ) - } -} - -@Composable -fun EditText( - title: String = "", - minLines: Int = 1, - value: MutableState, - onFocus: () -> Unit = {} -) { - val focusRequest = remember { FocusRequester() } - val focused = remember { mutableStateOf(false) } - val color = contentColorFor(backgroundColor = MaterialTheme.colors.background) - .copy(alpha = 0.3f) - val borderColor = animateColorAsState( - targetValue = if (focused.value) Color(0xFF135CB6) else color, - label = "TextField border color with focus" - ) - - BasicTextField( - modifier = Modifier - .focusRequester(focusRequest) - .onFocusChanged { - focused.value = it.hasFocus && it.isFocused - if (it.hasFocus && it.isFocused) { - onFocus() - } - } - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - minLines = minLines, - textStyle = TextStyle.Default.copy( - color = dayNightTextColor(), - fontSize = 18.sp - ), - cursorBrush = SolidColor(dayNightTextColor()), - value = value.value, - onValueChange = { value.value = it } - ) { innerTextField -> - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Surface( - border = BorderStroke(2.dp, borderColor.value), - shape = RoundedCornerShape(4.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - innerTextField() - } - } - if (title.isNotBlank()) { - Text( - text = title, - fontSize = 12.sp, - color = borderColor.value - ) - } - } - } -} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt new file mode 100644 index 000000000..8064b7d48 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt @@ -0,0 +1,107 @@ +package com.lalilu.lplaylist.screen.create + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import com.blankj.utilcode.util.ToastUtils +import com.lalilu.RemixIcon +import com.lalilu.component.LongClickableTextButton +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.lplaylist.viewmodel.PlaylistEditAction +import com.lalilu.lplaylist.viewmodel.PlaylistEditVM +import com.lalilu.remixicon.Design +import com.lalilu.remixicon.design.editBoxFill +import com.zhangke.krouter.annotation.Destination +import org.koin.core.parameter.parametersOf + + +/** + * [playlistId] 目标操作歌单的Id + */ +@Destination("/pages/playlist/edit") +data class PlaylistEditScreen( + private val playlistId: String? = null +) : Screen, ScreenInfoFactory, ScreenActionFactory { + override val key: ScreenKey = playlistId.toString() + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { "歌单创建编辑页" }, + icon = RemixIcon.Design.editBoxFill + ) + } + + @Composable + override fun provideScreenActions(): List { + val vm = registerAndGetViewModel( + parameters = { parametersOf(playlistId) } + ) + + return remember { + listOfNotNull( + ScreenAction.Dynamic { + val color = Color(0xFF0074FF) + + LongClickableTextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = 20.dp), + colors = ButtonDefaults.textButtonColors( + backgroundColor = color.copy(alpha = 0.15f), + contentColor = color + ), + enableLongClickMask = true, + onLongClick = { vm.intent(PlaylistEditAction.Confirm) }, + onClick = { ToastUtils.showShort("请长按此按钮以继续") }, + ) { + Image( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.Design.editBoxFill, + contentDescription = null, + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = if (vm.playlist.value == null) "创建歌单" else "更新歌单", + fontSize = 14.sp + ) + } + } + ) + } + } + + @Composable + override fun Content() { + val vm = registerAndGetViewModel( + parameters = { parametersOf(playlistId) } + ) + + PlaylistEditScreenContent( + titleValue = { vm.titleState.value }, + onUpdateTitle = { vm.titleState.value = it }, + subTitleValue = { vm.subTitleState.value }, + onUpdateSubTitle = { vm.subTitleState.value = it } + ) + } +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreenContent.kt new file mode 100644 index 000000000..ac813e90f --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreenContent.kt @@ -0,0 +1,178 @@ +package com.lalilu.lplaylist.screen.create + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActionScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.base.NavigatorHeader + +@Composable +internal fun PlaylistEditScreenContent( + titleHint: () -> String = { "" }, + subTitleHint: () -> String = { "" }, + isEditing: () -> Boolean = { false }, + titleValue: () -> String = { "" }, + subTitleValue: () -> String = { "" }, + onUpdateTitle: (String) -> Unit = {}, + onUpdateSubTitle: (String) -> Unit = {} +) { + val focusRequestForTitle = remember { FocusRequester() } + val focusRequestForSubTitle = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + NavigatorHeader( + modifier = Modifier.statusBarsPadding(), + title = if (isEditing()) "更新歌单" else "创建歌单", + subTitle = if (isEditing()) "更新歌单" else "创建歌单", + ) + } + item { + EditText( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + title = "标题", + text = titleValue, + focusRequester = focusRequestForTitle, + onUpdateText = onUpdateTitle, + onNext = { focusRequestForSubTitle.requestFocus() } + ) + } + item { + EditText( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + title = "简介/副标题", + text = subTitleValue, + focusRequester = focusRequestForSubTitle, + onUpdateText = onUpdateSubTitle, + onDone = { + keyboard?.hide() + focusRequestForTitle.freeFocus() + focusRequestForSubTitle.freeFocus() + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PlaylistEditScreenContentPreview() { + MaterialTheme { + PlaylistEditScreenContent( + titleValue = { "Title" }, + subTitleValue = { "SubTitle" } + ) + } +} + +@Composable +fun EditText( + modifier: Modifier = Modifier, + title: String, + focusRequester: FocusRequester, + text: () -> String = { "" }, + onUpdateText: (String) -> Unit = {}, + onFocus: () -> Unit = {}, + onNext: (KeyboardActionScope.() -> Unit)? = null, + onDone: (KeyboardActionScope.() -> Unit)? = null +) { + val focused = remember { mutableStateOf(false) } + + BasicTextField( + modifier = modifier + .focusRequester(focusRequester) + .onFocusChanged { + focused.value = it.hasFocus && it.isFocused + if (it.hasFocus && it.isFocused) onFocus() + } + .fillMaxWidth() + .padding(bottom = 8.dp), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = when { + onDone != null -> ImeAction.Done + onNext != null -> ImeAction.Next + else -> ImeAction.Default + }, + keyboardType = KeyboardType.Text, + showKeyboardOnFocus = true + ), + keyboardActions = KeyboardActions( + onNext = onNext, + onDone = onDone + ), + textStyle = TextStyle.Default.copy( + color = MaterialTheme.colors.onBackground, + fontSize = 16.sp, + lineHeight = 24.sp + ), + minLines = 2, + cursorBrush = SolidColor(MaterialTheme.colors.onBackground), + value = text(), + onValueChange = onUpdateText + ) { innerTextField -> + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (title.isNotBlank()) { + Text( + text = title, + fontSize = 12.sp, + color = MaterialTheme.colors.onBackground + ) + } + + Surface( + color = MaterialTheme.colors.onBackground.copy(0.05f), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + innerTextField() + } + } + } + } +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt new file mode 100644 index 000000000..c7e21bdde --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt @@ -0,0 +1,95 @@ +package com.lalilu.lplaylist.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.ToastUtils +import com.lalilu.common.MviWithIntent +import com.lalilu.common.mviImplWithIntent +import com.lalilu.component.extension.toMutableState +import com.lalilu.component.extension.toState +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent +import com.lalilu.lplaylist.entity.LPlaylist +import com.lalilu.lplaylist.repository.PlaylistRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalCoroutinesApi::class) +data class PlaylistEditState( + val playlistId: String, +) { + fun getPlaylistFlow(playlistRepo: PlaylistRepository): Flow { + return playlistRepo.getPlaylistsFlow().mapLatest { list -> + list.firstOrNull { it.id == playlistId } + } + } +} + +sealed interface PlaylistEditAction { + data object Confirm : PlaylistEditAction +} + +sealed interface PlaylistEditEvent { + +} + +@OptIn(ExperimentalUuidApi::class, ExperimentalCoroutinesApi::class) +@KoinViewModel +class PlaylistEditVM( + playlistId: String? = null, + private val actualId: String = playlistId ?: Uuid.random().toHexString(), + private val playlistRepo: PlaylistRepository +) : ViewModel(), + MviWithIntent + by mviImplWithIntent(PlaylistEditState(actualId)) { + + val state = stateFlow() + .toState(PlaylistEditState(actualId), viewModelScope) + + private val playlistFlow = stateFlow() + .distinctUntilChangedBy { it.playlistId } + .flatMapLatest { it.getPlaylistFlow(playlistRepo) } + + val titleState = playlistFlow + .mapLatest { it?.title ?: "" } + .toMutableState("", viewModelScope) + + val subTitleState = playlistFlow + .mapLatest { it?.subTitle ?: "" } + .toMutableState("", viewModelScope) + + val playlist = playlistFlow + .toState(viewModelScope) + + override fun intent(intent: PlaylistEditAction) = viewModelScope.launch { + when (intent) { + is PlaylistEditAction.Confirm -> { + if (titleState.value.isBlank()) { + ToastUtils.showShort("歌单标题不能为空") + return@launch + } + + playlistRepo.save( + LPlaylist( + id = state.value.playlistId, + title = titleState.value, + subTitle = subTitleState.value, + mediaIds = playlist.value?.mediaIds ?: emptyList(), + coverUri = playlist.value?.coverUri ?: "", + ) + ) + + AppRouter.intent(NavIntent.Pop) + } + + else -> {} + } + } +} \ No newline at end of file From b46733fcd2c753b428e73fa934264888f30e8dcd Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 3 Dec 2024 01:49:16 +0800 Subject: [PATCH 124/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=AD=8C=E5=8D=95=E8=AF=A6=E6=83=85=E9=A1=B5=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=92=8C=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 4 +- lmedia | 2 +- .../com/lalilu/lplaylist/PlaylistModule.kt | 9 +- .../screen/detail/PlaylistDetailScreen.kt | 214 +++++++++++------- .../detail/PlaylistDetailScreenContent.kt | 189 +++++++++++++++- .../lplaylist/viewmodel/PlaylistDetailVM.kt | 171 ++++++++++++++ 6 files changed, 495 insertions(+), 94 deletions(-) create mode 100644 lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 3af11a172..67401d702 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -16,7 +16,6 @@ import com.lalilu.lmedia.indexer.FilterProvider import com.lalilu.lmusic.utils.extension.ignoreSSLVerification import com.lalilu.lplayer.MPlayer import com.lalilu.lplaylist.PlaylistModule -import com.lalilu.lplaylist.PlaylistModule2 import com.zhangke.krouter.KRouter import com.zhangke.krouter.generated.KRouterInjectMap import org.koin.android.ext.android.inject @@ -46,10 +45,9 @@ class LMusicApp : Application(), FilterProvider, ViewModelStoreOwner { ViewModelModule, RuntimeModule, FilterModule, - PlaylistModule, ComponentModule, HistoryModule.module, - PlaylistModule2.module, + PlaylistModule.module, ArtistModule.module, AlbumModule.module, DictionaryModule, diff --git a/lmedia b/lmedia index 40d9fb5e1..5d1bd3405 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 40d9fb5e14c1bf48ce28c8c9e2e8b86942622056 +Subproject commit 5d1bd34051a6f67964ee67949e26242a3f7f2057 diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt index 3124ba05e..e9c96d256 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt @@ -1,15 +1,8 @@ package com.lalilu.lplaylist -import com.lalilu.lplaylist.screen.detail.PlaylistDetailScreenModel import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module -import org.koin.core.module.dsl.factoryOf -import org.koin.dsl.module @Module @ComponentScan("com.lalilu.lplaylist") -object PlaylistModule2 - -val PlaylistModule = module { - factoryOf(::PlaylistDetailScreenModel) -} \ No newline at end of file +object PlaylistModule \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt index e44f376e8..be2f0b3b0 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -1,31 +1,45 @@ package com.lalilu.lplaylist.screen.detail import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.screen.Screen -import com.blankj.utilcode.util.ToastUtils -import com.lalilu.common.toCachedFlow +import com.lalilu.RemixIcon +import com.lalilu.common.ext.requestFor import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.component.extension.SelectAction -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lmedia.entity.LSong +import com.lalilu.component.base.songs.SongsHeaderJumperDialog +import com.lalilu.component.base.songs.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSelectorPanel +import com.lalilu.component.base.songs.SongsSortPanelDialog +import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lplaylist.R -import com.lalilu.lplaylist.repository.PlaylistRepository +import com.lalilu.lplaylist.viewmodel.PlaylistDetailAction +import com.lalilu.lplaylist.viewmodel.PlaylistDetailVM +import com.lalilu.remixicon.Design +import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.System +import com.lalilu.remixicon.design.editBoxLine +import com.lalilu.remixicon.design.focus3Line +import com.lalilu.remixicon.editor.sortDesc +import com.lalilu.remixicon.system.checkboxMultipleBlankLine +import com.lalilu.remixicon.system.checkboxMultipleLine +import com.lalilu.remixicon.system.menuSearchLine import com.zhangke.krouter.annotation.Destination -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import com.lalilu.component.R as componentR +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named @Destination("/pages/playlist/detail") data class PlaylistDetailScreen( val playlistId: String -) : Screen, ScreenInfoFactory, ScreenActionFactory { +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { @Composable override fun provideScreenInfo(): ScreenInfo = remember { @@ -36,82 +50,124 @@ data class PlaylistDetailScreen( @Composable override fun provideScreenActions(): List { - return remember { listOf() } + val vm = registerAndGetViewModel( + parameters = { parametersOf(playlistId) } + ) + + val state by vm.state + + return remember { + listOf( + ScreenAction.Static( + title = { "排序" }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, + onAction = { vm.intent(PlaylistDetailAction.ToggleSortPanel) } + ), + ScreenAction.Static( + title = { "选择" }, + icon = { RemixIcon.Design.editBoxLine }, + color = { Color(0xFF009673) }, + onAction = { vm.selector.isSelecting.value = true } + ), + ScreenAction.Static( + title = { "搜索" }, + subTitle = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) "搜索中: $keyword" else null + }, + icon = { RemixIcon.System.menuSearchLine }, + color = { Color(0xFF8BC34A) }, + dotColor = { + val keyword = state.searchKeyWord + if (keyword.isNotBlank()) Color.Red else null + }, + onAction = { + vm.intent(PlaylistDetailAction.ToggleSearcherPanel) + DialogWrapper.dismiss() + } + ), + ScreenAction.Static( + title = { "定位至当前播放歌曲" }, + icon = { RemixIcon.Design.focus3Line }, + color = { Color(0xFF8700FF) }, + onAction = { vm.intent(PlaylistDetailAction.LocaleToPlayingItem) } + ), + ) + } } @Composable override fun Content() { + val vm = registerAndGetViewModel( + parameters = { parametersOf(playlistId) } + ) + + val state by vm.state + val songs by vm.songs + val playlist by vm.playlist - PlaylistDetailScreen( - playlistId = playlistId, + SongsSortPanelDialog( + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(PlaylistDetailAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(PlaylistDetailAction.SelectSortAction(it)) } ) - } -} -class PlaylistDetailScreenModel( - private val playingVM: IPlayingViewModel, - private val playlistRepo: PlaylistRepository, -) : ScreenModel { - private val playlistId = MutableStateFlow("") + SongsHeaderJumperDialog( + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(PlaylistDetailAction.HideJumperDialog) }, + items = { songs.keys }, + onSelectItem = { vm.intent(PlaylistDetailAction.LocaleToGroupItem(it)) } + ) - val playlist = playlistId - .combine(playlistRepo.getPlaylistsFlow()) { id, playlists -> - playlists.firstOrNull { it.id == id } - }.toCachedFlow() + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(PlaylistDetailAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(PlaylistDetailAction.SearchFor(it)) } + ) - val deleteAction = SelectAction.StaticAction.Custom( - title = R.string.playlist_action_remove_from_playlist, - forLongClick = true, - icon = componentR.drawable.ic_delete_bin_6_line, - color = Color.Red - ) { selector -> - val mediaIds = selector.selected.value.filterIsInstance() - .map { it.id } + SongsSelectorPanel( + isVisible = { vm.selector.isSelecting.value }, + onDismiss = { vm.selector.isSelecting.value = false }, + screenActions = listOfNotNull( + ScreenAction.Static( + title = { "全选" }, + color = { Color(0xFF00ACF0) }, + icon = { RemixIcon.System.checkboxMultipleLine }, + onAction = { vm.selector.selectAll(vm.songs.value.values.flatten()) } + ), + ScreenAction.Static( + title = { "取消全选" }, + icon = { RemixIcon.System.checkboxMultipleBlankLine }, + color = { Color(0xFFFF5100) }, + onAction = { vm.selector.clear() } + ), + requestFor( + qualifier = named("add_to_favourite_action"), + parameters = { parametersOf(vm.selector::selected) } + ), + requestFor( + qualifier = named("add_to_playlist_action"), + parameters = { parametersOf(vm.selector::selected) } + ) + ) + ) - playlistRepo.removeMediaIdsFromPlaylist(mediaIds, playlistId.value) - ToastUtils.showShort("Removed from playlist") + PlaylistDetailScreenContent( + songs = songs, + playlist = playlist, + enableDraggable = state.selectedSortAction is SortStaticAction.Normal, + keys = { vm.recorder.list().filterNotNull() }, + recorder = vm.recorder, + eventFlow = vm.eventFlow(), + isSelecting = { vm.selector.isSelecting.value }, + isSelected = { vm.selector.isSelected(it) }, + onSelect = { vm.selector.onSelect(it) }, + onClickGroup = { vm.intent(PlaylistDetailAction.ToggleJumperDialog) }, + onUpdatePlaylist = { vm.intent(PlaylistDetailAction.UpdatePlaylist(it)) } + ) } - -// val playAllAction = ScreenAction.StaticAction( -// title = R.string.playlist_action_play_all, -// icon = componentR.drawable.ic_play_list_2_fill, -// color = Color(0xFF008521) -// ) { -// val mediaIds = playlist.get()?.mediaIds ?: emptyList() -// -// if (mediaIds.isEmpty()) { -// ToastUtils.showShort("No item to play") -// } else { -// playingVM.play( -// mediaIds = mediaIds, -// mediaId = mediaIds.first() -// ) -// } -// } -// -// val playAllRandomlyAction = ScreenAction.StaticAction( -// title = R.string.playlist_action_play_randomly, -// icon = componentR.drawable.ic_dice_line, -// color = Color(0xFF8D01B4) -// ) { -// val mediaIds = playlist.get()?.mediaIds ?: emptyList() -// -// if (mediaIds.isEmpty()) { -// ToastUtils.showShort("No item to play") -// } else { -// playingVM.play( -// mediaIds = mediaIds.shuffled(), -// mediaId = mediaIds.random() -// ) -// } -// } -// -// fun updatePlaylistId(playlistId: String) { -// this.playlistId.tryEmit(playlistId) -// } -// -// fun onDragMoveEnd(items: List) { -// val mediaId = items.map { it.mediaId } -// playlistRepo.updateMediaIdsToPlaylist(mediaId, playlistId.value) -// } -} +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt index 4e7e7c611..58b0710bb 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt @@ -1,11 +1,194 @@ package com.lalilu.lplaylist.screen.detail +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gigamole.composefadingedges.FadingEdgesGravity +import com.gigamole.composefadingedges.content.FadingEdgesContentType +import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig +import com.gigamole.composefadingedges.fill.FadingEdgesFillType +import com.gigamole.composefadingedges.verticalFadingEdges +import com.lalilu.component.base.smartBarPadding +import com.lalilu.component.base.songs.SongsScreenStickyHeader +import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.startRecord +import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplaylist.entity.LPlaylist +import com.lalilu.lplaylist.viewmodel.PlaylistDetailEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState @Composable -private fun PlaylistDetailScreenContent( - playlistId: String, - playlistDetailSM: PlaylistDetailScreenModel, +internal fun PlaylistDetailScreenContent( + playlist: LPlaylist? = null, + songs: Map> = emptyMap(), + enableDraggable: Boolean = false, + eventFlow: Flow = emptyFlow(), + keys: () -> Collection = { emptyList() }, + recorder: ItemRecorder = ItemRecorder(), + isSelecting: () -> Boolean = { false }, + isSelected: (LSong) -> Boolean = { false }, + onSelect: (LSong) -> Unit = {}, + onClickGroup: (GroupIdentity) -> Unit = {}, + onUpdatePlaylist: (List) -> Unit = {} ) { + val density = LocalDensity.current + val statusBar = WindowInsets.statusBars + val hapticFeedback = LocalHapticFeedback.current + val listState: LazyListState = rememberLazyListState() + val stickyHeaderContentType = remember { "group" } + val playlistState = remember(songs) { + songs.values.flatten().toMutableStateList() + } + + val reorderableState = rememberReorderableLazyListState( + lazyListState = listState + ) { from, to -> + playlistState.toMutableList().apply { + val toIndex = indexOfFirst { it.id == to.key } + val fromIndex = indexOfFirst { it.id == from.key } + if (toIndex < 0 || fromIndex < 0) return@rememberReorderableLazyListState + + add(toIndex, removeAt(fromIndex)) + playlistState.clear() + playlistState.addAll(this) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .verticalFadingEdges( + length = statusBar + .asPaddingValues() + .calculateTopPadding(), + contentType = FadingEdgesContentType.Dynamic.Lazy.List( + scrollConfig = FadingEdgesScrollConfig.Dynamic(), + state = listState + ), + gravity = FadingEdgesGravity.Start, + fillType = remember { + FadingEdgesFillType.FadeClip( + fillStops = Triple(0f, 0.7f, 1f) + ) + } + ), + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + startRecord(recorder) { + itemWithRecord(key = "HEADER") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = playlist?.title ?: "Unknown", + fontSize = 20.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + Text( + text = "共 ${playlist?.mediaIds?.size ?: 0} 首歌曲", + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + } + + songs.forEach { (group, list) -> + if (group !is GroupIdentity.None) { + stickyHeaderWithRecord( + key = group, + contentType = stickyHeaderContentType + ) { + SongsScreenStickyHeader( + modifier = Modifier.animateItem(), + listState = listState, + group = group, + minOffset = { statusBar.getTop(density) }, + onClickGroup = onClickGroup + ) + } + } + + itemsWithRecord( + items = list, + key = { it.id }, + contentType = { it::class.java } + ) { item -> + ReorderableItem( + state = reorderableState, + enabled = enableDraggable, + key = item.id + ) { + SongCard( + dragModifier = Modifier.draggableHandle( + enabled = enableDraggable, + onDragStopped = { onUpdatePlaylist(playlistState.map { it.id }) } + ), + song = { item }, + isSelected = { isSelected(item) }, + onClick = { + if (isSelecting()) { + onSelect(item) + } else { + PlayerAction.PlayById(item.id).action() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + if (isSelecting()) { + onSelect(item) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", item.id) + .jump() + } + }, + onEnterSelect = { onSelect(item) } + ) + } + } + } + } + + smartBarPadding() + } } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt new file mode 100644 index 000000000..65b489e4f --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt @@ -0,0 +1,171 @@ +package com.lalilu.lplaylist.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.LogUtils +import com.lalilu.common.MviWithIntent +import com.lalilu.common.ext.requestFor +import com.lalilu.common.mviImplWithIntent +import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.ItemSelector +import com.lalilu.component.extension.toState +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lmedia.extension.ListAction +import com.lalilu.lmedia.extension.SortDynamicAction +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lplayer.MPlayer +import com.lalilu.lplaylist.entity.LPlaylist +import com.lalilu.lplaylist.repository.PlaylistRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import org.koin.core.qualifier.named + +@OptIn(ExperimentalCoroutinesApi::class) +@Stable +@Immutable +data class PlaylistDetailState( + val playlistId: String, + + // control flags + val showSortPanel: Boolean = false, + val showJumperDialog: Boolean = false, + val showSearcherPanel: Boolean = false, + + // control params + val searchKeyWord: String = "", + val selectedSortAction: ListAction = SortStaticAction.Normal, +) { + val distinctKey: Int = searchKeyWord.hashCode() + selectedSortAction.hashCode() + + fun getPlaylistFlow(playlistRepo: PlaylistRepository): Flow { + return playlistRepo.getPlaylistsFlow() + .mapLatest { list -> list.firstOrNull { it.id == playlistId } } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun getSongsFlow(playlistRepo: PlaylistRepository): Flow>> { + val source = getPlaylistFlow(playlistRepo) + .flatMapLatest { + LMedia.flowMapBy(it?.mediaIds ?: emptyList()) + } + + val keywords: List = when { + searchKeyWord.isBlank() -> emptyList() + searchKeyWord.contains(' ') -> searchKeyWord.split(' ') + else -> listOf(searchKeyWord) + } + + val searchResult = source.mapLatest { flow -> + flow.filter { item -> keywords.all { item.getMatchStr().contains(it) } } + } + + return when (selectedSortAction) { + is SortStaticAction -> searchResult.mapLatest { + selectedSortAction.doSort(it, false) + } + + is SortDynamicAction -> selectedSortAction.doSort(searchResult, false) + else -> flowOf(emptyMap()) + } + } +} + +sealed interface PlaylistDetailEvent { + data class ScrollToItem(val key: Any) : PlaylistDetailEvent +} + +sealed interface PlaylistDetailAction { + data object ToggleSortPanel : PlaylistDetailAction + data object ToggleSearcherPanel : PlaylistDetailAction + data object ToggleJumperDialog : PlaylistDetailAction + + data object HideSortPanel : PlaylistDetailAction + data object HideSearcherPanel : PlaylistDetailAction + data object HideJumperDialog : PlaylistDetailAction + + data object LocaleToPlayingItem : PlaylistDetailAction + data class LocaleToGroupItem(val item: GroupIdentity) : PlaylistDetailAction + data class SearchFor(val keyword: String) : PlaylistDetailAction + data class SelectSortAction(val action: ListAction) : PlaylistDetailAction + data class UpdatePlaylist(val mediaIds: List) : PlaylistDetailAction +} + +@OptIn(ExperimentalCoroutinesApi::class) +@KoinViewModel +class PlaylistDetailVM( + private val playlistId: String, + private val playlistRepo: PlaylistRepository +) : ViewModel(), + MviWithIntent by + mviImplWithIntent(PlaylistDetailState(playlistId)) { + val selector = ItemSelector() + val recorder = ItemRecorder() + + val songs = stateFlow() + .distinctUntilChangedBy { it.distinctKey } + .flatMapLatest { it.getSongsFlow(playlistRepo) } + .toState(emptyMap(), viewModelScope) + val playlist = stateFlow() + .flatMapLatest { it.getPlaylistFlow(playlistRepo) } + .toState(viewModelScope) + val state = stateFlow() + .toState(PlaylistDetailState(playlistId), viewModelScope) + + val supportSortActions: Set = + setOf( + SortStaticAction.Normal, + SortStaticAction.Title, + SortStaticAction.AddTime, + SortStaticAction.Shuffle, + SortStaticAction.Duration, + requestFor(named("sort_rule_play_count")), + requestFor(named("sort_rule_last_play_time")), + ).filterNotNull() + .toSet() + + override fun intent(intent: PlaylistDetailAction) = viewModelScope.launch { + when (intent) { + PlaylistDetailAction.ToggleJumperDialog -> reduce { it.copy(showJumperDialog = !it.showJumperDialog) } + PlaylistDetailAction.ToggleSearcherPanel -> reduce { it.copy(showSearcherPanel = !it.showSearcherPanel) } + PlaylistDetailAction.ToggleSortPanel -> reduce { it.copy(showSortPanel = !it.showSortPanel) } + PlaylistDetailAction.HideSortPanel -> reduce { it.copy(showSortPanel = false) } + PlaylistDetailAction.HideSearcherPanel -> reduce { it.copy(showSearcherPanel = false) } + PlaylistDetailAction.HideJumperDialog -> reduce { it.copy(showJumperDialog = false) } + is PlaylistDetailAction.SearchFor -> reduce { it.copy(searchKeyWord = intent.keyword) } + is PlaylistDetailAction.SelectSortAction -> reduce { it.copy(selectedSortAction = intent.action) } + is PlaylistDetailAction.LocaleToGroupItem -> postEvent { + PlaylistDetailEvent.ScrollToItem( + intent.item + ) + } + + is PlaylistDetailAction.LocaleToPlayingItem -> { + val mediaId = MPlayer.currentMediaItem?.mediaId ?: run { + LogUtils.e("can not find playing item's mediaId") + return@launch + } + postEvent { PlaylistDetailEvent.ScrollToItem(mediaId) } + } + + is PlaylistDetailAction.UpdatePlaylist -> { + playlist.value?.copy(mediaIds = intent.mediaIds) + ?.let { playlistRepo.save(it) } + } + + else -> { + LogUtils.i("Not implemented action: $intent") + } + } + } +} + From 1237d0fb3174e73cfbf24f046529564e06899fc8 Mon Sep 17 00:00:00 2001 From: Qiu <35896157+cy745@users.noreply.github.com> Date: Tue, 3 Dec 2024 10:15:45 +0800 Subject: [PATCH 125/213] Update android_daily_update.yml --- .github/workflows/android_daily_update.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android_daily_update.yml b/.github/workflows/android_daily_update.yml index eb0eb1b62..db1b8a95c 100644 --- a/.github/workflows/android_daily_update.yml +++ b/.github/workflows/android_daily_update.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2.4.2 + uses: actions/checkout@v4.2.2 with: repository: cy745/lmusic ref: dev @@ -22,7 +22,7 @@ jobs: clean: true fetch-depth: 1 lfs: false - submodules: true + submodules: recursive - name: Create the Keystore from Secrets to Sign the App env: @@ -35,7 +35,7 @@ jobs: echo $KEYSTORE_PROPERTIES_BASE64 | base64 -di > ${{ github.workspace }}/keystore.properties - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4.5.0 with: java-version: '17' distribution: 'temurin' @@ -47,7 +47,7 @@ jobs: run: ./gradlew build - name: Upload Apk to Artifact - uses: actions/upload-artifact@v3.1.0 + uses: actions/upload-artifact@v4.4.3 with: name: LMusic-Apks path: ${{ github.workspace }}/app/build/outputs/apk/release/*.apk From 0b39264bcdf2ef267e3f79e83f2b9e0287f17e04 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 3 Dec 2024 11:11:40 +0800 Subject: [PATCH 126/213] =?UTF-8?q?[refactor]=E8=A7=A3=E5=86=B3=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=88=97=E8=A1=A8=E5=85=83=E7=B4=A0=E6=8B=96=E5=8A=A8?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=BC=82=E5=B8=B8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/PlaylistDetailScreenContent.kt | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt index 58b0710bb..fae2236de 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt @@ -131,35 +131,18 @@ internal fun PlaylistDetailScreenContent( } } - songs.forEach { (group, list) -> - if (group !is GroupIdentity.None) { - stickyHeaderWithRecord( - key = group, - contentType = stickyHeaderContentType - ) { - SongsScreenStickyHeader( - modifier = Modifier.animateItem(), - listState = listState, - group = group, - minOffset = { statusBar.getTop(density) }, - onClickGroup = onClickGroup - ) - } - } - + if (enableDraggable) { itemsWithRecord( - items = list, + items = playlistState, key = { it.id }, contentType = { it::class.java } ) { item -> ReorderableItem( state = reorderableState, - enabled = enableDraggable, key = item.id ) { SongCard( dragModifier = Modifier.draggableHandle( - enabled = enableDraggable, onDragStopped = { onUpdatePlaylist(playlistState.map { it.id }) } ), song = { item }, @@ -186,6 +169,54 @@ internal fun PlaylistDetailScreenContent( ) } } + } else { + songs.forEach { (group, list) -> + if (group !is GroupIdentity.None) { + stickyHeaderWithRecord( + key = group, + contentType = stickyHeaderContentType + ) { + SongsScreenStickyHeader( + modifier = Modifier.animateItem(), + listState = listState, + group = group, + minOffset = { statusBar.getTop(density) }, + onClickGroup = onClickGroup + ) + } + } + + itemsWithRecord( + items = list, + key = { it.id }, + contentType = { it::class.java } + ) { item -> + SongCard( + modifier = Modifier.animateItem(), + song = { item }, + isSelected = { isSelected(item) }, + onClick = { + if (isSelecting()) { + onSelect(item) + } else { + PlayerAction.PlayById(item.id).action() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + if (isSelecting()) { + onSelect(item) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", item.id) + .jump() + } + }, + onEnterSelect = { onSelect(item) } + ) + } + } } } From e1e8e2383942959b9c50d42ba9287f164dd7e6b0 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 3 Dec 2024 11:11:52 +0800 Subject: [PATCH 127/213] =?UTF-8?q?[refactor]=E8=A7=A3=E5=86=B3=E6=B7=B7?= =?UTF-8?q?=E6=B7=86=E5=BC=82=E5=B8=B8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/proguard-rules.pro | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e9a039098..305e96ab2 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -51,4 +51,19 @@ # 墨 · 状态栏歌词 -keep class StatusBarLyric.API.StatusBarLyric { *; } --printmapping ../mapping.txt \ No newline at end of file +-printmapping ../mapping.txt + +-dontwarn org.gradle.api.Action +-dontwarn org.gradle.api.Named +-dontwarn org.gradle.api.Plugin +-dontwarn org.gradle.api.Task +-dontwarn org.gradle.api.artifacts.Dependency +-dontwarn org.gradle.api.artifacts.ExternalModuleDependency +-dontwarn org.gradle.api.attributes.Attribute +-dontwarn org.gradle.api.attributes.AttributeCompatibilityRule +-dontwarn org.gradle.api.attributes.AttributeContainer +-dontwarn org.gradle.api.attributes.AttributeDisambiguationRule +-dontwarn org.gradle.api.attributes.HasAttributes +-dontwarn org.gradle.api.component.SoftwareComponent +-dontwarn org.gradle.api.plugins.ExtensionAware +-dontwarn org.gradle.api.tasks.util.PatternFilterable \ No newline at end of file From 447b087db60cdb245751501496877c2b949f846f Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 3 Dec 2024 18:27:40 +0800 Subject: [PATCH 128/213] =?UTF-8?q?[refactor]=E8=A7=A3=E5=86=B3viewModel?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/lalilu/lmusic/compose/App.kt | 7 +- .../compose/screen/songs/SongsScreen.kt | 7 +- .../lalilu/component/extension/ComposeExt.kt | 78 +++++---------- .../lalilu/component/navigation/AppRouter.kt | 2 + gradle/libs.versions.toml | 2 + .../lalilu/lalbum/screen/AlbumDetailScreen.kt | 7 +- .../com/lalilu/lalbum/screen/AlbumsScreen.kt | 7 +- .../lartist/screen/ArtistDetailScreen.kt | 7 +- .../lartist/screen/artists/ArtistsScreen.kt | 7 +- .../lalilu/lplaylist/screen/PlaylistScreen.kt | 4 +- .../screen/create/PlaylistEditScreen.kt | 38 ++++++- .../screen/detail/PlaylistDetailScreen.kt | 6 +- .../detail/PlaylistDetailScreenContent.kt | 98 +++++++++++++++---- .../lplaylist/viewmodel/PlaylistEditVM.kt | 14 ++- 14 files changed, 176 insertions(+), 108 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/App.kt b/app/src/main/java/com/lalilu/lmusic/compose/App.kt index 3653bccda..a61b8c366 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/App.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/App.kt @@ -9,17 +9,22 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.jetpack.ProvideNavigatorLifecycleKMPSupport import com.lalilu.component.base.LocalWindowSize import com.lalilu.lmusic.LMusicTheme @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) object App { + @OptIn(ExperimentalVoyagerApi::class) @Composable fun Content(activity: Activity) { Environment(activity = activity) { Box(modifier = Modifier.fillMaxSize()) { - LayoutWrapperContent() + ProvideNavigatorLifecycleKMPSupport { + LayoutWrapperContent() + } } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt index 1cb05e20e..10dcf52e7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -20,8 +20,7 @@ import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper -import com.lalilu.component.extension.getViewModel -import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.component.extension.screenVM import com.lalilu.lmusic.viewmodel.SongsAction import com.lalilu.lmusic.viewmodel.SongsVM import com.lalilu.remixicon.Design @@ -55,7 +54,7 @@ data class SongsScreen( @Composable override fun provideScreenActions(): List { - val vm = getViewModel() + val vm = screenVM() val state by vm.state return remember { @@ -101,7 +100,7 @@ data class SongsScreen( @Composable override fun Content() { - val vm = registerAndGetViewModel(parameters = { parametersOf(mediaIds) }) + val vm = screenVM(parameters = { parametersOf(mediaIds) }) val songs by vm.songs val state by vm.state diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt index b2087e4b4..2bae1f533 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt @@ -18,8 +18,6 @@ import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -37,6 +35,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.core.annotation.InternalVoyagerApi +import cafe.adriel.voyager.core.lifecycle.LocalNavigatorScreenLifecycleProvider +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.jetpack.ScreenLifecycleKMPOwner import com.lalilu.common.SystemUiUtil import com.lalilu.component.R import com.lalilu.component.base.LocalWindowSize @@ -49,7 +52,6 @@ import org.koin.core.parameter.ParametersDefinition import org.koin.core.qualifier.Qualifier import org.koin.core.scope.Scope import org.koin.viewmodel.defaultExtras -import java.lang.ref.WeakReference import kotlin.math.roundToInt @Composable @@ -192,30 +194,6 @@ fun buildScrollToItemAction( } } - -/** - * [LaunchedEffect] 在监听值的变化的同时无法兼顾Composition的变化,会在Compose移除后继续执行内部代码 - * [DisposableEffect] 在进入Composition的时候无法处理初始值,只会处理之后值变化的情况 - * - * 为了处理初始值和避免Compose移除后仍处理值的变化的问题,创建了这个Effect - */ -@Composable -fun LaunchedDisposeEffect( - key: () -> T, - onDispose: () -> Unit = {}, - onUpdate: (key: T) -> Unit -) { - val item = key() - DisposableEffect(item) { - onUpdate(item) - onDispose(onDispose) - } - - LaunchedEffect(Unit) { - onUpdate(item) - } -} - /** * 监听滚动位置的变化,计算总的滚动距离 */ @@ -288,53 +266,41 @@ fun rememberIsPadLandScape(): State { } } +@Deprecated(message = "弃用") @Composable inline fun singleViewModel(): T = koinViewModel(viewModelStoreOwner = koinInject()) -val registerMap = mutableMapOf, WeakReference>() - @Composable -inline fun registerAndGetViewModel( +inline fun Screen.screenVM( qualifier: Qualifier? = null, - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull( + value = getScreenViewModelStoreOwner() ?: LocalViewModelStoreOwner.current, + lazyMessage = { "No Registered ViewModelStoreOwner was provided via registerMap for ${T::class.java}" } + ), key: String? = null, extras: CreationExtras = defaultExtras(viewModelStoreOwner), scope: Scope = currentKoinScope(), noinline parameters: ParametersDefinition? = null, ): T { - val actualViewModelStoreOwner = registerMap[T::class.java]?.get() ?: viewModelStoreOwner - return koinViewModel( qualifier = qualifier, - viewModelStoreOwner = actualViewModelStoreOwner, + viewModelStoreOwner = viewModelStoreOwner, key = key, extras = extras, scope = scope, parameters = parameters - ).also { registerMap[T::class.java] = WeakReference(actualViewModelStoreOwner) } + ) } -@Deprecated(message = "弃用") +@OptIn(ExperimentalVoyagerApi::class, InternalVoyagerApi::class) @Composable -inline fun getViewModel( - qualifier: Qualifier? = null, - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(registerMap[T::class.java]?.get()) { - "No Registered ViewModelStoreOwner was provided via registerMap for ${T::class.java}" - }, - key: String? = null, - extras: CreationExtras = defaultExtras(viewModelStoreOwner), - scope: Scope = currentKoinScope(), - noinline parameters: ParametersDefinition? = null, -): T { - return koinViewModel( - qualifier = qualifier, - viewModelStoreOwner = viewModelStoreOwner, - key = key, - extras = extras, - scope = scope, - parameters = parameters - ) +fun Screen.getScreenViewModelStoreOwner(): ViewModelStoreOwner? { + val provider = LocalNavigatorScreenLifecycleProvider.current + + return remember { + provider?.provide(this)?.get(0) + ?.let { it as? ScreenLifecycleKMPOwner } + ?.owner + } } \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt index 205d718bf..22c276485 100644 --- a/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -16,6 +16,7 @@ sealed interface NavIntent { data class Jump(val screen: Screen) : NavIntent data class Push(val screen: Screen) : NavIntent data class Replace(val screen: Screen) : NavIntent + data class PopUtil(val screen: Screen) : NavIntent data object Pop : NavIntent data object None : NavIntent } @@ -59,6 +60,7 @@ val DefaultHandler = NavHandler { navigator, intent -> is NavIntent.Push -> navigator.push(intent.screen) is NavIntent.Replace -> navigator.replace(intent.screen) is NavIntent.Jump -> navigator.push(intent.screen) + is NavIntent.PopUtil -> navigator.popUntil { intent.screen == it } NavIntent.None -> {} } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7d86ee2ac..049130dde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,7 @@ compose-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie-compose" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } +voyager-lifecycle-kmp = { module = "cafe.adriel.voyager:voyager-lifecycle-kmp", version.ref = "voyager" } voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } @@ -145,6 +146,7 @@ compose = [ ] voyager = [ "voyager-navigator", + "voyager-lifecycle-kmp", "voyager-tabNavigator", "voyager-transitions", "voyager-koin" diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt index fa0bc9250..3f2495f79 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt @@ -19,8 +19,7 @@ import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper -import com.lalilu.component.extension.getViewModel -import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.component.extension.screenVM import com.lalilu.lalbum.R import com.lalilu.lalbum.viewModel.AlbumDetailAction import com.lalilu.lalbum.viewModel.AlbumDetailVM @@ -52,7 +51,7 @@ data class AlbumDetailScreen( @Composable override fun provideScreenActions(): List { - val vm = getViewModel() + val vm = screenVM() val state by vm.state return remember { @@ -98,7 +97,7 @@ data class AlbumDetailScreen( @Composable override fun Content() { - val vm = registerAndGetViewModel(parameters = { parametersOf(albumId) }) + val vm = screenVM(parameters = { parametersOf(albumId) }) val songs by vm.songs val state by vm.state val album by vm.album diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index 5f5c23ca7..493ed8786 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -15,8 +15,7 @@ import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper -import com.lalilu.component.extension.getViewModel -import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.component.extension.screenVM import com.lalilu.lalbum.R import com.lalilu.lalbum.viewModel.AlbumsAction import com.lalilu.lalbum.viewModel.AlbumsVM @@ -44,7 +43,7 @@ data class AlbumsScreen( @Composable override fun provideScreenActions(): List { - val albumsVM = getViewModel() + val albumsVM = screenVM() val state by albumsVM.state return remember { @@ -83,7 +82,7 @@ data class AlbumsScreen( @Composable override fun Content() { - val vm = registerAndGetViewModel() + val vm = screenVM() val state by vm.state val albums by vm.albums diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt index a1b5d4af4..db4b60090 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt @@ -20,8 +20,7 @@ import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper -import com.lalilu.component.extension.getViewModel -import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.component.extension.screenVM import com.lalilu.lartist.R import com.lalilu.lartist.viewModel.ArtistDetailAction import com.lalilu.lartist.viewModel.ArtistDetailVM @@ -54,7 +53,7 @@ data class ArtistDetailScreen( @Composable override fun provideScreenActions(): List { - val vm = getViewModel() + val vm = screenVM() val state by vm.state return remember { @@ -100,7 +99,7 @@ data class ArtistDetailScreen( @Composable override fun Content() { - val vm = registerAndGetViewModel(parameters = { parametersOf(artistName) }) + val vm = screenVM(parameters = { parametersOf(artistName) }) val songs by vm.songs val state by vm.state val artist by vm.artist diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt index 4786bf533..b49b3ec89 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt @@ -18,8 +18,7 @@ import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper -import com.lalilu.component.extension.getViewModel -import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.component.extension.screenVM import com.lalilu.lartist.R import com.lalilu.lartist.viewModel.ArtistsAction import com.lalilu.lartist.viewModel.ArtistsVM @@ -50,7 +49,7 @@ object ArtistsScreen : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBar @Composable override fun provideScreenActions(): List { - val vm = getViewModel() + val vm = screenVM() val state by vm.state return remember { @@ -96,7 +95,7 @@ object ArtistsScreen : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBar @Composable override fun Content() { - val vm = registerAndGetViewModel() + val vm = screenVM() val state by vm.state val artists by vm.artists diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index 3b27a1135..48cddabda 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -27,7 +27,7 @@ import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel -import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.component.extension.screenVM import com.lalilu.component.navigation.AppRouter import com.lalilu.lplaylist.R import com.lalilu.lplaylist.viewmodel.PlaylistsAction @@ -53,7 +53,7 @@ data object PlaylistScreen : TabScreen, ScreenBarFactory { @Composable override fun Content() { - val vm = registerAndGetViewModel() + val vm = screenVM() val state by vm.state SongsSearcherPanel( diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt index 8064b7d48..a50129f82 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt @@ -25,11 +25,13 @@ import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.component.extension.screenVM import com.lalilu.lplaylist.viewmodel.PlaylistEditAction import com.lalilu.lplaylist.viewmodel.PlaylistEditVM import com.lalilu.remixicon.Design +import com.lalilu.remixicon.System import com.lalilu.remixicon.design.editBoxFill +import com.lalilu.remixicon.system.deleteBinLine import com.zhangke.krouter.annotation.Destination import org.koin.core.parameter.parametersOf @@ -53,12 +55,40 @@ data class PlaylistEditScreen( @Composable override fun provideScreenActions(): List { - val vm = registerAndGetViewModel( + val vm = screenVM( parameters = { parametersOf(playlistId) } ) return remember { listOfNotNull( + ScreenAction.Dynamic { + val color = Color(0xFFF5381D) + + LongClickableTextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = 20.dp), + colors = ButtonDefaults.textButtonColors( + backgroundColor = color.copy(alpha = 0.15f), + contentColor = color + ), + enableLongClickMask = true, + onLongClick = { vm.intent(PlaylistEditAction.Delete) }, + onClick = { ToastUtils.showShort("请长按此按钮以继续") }, + ) { + Image( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.System.deleteBinLine, + contentDescription = null, + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "删除歌单", + fontSize = 14.sp + ) + } + }, ScreenAction.Dynamic { val color = Color(0xFF0074FF) @@ -93,11 +123,13 @@ data class PlaylistEditScreen( @Composable override fun Content() { - val vm = registerAndGetViewModel( + val vm = screenVM( parameters = { parametersOf(playlistId) } ) PlaylistEditScreenContent( + titleHint = { vm.playlist.value?.title ?: "" }, + subTitleHint = { vm.playlist.value?.subTitle ?: "" }, titleValue = { vm.titleState.value }, onUpdateTitle = { vm.titleState.value = it }, subTitleValue = { vm.subTitleState.value }, diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt index be2f0b3b0..c1d4ca252 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -18,7 +18,7 @@ import com.lalilu.component.base.songs.SongsSearcherPanel import com.lalilu.component.base.songs.SongsSelectorPanel import com.lalilu.component.base.songs.SongsSortPanelDialog import com.lalilu.component.extension.DialogWrapper -import com.lalilu.component.extension.registerAndGetViewModel +import com.lalilu.component.extension.screenVM import com.lalilu.lmedia.extension.SortStaticAction import com.lalilu.lplaylist.R import com.lalilu.lplaylist.viewmodel.PlaylistDetailAction @@ -50,7 +50,7 @@ data class PlaylistDetailScreen( @Composable override fun provideScreenActions(): List { - val vm = registerAndGetViewModel( + val vm = screenVM( parameters = { parametersOf(playlistId) } ) @@ -99,7 +99,7 @@ data class PlaylistDetailScreen( @Composable override fun Content() { - val vm = registerAndGetViewModel( + val vm = screenVM( parameters = { parametersOf(playlistId) } ) diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt index fae2236de..893071a3b 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt @@ -1,20 +1,23 @@ package com.lalilu.lplaylist.screen.detail import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment @@ -22,7 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.gigamole.composefadingedges.FadingEdgesGravity @@ -30,10 +32,13 @@ import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig import com.gigamole.composefadingedges.fill.FadingEdgesFillType import com.gigamole.composefadingedges.verticalFadingEdges +import com.lalilu.RemixIcon +import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.smartBarPadding import com.lalilu.component.base.songs.SongsScreenStickyHeader import com.lalilu.component.card.SongCard import com.lalilu.component.extension.ItemRecorder +import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter import com.lalilu.lmedia.entity.LSong @@ -41,7 +46,10 @@ import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.extensions.PlayerAction import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.viewmodel.PlaylistDetailEvent +import com.lalilu.remixicon.Design +import com.lalilu.remixicon.design.editBoxFill import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.emptyFlow import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -65,6 +73,10 @@ internal fun PlaylistDetailScreenContent( val hapticFeedback = LocalHapticFeedback.current val listState: LazyListState = rememberLazyListState() val stickyHeaderContentType = remember { "group" } + val scroller = rememberLazyListAnimateScroller( + listState = listState, + keys = keys + ) val playlistState = remember(songs) { songs.values.flatten().toMutableStateList() @@ -84,6 +96,33 @@ internal fun PlaylistDetailScreenContent( } } + LaunchedEffect(Unit) { + eventFlow.collectLatest { event -> + when (event) { + is PlaylistDetailEvent.ScrollToItem -> { + scroller.animateTo( + key = event.key, + isStickyHeader = { it.contentType == stickyHeaderContentType }, + offset = { item -> + // 若是 sticky header,则滚动到顶部 + if (item.contentType == stickyHeaderContentType) { + return@animateTo -statusBar.getTop(density) + } + + val closestStickyHeaderSize = listState.layoutInfo.visibleItemsInfo + .lastOrNull { it.index < item.index && it.contentType == stickyHeaderContentType } + ?.size ?: 0 + + -(statusBar.getTop(density) + closestStickyHeaderSize) + } + ) + } + + else -> {} + } + } + } + LazyColumn( modifier = Modifier .fillMaxSize() @@ -108,26 +147,43 @@ internal fun PlaylistDetailScreenContent( ) { startRecord(recorder) { itemWithRecord(key = "HEADER") { - Column( + NavigatorHeader( modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .statusBarsPadding(), - verticalArrangement = Arrangement.spacedBy(8.dp) + .statusBarsPadding() + .fillMaxWidth(), + rowExtraSpace = 8.dp, + paddingValues = PaddingValues( + top = 26.dp, + bottom = 20.dp, + start = 20.dp, + end = 12.dp + ), + title = playlist?.title ?: "unknown", + columnExtraContent = { + Text( + text = "${playlist?.subTitle}", + fontSize = 14.sp, + color = contentColorFor(backgroundColor = MaterialTheme.colors.background) + .copy(alpha = 0.5f) + ) +// Text( +// text = "共 ${playlist?.mediaIds?.size ?: 0} 首歌曲", +// fontSize = 14.sp, +// color = contentColorFor(backgroundColor = MaterialTheme.colors.background) +// .copy(alpha = 0.5f) +// ) + } ) { - Text( - text = playlist?.title ?: "Unknown", - fontSize = 20.sp, - lineHeight = 20.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.onBackground - ) - Text( - text = "共 ${playlist?.mediaIds?.size ?: 0} 首歌曲", - color = MaterialTheme.colors.onBackground.copy(0.6f), - fontSize = 12.sp, - lineHeight = 12.sp, - ) + IconButton(onClick = { + AppRouter.route("/pages/playlist/edit") + .with("playlistId", playlist?.id ?: "") + .push() + }) { + Icon( + imageVector = RemixIcon.Design.editBoxFill, + contentDescription = null + ) + } } } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt index c7e21bdde..8563302ca 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt @@ -2,6 +2,7 @@ package com.lalilu.lplaylist.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ToastUtils import com.lalilu.common.MviWithIntent import com.lalilu.common.mviImplWithIntent @@ -11,6 +12,7 @@ import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository +import com.zhangke.krouter.KRouter import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -34,6 +36,7 @@ data class PlaylistEditState( sealed interface PlaylistEditAction { data object Confirm : PlaylistEditAction + data object Delete : PlaylistEditAction } sealed interface PlaylistEditEvent { @@ -42,8 +45,8 @@ sealed interface PlaylistEditEvent { @OptIn(ExperimentalUuidApi::class, ExperimentalCoroutinesApi::class) @KoinViewModel -class PlaylistEditVM( - playlistId: String? = null, +data class PlaylistEditVM( + val playlistId: String?, private val actualId: String = playlistId ?: Uuid.random().toHexString(), private val playlistRepo: PlaylistRepository ) : ViewModel(), @@ -89,6 +92,13 @@ class PlaylistEditVM( AppRouter.intent(NavIntent.Pop) } + is PlaylistEditAction.Delete -> { + playlistRepo.removeById(actualId) + + KRouter.route("/pages/playlist") + ?.let { AppRouter.intent(NavIntent.PopUtil(it)) } + } + else -> {} } } From 6c848477e337165a97ed43b43d4896a2c41d2dc0 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 3 Dec 2024 18:29:56 +0800 Subject: [PATCH 129/213] =?UTF-8?q?[refactor]=E6=8F=90=E9=AB=98=E6=89=93?= =?UTF-8?q?=E5=8C=85=E7=BC=96=E8=AF=91=E5=86=85=E5=AD=98=E5=8D=A0=E7=94=A8?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 98bed167d..1462a5544 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects From beb61368730460fc6d38250a81309f37777d6ede Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 3 Dec 2024 18:50:11 +0800 Subject: [PATCH 130/213] =?UTF-8?q?[refactor]=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base/screen/ScreenActionFactory.kt | 1 + .../base/songs/SongsSelectorPanel.kt | 2 +- .../component/navigation/NavigateCommonBar.kt | 444 ------------------ .../navigation/NavigationSmartBar.kt | 4 +- .../smartbar/MoreActionPanelDialog.kt | 90 ++++ .../component/smartbar/NavigateCommonBar.kt | 182 +++++++ .../NavigateTabBar.kt | 2 +- .../smartbar/component/ActionItem.kt | 134 ++++++ .../smartbar/component/MoreActionBtn.kt | 101 ++++ .../screen/detail/PlaylistDetailScreen.kt | 48 ++ .../lplaylist/viewmodel/PlaylistDetailVM.kt | 1 + 11 files changed, 562 insertions(+), 447 deletions(-) delete mode 100644 component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt create mode 100644 component/src/main/java/com/lalilu/component/smartbar/MoreActionPanelDialog.kt create mode 100644 component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt rename component/src/main/java/com/lalilu/component/{navigation => smartbar}/NavigateTabBar.kt (99%) create mode 100644 component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt create mode 100644 component/src/main/java/com/lalilu/component/smartbar/component/MoreActionBtn.kt diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt index 0ec411669..d340e8b76 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt @@ -20,6 +20,7 @@ sealed class ScreenAction { val color: @Composable () -> Color = { Color.White }, val icon: @Composable () -> ImageVector? = { null }, val dotColor: @Composable () -> Color? = { null }, + val longClick: () -> Boolean = { false }, val onAction: () -> Unit = {} ) : ScreenAction() diff --git a/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt index 51cdb2cfa..344cd5f3b 100644 --- a/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt @@ -8,7 +8,7 @@ import com.lalilu.RemixIcon import com.lalilu.component.base.screen.ActionContext import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenBarFactory -import com.lalilu.component.navigation.NavigateCommonBarContent +import com.lalilu.component.smartbar.NavigateCommonBarContent import com.lalilu.remixicon.System import com.lalilu.remixicon.system.closeLine diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt deleted file mode 100644 index 9dfdac2e0..000000000 --- a/component/src/main/java/com/lalilu/component/navigation/NavigateCommonBar.kt +++ /dev/null @@ -1,444 +0,0 @@ -package com.lalilu.component.navigation - -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.StartOffset -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.Placeable -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.fastForEachReversed -import cafe.adriel.voyager.core.screen.Screen -import com.lalilu.RemixIcon -import com.lalilu.component.base.screen.ActionContext -import com.lalilu.component.base.screen.ScreenAction -import com.lalilu.component.base.screen.ScreenActionFactory -import com.lalilu.component.extension.DialogItem -import com.lalilu.component.extension.DialogWrapper -import com.lalilu.remixicon.Arrows -import com.lalilu.remixicon.System -import com.lalilu.remixicon.arrows.arrowLeftSLine -import com.lalilu.remixicon.system.moreLine -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlin.random.Random -import kotlin.random.nextInt - - -@Composable -fun NavigateCommonBar( - modifier: Modifier = Modifier, - previousTitle: String, - currentScreen: Screen? -) { - val screenActions = (currentScreen as? ScreenActionFactory)?.provideScreenActions() - val actionContext = ActionContext(isFullyExpanded = false) - val isDialogVisible = remember { mutableStateOf(false) } - - NavigateCommonBarContent( - modifier = modifier, - previousTitle = previousTitle, - dialogVisible = isDialogVisible, - screenActions = screenActions, - actionContext = actionContext - ) -} - -@Composable -fun NavigateCommonBarContent( - modifier: Modifier = Modifier, - previousTitle: String, - previousIcon: ImageVector = RemixIcon.Arrows.arrowLeftSLine, - dialogVisible: MutableState, - screenActions: List?, - actionContext: ActionContext, - onBackPress: (() -> Unit)? = null -) { - val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current - ?.onBackPressedDispatcher - - MoreActionPanelDialog( - isVisible = dialogVisible, - actions = screenActions ?: emptyList() - ) - - Row( - modifier = modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(start = 12.dp, end = 20.dp), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colors.onBackground - ), - onClick = { - if (onBackPress != null) { - onBackPress() - } else { - onBackPressedDispatcher?.onBackPressed() - } - } - ) { - Icon( - imageVector = previousIcon, - tint = MaterialTheme.colors.onBackground, - contentDescription = null - ) - AnimatedContent( - targetState = previousTitle, label = "" - ) { - Text( - text = it, - fontSize = 14.sp - ) - } - } - - AnimatedContent( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - transitionSpec = { fadeIn() togetherWith fadeOut() }, - targetState = screenActions, - label = "ExtraActions" - ) { actions -> - SubcomposeLayout( - modifier = Modifier.fillMaxSize() - ) { constraints -> - // 若actions为空,则不显示 - if (actions == null) return@SubcomposeLayout layout(0, 0) {} - - val moreBtnMeasurable = subcompose("moreBtn") { - val colors = screenActions?.filterIsInstance() - ?.mapNotNull { it.dotColor() } - ?: emptyList() - - MoreActionBtn( - dotColors = colors, - onClick = { dialogVisible.value = true }, - ) - }[0] - val moreBtnPlaceable = moreBtnMeasurable.measure( - constraints.copy( - maxWidth = moreBtnMeasurable.maxIntrinsicWidth(constraints.maxWidth), - minWidth = 0 - ) - ) - - var widthSum = 0f - val targets = mutableListOf() - for (action in actions) { - val measurable = subcompose(action) { - ActionItem( - action = action, - actionContext = actionContext - ) - }[0] - val placeable = measurable.measure( - constraints.copy( - maxWidth = measurable.maxIntrinsicWidth(constraints.maxWidth), - minWidth = 0 - ) - ) - - // 若宽度超出,则显示下拉菜单按钮 - if (placeable.width + moreBtnPlaceable.width + widthSum > constraints.maxWidth) { - targets.add(moreBtnPlaceable) - break - } - - targets.add(placeable) - widthSum += placeable.width - } - - layout(width = constraints.maxWidth, height = constraints.maxHeight) { - var startX = constraints.maxWidth - - targets.fastForEachReversed { - it.place(x = startX - it.width, y = 0) - startX -= it.width - } - } - } - } - } -} - -@Composable -private fun MoreActionBtn( - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colors.onBackground, - dotColors: List = emptyList(), - onClick: () -> Unit = {} -) { - TextButton( - modifier = modifier.fillMaxHeight(), - shape = RectangleShape, - colors = ButtonDefaults.textButtonColors( - backgroundColor = color.copy(alpha = 0.15f), - contentColor = color - ), - onClick = onClick - ) { - val showingColor = remember { mutableStateOf(null) } - - LaunchedEffect(showingColor.value) { - if (showingColor.value == null) { - showingColor.value = dotColors.firstOrNull() - return@LaunchedEffect - } - - delay(3000) - if (!isActive) return@LaunchedEffect - - val currentIndex = dotColors.indexOf(showingColor.value) - val nextIndex = (currentIndex + 1) % dotColors.size - showingColor.value = dotColors.getOrNull(nextIndex) - } - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - modifier = Modifier.size(20.dp), - imageVector = RemixIcon.System.moreLine, - contentDescription = null, - tint = color - ) - - showingColor.value?.let { dotColor -> - AnimatedContent( - modifier = Modifier.align(Alignment.TopStart), - transitionSpec = { - fadeIn(spring(stiffness = Spring.StiffnessLow)) togetherWith - fadeOut(spring(stiffness = Spring.StiffnessLow)) - }, - targetState = dotColor, - label = "" - ) { - Spacer( - modifier = Modifier - .clip(CircleShape) - .background(color = it) - .size(8.dp) - ) - } - } - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun ActionItem( - modifier: Modifier = Modifier, - actionContext: ActionContext, - action: ScreenAction -) { - when (action) { - is ScreenAction.Dynamic -> { - action.content(actionContext) - } - - is ScreenAction.Static -> { - val color = action.color() - val title = action.title() - val subTitle = action.subTitle() - val icon = action.icon() - val dotColor = action.dotColor() - - Surface( - modifier = modifier, - color = color.copy(0.2f), - onClick = { action.onAction() } - ) { - Box( - modifier = Modifier, - contentAlignment = Alignment.CenterStart - ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - icon?.let { - Image( - modifier = Modifier.size(20.dp), - imageVector = icon, - contentDescription = title, - colorFilter = ColorFilter.tint(color = color) - ) - Spacer(modifier = Modifier.width(6.dp)) - } - - Column( - modifier = Modifier, - verticalArrangement = Arrangement - .spacedBy(2.dp, Alignment.CenterVertically) - ) { - Text( - text = title, - fontSize = 14.sp, - lineHeight = 14.sp, - color = color, - fontWeight = FontWeight.Medium - ) - - if (actionContext.isFullyExpanded && subTitle != null) { - Text( - text = subTitle, - fontSize = 10.sp, - lineHeight = 10.sp, - color = color.copy(0.5f), - ) - } - } - } - - if (dotColor != null) { - val animation = rememberInfiniteTransition(label = "") - val scaleValue = animation.animateFloat( - initialValue = 0.1f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000), - repeatMode = RepeatMode.Reverse, - initialStartOffset = StartOffset( - offsetMillis = remember { Random.nextInt(0..1000) } - ) - ), - label = "" - ) - - Spacer( - modifier = Modifier - .graphicsLayer { alpha = scaleValue.value } - .padding(8.dp) - .align(Alignment.TopStart) - .clip(CircleShape) - .background(color = dotColor) - .size(8.dp) - ) - } - } - } - } - } -} - -@Composable -private fun MoreActionPanelDialog( - isVisible: MutableState, - actions: List, -) { - val actualActions = rememberUpdatedState(newValue = actions) - - val dialog = remember { - DialogItem.Dynamic(backgroundColor = Color.Transparent) { - MoreActionPanelDialogContent( - actions = actualActions.value, - onDismiss = { dismiss() } - ) - } - } - - DialogWrapper.register( - isVisible = { isVisible.value }, - onDismiss = { isVisible.value = false }, - dialogItem = dialog - ) -} - -@Composable -private fun MoreActionPanelDialogContent( - modifier: Modifier = Modifier, - actions: List, - onDismiss: () -> Unit -) { - Surface( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - .navigationBarsPadding(), - border = BorderStroke(1.dp, MaterialTheme.colors.onBackground.copy(0.1f)), - shape = RoundedCornerShape(18.dp), - elevation = 10.dp - ) { - Column( - modifier = Modifier - .padding(16.dp) - .clip(RoundedCornerShape(12.dp)), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - actions.forEach { action -> - Surface( - modifier = Modifier - .fillMaxWidth() - .height(64.dp), - ) { - ActionItem( - action = action, - actionContext = ActionContext(isFullyExpanded = true) - ) - } - } - } - } -} diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt index 301568fc0..c5ae813fd 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -25,9 +25,11 @@ import com.lalilu.component.base.ScreenBarComponent import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.smartbar.NavigateCommonBar +import com.lalilu.component.smartbar.NavigateTabBar -sealed interface NavigationBarType { +private sealed interface NavigationBarType { data object TabBar : NavigationBarType data object CommonBar : NavigationBarType data class NormalBar(val barComponent: ScreenBarComponent) : NavigationBarType diff --git a/component/src/main/java/com/lalilu/component/smartbar/MoreActionPanelDialog.kt b/component/src/main/java/com/lalilu/component/smartbar/MoreActionPanelDialog.kt new file mode 100644 index 000000000..37f5bbd1e --- /dev/null +++ b/component/src/main/java/com/lalilu/component/smartbar/MoreActionPanelDialog.kt @@ -0,0 +1,90 @@ +package com.lalilu.component.smartbar + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.lalilu.component.base.screen.ActionContext +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.extension.DialogItem +import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.smartbar.component.ActionItem +import kotlin.collections.forEach + + +@Composable +internal fun MoreActionPanelDialog( + isVisible: MutableState, + actions: List, +) { + val actualActions = rememberUpdatedState(newValue = actions) + + val dialog = remember { + DialogItem.Dynamic(backgroundColor = Color.Transparent) { + MoreActionPanelDialogContent( + actions = actualActions.value, + onDismiss = { dismiss() } + ) + } + } + + DialogWrapper.register( + isVisible = { isVisible.value }, + onDismiss = { isVisible.value = false }, + dialogItem = dialog + ) +} + +@Composable +private fun MoreActionPanelDialogContent( + modifier: Modifier = Modifier, + actions: List, + onDismiss: () -> Unit +) { + Surface( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .navigationBarsPadding(), + border = BorderStroke(1.dp, MaterialTheme.colors.onBackground.copy(0.1f)), + shape = RoundedCornerShape(18.dp), + elevation = 10.dp + ) { + Column( + modifier = Modifier + .padding(16.dp) + .clip(RoundedCornerShape(12.dp)), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + actions.forEach { action -> + Surface( + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + ) { + ActionItem( + action = action, + actionContext = ActionContext(isFullyExpanded = true) + ) + } + } + } + } +} diff --git a/component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt b/component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt new file mode 100644 index 000000000..afca9c0e0 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt @@ -0,0 +1,182 @@ +package com.lalilu.component.smartbar + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachReversed +import cafe.adriel.voyager.core.screen.Screen +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ActionContext +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.smartbar.component.ActionItem +import com.lalilu.component.smartbar.component.MoreActionBtn +import com.lalilu.remixicon.Arrows +import com.lalilu.remixicon.arrows.arrowLeftSLine + + +@Composable +fun NavigateCommonBar( + modifier: Modifier = Modifier, + previousTitle: String, + currentScreen: Screen? +) { + val screenActions = (currentScreen as? ScreenActionFactory)?.provideScreenActions() + val actionContext = ActionContext(isFullyExpanded = false) + val isDialogVisible = remember { mutableStateOf(false) } + + NavigateCommonBarContent( + modifier = modifier, + previousTitle = previousTitle, + dialogVisible = isDialogVisible, + screenActions = screenActions, + actionContext = actionContext + ) +} + +@Composable +fun NavigateCommonBarContent( + modifier: Modifier = Modifier, + previousTitle: String, + previousIcon: ImageVector = RemixIcon.Arrows.arrowLeftSLine, + dialogVisible: MutableState, + screenActions: List?, + actionContext: ActionContext, + onBackPress: (() -> Unit)? = null +) { + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current + ?.onBackPressedDispatcher + + MoreActionPanelDialog( + isVisible = dialogVisible, + actions = screenActions ?: emptyList() + ) + + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(start = 12.dp, end = 20.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colors.onBackground + ), + onClick = { + if (onBackPress != null) { + onBackPress() + } else { + onBackPressedDispatcher?.onBackPressed() + } + } + ) { + Icon( + imageVector = previousIcon, + tint = MaterialTheme.colors.onBackground, + contentDescription = null + ) + AnimatedContent( + targetState = previousTitle, label = "" + ) { + Text( + text = it, + fontSize = 14.sp + ) + } + } + + AnimatedContent( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + transitionSpec = { fadeIn() togetherWith fadeOut() }, + targetState = screenActions, + label = "ExtraActions" + ) { actions -> + SubcomposeLayout( + modifier = Modifier.fillMaxSize() + ) { constraints -> + // 若actions为空,则不显示 + if (actions == null) return@SubcomposeLayout layout(0, 0) {} + + val moreBtnMeasurable = subcompose("moreBtn") { + val colors = screenActions?.filterIsInstance() + ?.mapNotNull { it.dotColor() } + ?: emptyList() + + MoreActionBtn( + dotColors = colors, + onClick = { dialogVisible.value = true }, + ) + }[0] + val moreBtnPlaceable = moreBtnMeasurable.measure( + constraints.copy( + maxWidth = moreBtnMeasurable.maxIntrinsicWidth(constraints.maxWidth), + minWidth = 0 + ) + ) + + var widthSum = 0f + val targets = mutableListOf() + for (action in actions) { + val measurable = subcompose(action) { + ActionItem( + action = action, + actionContext = actionContext + ) + }[0] + val placeable = measurable.measure( + constraints.copy( + maxWidth = measurable.maxIntrinsicWidth(constraints.maxWidth), + minWidth = 0 + ) + ) + + // 若宽度超出,则显示下拉菜单按钮 + if (placeable.width + moreBtnPlaceable.width + widthSum > constraints.maxWidth) { + targets.add(moreBtnPlaceable) + break + } + + targets.add(placeable) + widthSum += placeable.width + } + + layout(width = constraints.maxWidth, height = constraints.maxHeight) { + var startX = constraints.maxWidth + + targets.fastForEachReversed { + it.place(x = startX - it.width, y = 0) + startX -= it.width + } + } + } + } + } +} diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt b/component/src/main/java/com/lalilu/component/smartbar/NavigateTabBar.kt similarity index 99% rename from component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt rename to component/src/main/java/com/lalilu/component/smartbar/NavigateTabBar.kt index ee8a89c87..e03516562 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigateTabBar.kt +++ b/component/src/main/java/com/lalilu/component/smartbar/NavigateTabBar.kt @@ -1,4 +1,4 @@ -package com.lalilu.component.navigation +package com.lalilu.component.smartbar import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState diff --git a/component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt b/component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt new file mode 100644 index 000000000..0256d4cb5 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt @@ -0,0 +1,134 @@ +package com.lalilu.component.smartbar.component + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.base.screen.ActionContext +import com.lalilu.component.base.screen.ScreenAction +import kotlin.random.Random +import kotlin.random.nextInt + + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun ActionItem( + modifier: Modifier = Modifier, + actionContext: ActionContext, + action: ScreenAction +) { + when (action) { + is ScreenAction.Dynamic -> { + action.content(actionContext) + } + + is ScreenAction.Static -> { + val color = action.color() + val title = action.title() + val subTitle = action.subTitle() + val icon = action.icon() + val dotColor = action.dotColor() + + Surface( + modifier = modifier, + color = color.copy(0.2f), + onClick = { action.onAction() } + ) { + Box( + modifier = Modifier, + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon?.let { + Image( + modifier = Modifier.size(20.dp), + imageVector = icon, + contentDescription = title, + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + Column( + modifier = Modifier, + verticalArrangement = Arrangement + .spacedBy(2.dp, Alignment.CenterVertically) + ) { + Text( + text = title, + fontSize = 14.sp, + lineHeight = 14.sp, + color = color, + fontWeight = FontWeight.Medium + ) + + if (actionContext.isFullyExpanded && subTitle != null) { + Text( + text = subTitle, + fontSize = 10.sp, + lineHeight = 10.sp, + color = color.copy(0.5f), + ) + } + } + } + + if (dotColor != null) { + val animation = rememberInfiniteTransition(label = "") + val scaleValue = animation.animateFloat( + initialValue = 0.1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset( + offsetMillis = remember { Random.nextInt(0..1000) } + ) + ), + label = "" + ) + + Spacer( + modifier = Modifier + .graphicsLayer { alpha = scaleValue.value } + .padding(8.dp) + .align(Alignment.TopStart) + .clip(CircleShape) + .background(color = dotColor) + .size(8.dp) + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/smartbar/component/MoreActionBtn.kt b/component/src/main/java/com/lalilu/component/smartbar/component/MoreActionBtn.kt new file mode 100644 index 000000000..c5b4ac7f1 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/smartbar/component/MoreActionBtn.kt @@ -0,0 +1,101 @@ +package com.lalilu.component.smartbar.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import com.lalilu.RemixIcon +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.moreLine +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.collections.indexOf + + +@Composable +internal fun MoreActionBtn( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colors.onBackground, + dotColors: List = emptyList(), + onClick: () -> Unit = {} +) { + TextButton( + modifier = modifier.fillMaxHeight(), + shape = RectangleShape, + colors = ButtonDefaults.textButtonColors( + backgroundColor = color.copy(alpha = 0.15f), + contentColor = color + ), + onClick = onClick + ) { + val showingColor = remember { mutableStateOf(null) } + + LaunchedEffect(showingColor.value) { + if (showingColor.value == null) { + showingColor.value = dotColors.firstOrNull() + return@LaunchedEffect + } + + delay(3000) + if (!isActive) return@LaunchedEffect + + val currentIndex = dotColors.indexOf(showingColor.value) + val nextIndex = (currentIndex + 1) % dotColors.size + showingColor.value = dotColors.getOrNull(nextIndex) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.System.moreLine, + contentDescription = null, + tint = color + ) + + showingColor.value?.let { dotColor -> + AnimatedContent( + modifier = Modifier.align(Alignment.TopStart), + transitionSpec = { + fadeIn(spring(stiffness = Spring.StiffnessLow)) togetherWith + fadeOut(spring(stiffness = Spring.StiffnessLow)) + }, + targetState = dotColor, + label = "" + ) { + Spacer( + modifier = Modifier + .clip(CircleShape) + .background(color = it) + .size(8.dp) + ) + } + } + } + } +} diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt index c1d4ca252..38cf37718 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -1,13 +1,28 @@ package com.lalilu.lplaylist.screen.detail +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen +import com.blankj.utilcode.util.ToastUtils import com.lalilu.RemixIcon import com.lalilu.common.ext.requestFor +import com.lalilu.component.LongClickableTextButton import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenBarFactory @@ -31,6 +46,7 @@ import com.lalilu.remixicon.design.focus3Line import com.lalilu.remixicon.editor.sortDesc import com.lalilu.remixicon.system.checkboxMultipleBlankLine import com.lalilu.remixicon.system.checkboxMultipleLine +import com.lalilu.remixicon.system.deleteBinLine import com.lalilu.remixicon.system.menuSearchLine import com.zhangke.krouter.annotation.Destination import org.koin.core.parameter.parametersOf @@ -70,6 +86,38 @@ data class PlaylistDetailScreen( color = { Color(0xFF009673) }, onAction = { vm.selector.isSelecting.value = true } ), + ScreenAction.Dynamic { + val color = Color(0xFFF5381D) + + LongClickableTextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = 20.dp), + colors = ButtonDefaults.textButtonColors( + backgroundColor = color.copy(alpha = 0.15f), + contentColor = color + ), + enableLongClickMask = true, + onLongClick = { + val ids = vm.selector.selected().map { it.id } + + vm.intent(PlaylistDetailAction.RemoveItems(ids)) + }, + onClick = { ToastUtils.showShort("请长按此按钮以继续") }, + ) { + Image( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.System.deleteBinLine, + contentDescription = null, + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "删除歌单", + fontSize = 14.sp + ) + } + }, ScreenAction.Static( title = { "搜索" }, subTitle = { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt index 65b489e4f..c3e15576d 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt @@ -98,6 +98,7 @@ sealed interface PlaylistDetailAction { data class SearchFor(val keyword: String) : PlaylistDetailAction data class SelectSortAction(val action: ListAction) : PlaylistDetailAction data class UpdatePlaylist(val mediaIds: List) : PlaylistDetailAction + data class RemoveItems(val mediaIds: List) : PlaylistDetailAction } @OptIn(ExperimentalCoroutinesApi::class) From e2f06d6ed3f8296fd2616c794ba85c0cb958de09 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Tue, 3 Dec 2024 20:24:47 +0800 Subject: [PATCH 131/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E5=B0=81?= =?UTF-8?q?=E8=A3=85=E9=95=BF=E6=8C=89=E7=B1=BB=E5=9E=8B=E7=9A=84=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=8C=89=E9=92=AE=E9=80=BB=E8=BE=91=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84Playlist=E7=9B=B8=E5=85=B3=E9=A1=B5=E9=9D=A2=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lmusic/extension/SleepTimer.kt | 1 - .../component/LongClickableTextButton.kt | 52 ++- .../component/extension/ComposeModifierExt.kt | 7 +- .../smartbar/component/ActionItem.kt | 329 ++++++++++++++---- .../lalilu/lplaylist/screen/PlaylistScreen.kt | 1 - .../screen/create/PlaylistEditScreen.kt | 88 +---- .../screen/detail/PlaylistDetailScreen.kt | 57 +-- .../lplaylist/viewmodel/PlaylistDetailVM.kt | 7 + 8 files changed, 328 insertions(+), 214 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt index cc0bedb33..614127c5d 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt @@ -243,7 +243,6 @@ fun SleepTimer( .fillMaxWidth() .heightIn(min = 60.dp), shape = RoundedCornerShape(8.dp), - enableLongClickMask = true, colors = ButtonDefaults.textButtonColors( backgroundColor = animateColor.value.copy(alpha = 0.15f), contentColor = animateColor.value diff --git a/component/src/main/java/com/lalilu/component/LongClickableTextButton.kt b/component/src/main/java/com/lalilu/component/LongClickableTextButton.kt index 403db2111..70c2a828d 100644 --- a/component/src/main/java/com/lalilu/component/LongClickableTextButton.kt +++ b/component/src/main/java/com/lalilu/component/LongClickableTextButton.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -19,6 +18,7 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme import androidx.compose.material.ProvideTextStyle import androidx.compose.material.Surface +import androidx.compose.material.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,11 +28,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import com.lalilu.component.extension.enableFor import com.lalilu.component.extension.longClickable @@ -40,13 +41,13 @@ import com.lalilu.component.extension.longClickable fun LongClickableTextButton( modifier: Modifier = Modifier, enabled: Boolean = true, - shape: Shape, - colors: ButtonColors, + shape: Shape = RectangleShape, + colors: ButtonColors = ButtonDefaults.textButtonColors(), border: BorderStroke? = null, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, - enableLongClickMask: Boolean = false, contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, content: @Composable RowScope.() -> Unit ) { @@ -57,6 +58,7 @@ fun LongClickableTextButton( modifier = modifier .clip(shape) .longClickable( + indication = ripple(color = Color.Transparent), onClick = { if (enabled) onClick() }, enableHaptic = false, onLongClick = {}, @@ -69,10 +71,10 @@ fun LongClickableTextButton( shape = shape, colors = colors, border = border, - enableDrawMask = enableLongClickMask, contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, onProgressFinished = { - val skip = it != 1f || !enableLongClickMask || !enabled + val skip = it != 1f || !enabled if (!skip) { isClicking = false onLongClick() @@ -84,22 +86,19 @@ fun LongClickableTextButton( } @Composable -fun ProgressTextButton( +private fun ProgressTextButton( modifier: Modifier = Modifier, enabled: Boolean = true, progress: () -> Float = { 1f }, shape: Shape = remember { RoundedCornerShape(8.dp) }, colors: ButtonColors = ButtonDefaults.textButtonColors(), border: BorderStroke? = null, - enableDrawMask: Boolean = false, contentPadding: PaddingValues = ButtonDefaults.ContentPadding, - maskAnimationSpec: AnimationSpec = remember { - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessVeryLow - ) - }, - onClick: (() -> Unit)? = null, + maskAnimationSpec: AnimationSpec = spring( + Spring.DampingRatioNoBouncy, + Spring.StiffnessVeryLow + ), + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center, onProgressFinished: ((Float) -> Unit)? = null, content: @Composable RowScope.() -> Unit ) { @@ -108,16 +107,13 @@ fun ProgressTextButton( val maskWidthProgress by animateFloatAsState( label = "Animate mask width progress", targetValue = progress(), + visibilityThreshold = 0.001f, animationSpec = maskAnimationSpec, finishedListener = onProgressFinished ) Surface( - modifier = modifier - .clip(shape) - .enableFor(enable = { onClick != null }) { - clickable(onClick = onClick!!) - }, + modifier = modifier, shape = shape, color = colors.backgroundColor(enabled).value, contentColor = contentColor.copy(alpha = 1f), @@ -127,20 +123,18 @@ fun ProgressTextButton( ProvideTextStyle(value = MaterialTheme.typography.button) { Row( Modifier - .enableFor(enable = { enableDrawMask }) { - drawBehind { - drawRect( - color = maskColor, - size = size.copy(width = size.width * maskWidthProgress) - ) - } + .drawBehind { + drawRect( + color = maskColor, + size = size.copy(width = size.width * maskWidthProgress) + ) } .defaultMinSize( minWidth = ButtonDefaults.MinWidth, minHeight = ButtonDefaults.MinHeight ) .padding(contentPadding), - horizontalArrangement = Arrangement.Center, + horizontalArrangement = horizontalArrangement, verticalAlignment = Alignment.CenterVertically, content = content ) diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt index 9aeffe5d0..cb3e88606 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt @@ -43,10 +43,7 @@ fun Modifier.longClickable( this .semantics { role = Role.Button } - .indication( - interactionSource = interactionSource, - indication = indication ?: LocalIndication.current - ) + .indication(interactionSource, indication ?: LocalIndication.current) .hoverable(interactionSource, true) .pointerInput(Unit) { var timer: Job? @@ -76,7 +73,7 @@ fun Modifier.longClickable( } // 取消计时器 - timer?.cancel() + timer.cancel() onRelease() }, onTap = { onClick() }, diff --git a/component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt b/component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt index 0256d4cb5..a8b74f7d0 100644 --- a/component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt +++ b/component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt @@ -1,37 +1,59 @@ package com.lalilu.component.smartbar.component +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.StartOffset import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.lalilu.RemixIcon +import com.lalilu.component.LongClickableTextButton import com.lalilu.component.base.screen.ActionContext import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.deleteBinFill +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlin.random.Random import kotlin.random.nextInt @@ -49,49 +71,111 @@ internal fun ActionItem( } is ScreenAction.Static -> { - val color = action.color() - val title = action.title() - val subTitle = action.subTitle() - val icon = action.icon() - val dotColor = action.dotColor() - - Surface( - modifier = modifier, - color = color.copy(0.2f), - onClick = { action.onAction() } + if (action.longClick()) { + LongClickActionItemContent( + modifier = modifier, + color = action.color(), + title = action.title(), + subTitle = action.subTitle(), + icon = action.icon(), + dotColor = action.dotColor(), + onAction = action.onAction + ) + } else { + ActionItemContent( + modifier = modifier, + color = action.color(), + title = action.title(), + subTitle = action.subTitle(), + icon = action.icon(), + dotColor = action.dotColor(), + onAction = action.onAction + ) + } + } + } +} + +@Composable +fun LongClickActionItemContent( + modifier: Modifier = Modifier, + color: Color, + title: String, + subTitle: String? = null, + icon: ImageVector? = null, + dotColor: Color? = null, + fullyExpended: Boolean = false, + onAction: () -> Unit = {} +) { + val tipsShow = remember { mutableLongStateOf(0L) } + + LaunchedEffect(tipsShow.longValue) { + delay(3000) + + if (!isActive) return@LaunchedEffect + tipsShow.longValue = 0L + } + + LongClickableTextButton( + modifier = modifier, + colors = ButtonDefaults.textButtonColors( + backgroundColor = color.copy(alpha = 0.15f), + contentColor = color + ), + horizontalArrangement = Arrangement.Start, + contentPadding = PaddingValues(0.dp), + onClick = { tipsShow.longValue = System.currentTimeMillis() }, + onLongClick = { + tipsShow.longValue = 0 + onAction() + } + ) { + Box( + modifier = Modifier, + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically ) { - Box( + icon?.let { + Image( + modifier = Modifier.size(20.dp), + imageVector = icon, + contentDescription = title, + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + Column( modifier = Modifier, - contentAlignment = Alignment.CenterStart + verticalArrangement = Arrangement.Center ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - icon?.let { - Image( - modifier = Modifier.size(20.dp), - imageVector = icon, - contentDescription = title, - colorFilter = ColorFilter.tint(color = color) - ) - Spacer(modifier = Modifier.width(6.dp)) - } + Text( + text = title, + fontSize = 14.sp, + lineHeight = 14.sp, + color = color, + fontWeight = FontWeight.Medium + ) - Column( - modifier = Modifier, - verticalArrangement = Arrangement - .spacedBy(2.dp, Alignment.CenterVertically) - ) { - Text( - text = title, - fontSize = 14.sp, - lineHeight = 14.sp, - color = color, - fontWeight = FontWeight.Medium - ) - - if (actionContext.isFullyExpanded && subTitle != null) { + if (fullyExpended && subTitle != null) { + AnimatedContent( + targetState = tipsShow.longValue > 0, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { show -> + if (show) { + Text( + modifier = Modifier.alpha(0.6f), + text = "长按以执行", + fontSize = 10.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Bold, + color = color + ) + } else { Text( text = subTitle, fontSize = 10.sp, @@ -100,35 +184,160 @@ internal fun ActionItem( ) } } + } else { + AnimatedVisibility(visible = tipsShow.longValue > 0) { + Text( + modifier = Modifier.alpha(0.6f), + text = subTitle ?: "长按以执行", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = color + ) + } } + } + } - if (dotColor != null) { - val animation = rememberInfiniteTransition(label = "") - val scaleValue = animation.animateFloat( - initialValue = 0.1f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000), - repeatMode = RepeatMode.Reverse, - initialStartOffset = StartOffset( - offsetMillis = remember { Random.nextInt(0..1000) } - ) - ), - label = "" + if (dotColor != null) { + val animation = rememberInfiniteTransition(label = "") + val scaleValue = animation.animateFloat( + initialValue = 0.1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset( + offsetMillis = remember { Random.nextInt(0..1000) } ) + ), + label = "" + ) + + Spacer( + modifier = Modifier + .graphicsLayer { alpha = scaleValue.value } + .padding(8.dp) + .align(Alignment.TopStart) + .clip(CircleShape) + .background(color = dotColor) + .size(8.dp) + ) + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ActionItemContent( + modifier: Modifier = Modifier, + color: Color, + title: String, + subTitle: String? = null, + icon: ImageVector? = null, + dotColor: Color? = null, + fullyExpended: Boolean = false, + onAction: () -> Unit = {} +) { + Surface( + modifier = modifier.clickable(onClick = onAction), + color = color.copy(0.2f), + ) { + Box( + modifier = Modifier, + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon?.let { + Image( + modifier = Modifier.size(20.dp), + imageVector = icon, + contentDescription = title, + colorFilter = ColorFilter.tint(color = color) + ) + Spacer(modifier = Modifier.width(6.dp)) + } - Spacer( - modifier = Modifier - .graphicsLayer { alpha = scaleValue.value } - .padding(8.dp) - .align(Alignment.TopStart) - .clip(CircleShape) - .background(color = dotColor) - .size(8.dp) + Column( + modifier = Modifier, + verticalArrangement = Arrangement + .spacedBy(2.dp, Alignment.CenterVertically) + ) { + Text( + text = title, + fontSize = 14.sp, + lineHeight = 14.sp, + color = color, + fontWeight = FontWeight.Medium + ) + + if (fullyExpended && subTitle != null) { + Text( + text = subTitle, + fontSize = 10.sp, + lineHeight = 10.sp, + color = color.copy(0.5f), ) } } } + + if (dotColor != null) { + val animation = rememberInfiniteTransition(label = "") + val scaleValue = animation.animateFloat( + initialValue = 0.1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset( + offsetMillis = remember { Random.nextInt(0..1000) } + ) + ), + label = "" + ) + + Spacer( + modifier = Modifier + .graphicsLayer { alpha = scaleValue.value } + .padding(8.dp) + .align(Alignment.TopStart) + .clip(CircleShape) + .background(color = dotColor) + .size(8.dp) + ) + } } } +} + +@Preview(showBackground = true) +@Composable +private fun ActionItemContentPreview() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ActionItemContent( + modifier = Modifier + .height(78.dp), + title = "删除歌单", + icon = RemixIcon.System.deleteBinFill, + color = Color.Red, + fullyExpended = false + ) + + ActionItemContent( + modifier = Modifier + .height(78.dp) + .fillMaxWidth(), + title = "删除歌单", + icon = RemixIcon.System.deleteBinFill, + color = Color.Red, + fullyExpended = false + ) + } } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index 48cddabda..c50f2e02b 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -78,7 +78,6 @@ data object PlaylistScreen : TabScreen, ScreenBarFactory { backgroundColor = color.copy(alpha = 0.15f), contentColor = color ), - enableLongClickMask = true, onLongClick = { vm.intent(PlaylistsAction.TryRemovePlaylist(vm.selector.selected())) }, onClick = { ToastUtils.showShort("请长按此按钮以继续") }, ) { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt index a50129f82..6f82ea5c9 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt @@ -1,26 +1,11 @@ package com.lalilu.lplaylist.screen.create -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey -import com.blankj.utilcode.util.ToastUtils import com.lalilu.RemixIcon -import com.lalilu.component.LongClickableTextButton import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenInfo @@ -61,62 +46,22 @@ data class PlaylistEditScreen( return remember { listOfNotNull( - ScreenAction.Dynamic { - val color = Color(0xFFF5381D) - - LongClickableTextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = 20.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = color.copy(alpha = 0.15f), - contentColor = color - ), - enableLongClickMask = true, - onLongClick = { vm.intent(PlaylistEditAction.Delete) }, - onClick = { ToastUtils.showShort("请长按此按钮以继续") }, - ) { - Image( - modifier = Modifier.size(20.dp), - imageVector = RemixIcon.System.deleteBinLine, - contentDescription = null, - colorFilter = ColorFilter.tint(color = color) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "删除歌单", - fontSize = 14.sp - ) - } - }, - ScreenAction.Dynamic { - val color = Color(0xFF0074FF) - - LongClickableTextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = 20.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = color.copy(alpha = 0.15f), - contentColor = color - ), - enableLongClickMask = true, - onLongClick = { vm.intent(PlaylistEditAction.Confirm) }, - onClick = { ToastUtils.showShort("请长按此按钮以继续") }, - ) { - Image( - modifier = Modifier.size(20.dp), - imageVector = RemixIcon.Design.editBoxFill, - contentDescription = null, - colorFilter = ColorFilter.tint(color = color) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = if (vm.playlist.value == null) "创建歌单" else "更新歌单", - fontSize = 14.sp - ) - } - } + if (vm.playlist.value != null) { + ScreenAction.Static( + title = { "删除歌单" }, + icon = { RemixIcon.System.deleteBinLine }, + longClick = { true }, + color = { Color(0xFFF5381D) }, + onAction = { vm.intent(PlaylistEditAction.Delete) } + ) + } else null, + ScreenAction.Static( + title = { if (vm.playlist.value == null) "创建歌单" else "更新歌单" }, + icon = { RemixIcon.Design.editBoxFill }, + longClick = { true }, + color = { Color(0xFF0074FF) }, + onAction = { vm.intent(PlaylistEditAction.Confirm) } + ), ) } } @@ -128,6 +73,7 @@ data class PlaylistEditScreen( ) PlaylistEditScreenContent( + isEditing = { vm.playlist.value != null }, titleHint = { vm.playlist.value?.title ?: "" }, subTitleHint = { vm.playlist.value?.subTitle ?: "" }, titleValue = { vm.titleState.value }, diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt index 38cf37718..26020b348 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -1,28 +1,13 @@ package com.lalilu.lplaylist.screen.detail -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen -import com.blankj.utilcode.util.ToastUtils import com.lalilu.RemixIcon import com.lalilu.common.ext.requestFor -import com.lalilu.component.LongClickableTextButton import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenBarFactory @@ -86,38 +71,6 @@ data class PlaylistDetailScreen( color = { Color(0xFF009673) }, onAction = { vm.selector.isSelecting.value = true } ), - ScreenAction.Dynamic { - val color = Color(0xFFF5381D) - - LongClickableTextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = 20.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = color.copy(alpha = 0.15f), - contentColor = color - ), - enableLongClickMask = true, - onLongClick = { - val ids = vm.selector.selected().map { it.id } - - vm.intent(PlaylistDetailAction.RemoveItems(ids)) - }, - onClick = { ToastUtils.showShort("请长按此按钮以继续") }, - ) { - Image( - modifier = Modifier.size(20.dp), - imageVector = RemixIcon.System.deleteBinLine, - contentDescription = null, - colorFilter = ColorFilter.tint(color = color) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "删除歌单", - fontSize = 14.sp - ) - } - }, ScreenAction.Static( title = { "搜索" }, subTitle = { @@ -193,6 +146,16 @@ data class PlaylistDetailScreen( color = { Color(0xFFFF5100) }, onAction = { vm.selector.clear() } ), + ScreenAction.Static( + title = { "删除" }, + icon = { RemixIcon.System.deleteBinLine }, + longClick = { true }, + color = { Color(0xFFF5381D) }, + onAction = { + val ids = vm.selector.selected().map { it.id } + vm.intent(PlaylistDetailAction.RemoveItems(ids)) + } + ), requestFor( qualifier = named("add_to_favourite_action"), parameters = { parametersOf(vm.selector::selected) } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt index c3e15576d..9b6415338 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt @@ -163,6 +163,13 @@ class PlaylistDetailVM( ?.let { playlistRepo.save(it) } } + is PlaylistDetailAction.RemoveItems -> { + playlistRepo.removeMediaIdsFromPlaylist( + mediaIds = intent.mediaIds, + playlistId = playlistId + ) + } + else -> { LogUtils.i("Not implemented action: $intent") } From 4571d226a71f122fd68350e423de40082d818534 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 9 Dec 2024 09:39:32 +0800 Subject: [PATCH 132/213] =?UTF-8?q?[refactor]=E5=8E=BB=E9=99=A4=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E4=BB=A3=E7=A0=81=EF=BC=8C=E4=BC=98=E5=8C=96=E6=AD=8C?= =?UTF-8?q?=E5=8D=95=E5=8D=A1=E7=89=87=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 53 ---------------- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 12 +--- .../lalilu/lmusic/datastore/LastPlayedSp.kt | 20 ------- .../lmusic/repository/CoverRepository.kt | 14 ----- .../com/lalilu/component/ComponentModule.kt | 9 --- .../component/viewmodel/SongsViewModel.kt | 15 ----- lmedia | 2 +- .../com/lalilu/lplayer/service/MService.kt | 21 +++---- .../lplaylist/component/PlaylistCard.kt | 60 +++++++++++-------- 9 files changed, 44 insertions(+), 162 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/datastore/LastPlayedSp.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/repository/CoverRepository.kt delete mode 100644 component/src/main/java/com/lalilu/component/ComponentModule.kt delete mode 100644 component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index af5c1a966..6d3dac8f0 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -10,17 +10,11 @@ import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.transitionFactory import com.lalilu.R -import com.lalilu.common.base.SourceType import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmedia.indexer.Filter -import com.lalilu.lmedia.indexer.FilterGroup import com.lalilu.lmusic.Config.LRCSHARE_BASEURL import com.lalilu.lmusic.api.lrcshare.LrcShareApi -import com.lalilu.lmusic.datastore.LastPlayedSp import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.datastore.TempSp -import com.lalilu.lmusic.repository.CoverRepository import com.lalilu.lmusic.utils.EQHelper import com.lalilu.lmusic.utils.coil.CrossfadeTransitionFactory import com.lalilu.lmusic.utils.coil.fetcher.LAlbumFetcher @@ -41,11 +35,9 @@ import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module import org.koin.core.annotation.Single -import org.koin.core.module.dsl.singleOf import org.koin.dsl.module import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import java.net.URLDecoder @Module @ComponentScan("com.lalilu.lmusic") @@ -76,7 +68,6 @@ fun provideImageLoaderFactory( val AppModule = module { single { androidApplication() as ViewModelStoreOwner } single { SettingsSp(androidApplication()) } - single { LastPlayedSp(androidApplication()) } single { TempSp(androidApplication()) } single { EQHelper(androidApplication()) } single { @@ -97,10 +88,6 @@ val ViewModelModule = module { viewModelOf(::SearchLyricViewModel) } -val RuntimeModule = module { - singleOf(::CoverRepository) -} - val ApiModule = module { single { GsonConverterFactory.create() } single { OkHttpClient.Builder().build() } @@ -112,44 +99,4 @@ val ApiModule = module { .build() .create(LrcShareApi::class.java) } -} - -val FilterModule = module { - single { - val settingSp: SettingsSp = get() - val unknownArtistFilter = Filter( - flow = settingSp.enableUnknownFilter.flow(true), - getter = { it.metadata.artist }, - targetClass = LSong::class.java, - ignoreRule = { flowValue, getterValue -> - flowValue == true && getterValue == "" - } - ) - val durationFilter = Filter( - flow = settingSp.durationFilter.flow(true), - getter = { it.metadata.duration }, - targetClass = LSong::class.java, - ignoreRule = { flowValue, getterValue -> - getterValue <= (flowValue ?: 15) - } - ) - val excludePathFilter = Filter( - flow = settingSp.excludePath.flow(true), - getter = { it }, - targetClass = LSong::class.java, - ignoreRule = { flowValue, getterValue -> - if (flowValue.isNullOrEmpty()) return@Filter false - // 排除目录功能只涉及 FileSystemScanner 和 MediaStoreScanner的 - if (getterValue.sourceType != SourceType.Local && getterValue.sourceType != SourceType.MediaStore) - return@Filter false - - val path = getterValue.fileInfo.directoryPath - flowValue.any { path.startsWith(URLDecoder.decode(it, "UTF-8")) } - } - ) - - FilterGroup.Builder() - .add(unknownArtistFilter, durationFilter, excludePathFilter) - .build() - } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index 67401d702..b36949d64 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -5,20 +5,16 @@ import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import coil3.SingletonImageLoader import com.blankj.utilcode.util.LogUtils -import com.lalilu.component.ComponentModule import com.lalilu.lalbum.AlbumModule import com.lalilu.lartist.ArtistModule import com.lalilu.ldictionary.DictionaryModule import com.lalilu.lhistory.HistoryModule import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.indexer.FilterGroup -import com.lalilu.lmedia.indexer.FilterProvider import com.lalilu.lmusic.utils.extension.ignoreSSLVerification import com.lalilu.lplayer.MPlayer import com.lalilu.lplaylist.PlaylistModule import com.zhangke.krouter.KRouter import com.zhangke.krouter.generated.KRouterInjectMap -import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.androix.startup.KoinStartup.onKoinStartup import org.koin.java.KoinJavaComponent @@ -27,11 +23,8 @@ import java.io.File @Suppress("OPT_IN_USAGE") -class LMusicApp : Application(), FilterProvider, ViewModelStoreOwner { +class LMusicApp : Application(), ViewModelStoreOwner { override val viewModelStore: ViewModelStore = ViewModelStore() - private val filterGroup: FilterGroup by inject() - - override fun newFilterGroup(): FilterGroup = filterGroup init { KRouter.init(KRouterInjectMap::getMap) @@ -43,9 +36,6 @@ class LMusicApp : Application(), FilterProvider, ViewModelStoreOwner { AppModule, ApiModule, ViewModelModule, - RuntimeModule, - FilterModule, - ComponentModule, HistoryModule.module, PlaylistModule.module, ArtistModule.module, diff --git a/app/src/main/java/com/lalilu/lmusic/datastore/LastPlayedSp.kt b/app/src/main/java/com/lalilu/lmusic/datastore/LastPlayedSp.kt deleted file mode 100644 index 9188c46c5..000000000 --- a/app/src/main/java/com/lalilu/lmusic/datastore/LastPlayedSp.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.lalilu.lmusic.datastore - -import android.app.Application -import android.content.Context -import android.content.SharedPreferences -import com.lalilu.common.base.BaseSp -import com.lalilu.lmusic.Config - -class LastPlayedSp(private val context: Context) : BaseSp() { - override fun obtainSourceSp(): SharedPreferences { - return context.getSharedPreferences( - context.packageName + "_LAST_PLAYED", - Application.MODE_PRIVATE - ) - } - - val lastPlayedIdKey = obtain(Config.LAST_PLAYED_ID) - val lastPlayedPositionKey = obtain(Config.LAST_PLAYED_POSITION) - val lastPlayedListIdsKey = obtainList(Config.LAST_PLAYED_LIST_IDS) -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/repository/CoverRepository.kt b/app/src/main/java/com/lalilu/lmusic/repository/CoverRepository.kt deleted file mode 100644 index d0b7a68ab..000000000 --- a/app/src/main/java/com/lalilu/lmusic/repository/CoverRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.lalilu.lmusic.repository - -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LSong -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf - -class CoverRepository { - fun fetch(id: Any?): Flow { - if (id == null || id !is String) return flowOf(id) - - return flowOf(LMedia.get(id) ?: id) - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/ComponentModule.kt b/component/src/main/java/com/lalilu/component/ComponentModule.kt deleted file mode 100644 index 74d93ec96..000000000 --- a/component/src/main/java/com/lalilu/component/ComponentModule.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.lalilu.component - -import com.lalilu.component.viewmodel.SongsSp -import org.koin.android.ext.koin.androidApplication -import org.koin.dsl.module - -val ComponentModule = module { - single { SongsSp(androidApplication()) } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt b/component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt deleted file mode 100644 index 31f6979d7..000000000 --- a/component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.lalilu.component.viewmodel - -import android.app.Application -import android.content.Context -import android.content.SharedPreferences -import com.lalilu.common.base.BaseSp - -class SongsSp(private val context: Context) : BaseSp() { - override fun obtainSourceSp(): SharedPreferences { - return context.getSharedPreferences( - context.packageName + "_SONGS", - Application.MODE_PRIVATE - ) - } -} diff --git a/lmedia b/lmedia index 5d1bd3405..4d9c6e7e4 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 5d1bd34051a6f67964ee67949e26242a3f7f2057 +Subproject commit 4d9c6e7e44444e88a0ec857bc12b11b16652860d diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index ff5488c38..a64f1af4e 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -101,14 +101,7 @@ class MService : MediaLibraryService(), CoroutineScope { } @OptIn(UnstableApi::class) -class MServiceCallback : MediaLibrarySession.Callback { - companion object { - const val ROOT = "root" - const val ALL_SONGS = "all_songs" - const val ALL_ARTISTS = "all_artists" - const val ALL_ALBUMS = "all_albums" - } - +private class MServiceCallback : MediaLibrarySession.Callback { private fun buildBrowsableItem(id: String, title: String): MediaItem { val metadata = MediaMetadata.Builder() .setTitle(title) @@ -131,7 +124,7 @@ class MServiceCallback : MediaLibrarySession.Callback { browser: MediaSession.ControllerInfo, params: LibraryParams? ): ListenableFuture> = Futures.immediateFuture( - LibraryResult.ofItem(buildBrowsableItem(ROOT, "LMedia Library"), params) + LibraryResult.ofItem(buildBrowsableItem(LMedia.ROOT, "LMedia Library"), params) ) override fun onGetChildren( @@ -142,13 +135,13 @@ class MServiceCallback : MediaLibrarySession.Callback { pageSize: Int, params: LibraryParams? ): ListenableFuture>> { - if (parentId == ROOT) { + if (parentId == LMedia.ROOT) { return Futures.immediateFuture( LibraryResult.ofItemList( listOf( - buildBrowsableItem(ALL_SONGS, "All Songs"), - buildBrowsableItem(ALL_ARTISTS, "All Artists"), - buildBrowsableItem(ALL_ALBUMS, "All Albums") + buildBrowsableItem(LMedia.ALL_SONGS, "All Songs"), + buildBrowsableItem(LMedia.ALL_ARTISTS, "All Artists"), + buildBrowsableItem(LMedia.ALL_ALBUMS, "All Albums") ), params ) @@ -225,7 +218,7 @@ internal fun getHistoryItems(): List { val history = MPlayerKV.historyPlaylistIds.get() return if (!history.isNullOrEmpty()) LMedia.mapItems(history) - else LMedia.getChildren(MServiceCallback.ALL_SONGS) + else LMedia.getChildren(LMedia.ALL_SONGS) } internal fun saveHistoryIds(mediaIds: List) { diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt index 6ce3f8f34..a5c182901 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt @@ -2,13 +2,14 @@ package com.lalilu.lplaylist.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon @@ -21,16 +22,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.lalilu.component.extension.dayNightTextColor +import androidx.compose.ui.unit.sp +import com.lalilu.RemixIcon import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.repository.PlaylistRepository -import com.lalilu.component.R as componentR +import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.HealthAndMedical +import com.lalilu.remixicon.editor.draggable +import com.lalilu.remixicon.healthandmedical.heart3Fill -@OptIn(ExperimentalFoundationApi::class) @Composable fun PlaylistCard( playlist: LPlaylist, @@ -44,15 +47,17 @@ fun PlaylistCard( ) { val bgColor by animateColorAsState( targetValue = when { - isDragging() -> dayNightTextColor(0.25f) - isSelected() -> dayNightTextColor(0.20f) - else -> dayNightTextColor(0.05f) + isDragging() -> MaterialTheme.colors.onBackground.copy(0.25f) + isSelected() -> MaterialTheme.colors.onBackground.copy(0.2f) + else -> MaterialTheme.colors.onBackground.copy(0.05f) }, label = "" ) Row( modifier = modifier + .fillMaxWidth() + .height(64.dp) .padding(horizontal = 16.dp) .clip(RoundedCornerShape(8.dp)) .background(bgColor) @@ -60,29 +65,32 @@ fun PlaylistCard( onClick = { onClick(playlist) }, onLongClick = { onLongClick(playlist) } ) - .padding(horizontal = 20.dp, vertical = 10.dp) - .fillMaxWidth(), + .padding(horizontal = 20.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier + .fillMaxHeight() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) ) { Text( text = playlist.title, - style = MaterialTheme.typography.subtitle1, - color = dayNightTextColor() + color = MaterialTheme.colors.onBackground, + fontSize = 14.sp, + lineHeight = 14.sp, + maxLines = 1, + fontWeight = FontWeight.Medium ) - AnimatedVisibility( - visible = playlist.subTitle.isNotBlank(), - label = "SubTitleVisibility" - ) { + if (playlist.subTitle.isNotBlank()) { Text( text = playlist.subTitle, - style = MaterialTheme.typography.body2, - color = dayNightTextColor(alpha = 0.8f) + color = MaterialTheme.colors.onBackground.copy(0.8f), + fontSize = 10.sp, + lineHeight = 16.sp, + maxLines = 1, ) } } @@ -92,7 +100,7 @@ fun PlaylistCard( modifier = Modifier .padding(horizontal = 16.dp) .scale(0.9f), - painter = painterResource(id = componentR.drawable.ic_heart_3_fill), + imageVector = RemixIcon.HealthAndMedical.heart3Fill, tint = Color(0xFFFE4141), contentDescription = "heart_icon" ) @@ -102,7 +110,7 @@ fun PlaylistCard( text = "${playlist.mediaIds.size}", style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold, - color = dayNightTextColor(alpha = 0.8f) + color = MaterialTheme.colors.onBackground.copy(alpha = 0.8f) ) AnimatedVisibility( @@ -112,14 +120,14 @@ fun PlaylistCard( Icon( modifier = draggingModifier .padding(start = 16.dp), - painter = painterResource(id = componentR.drawable.ic_draggable), + imageVector = RemixIcon.Editor.draggable, contentDescription = "DragHandle", ) } } } -@Preview +@Preview(showBackground = true) @Composable private fun PlaylistCardPreview() { val playlist = LPlaylist( @@ -148,7 +156,9 @@ private fun PlaylistCardListPreview() { ) Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { PlaylistCard( From 852f073efa20aa56e01f81f6bfb2bc11fa71c185 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 9 Dec 2024 15:19:58 +0800 Subject: [PATCH 133/213] =?UTF-8?q?[refactor]=E8=BD=AC=E7=A7=BBPlaylist?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=E8=87=B3=E5=AF=B9=E5=BA=94?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../new_screen/detail/SongDetailScreen.kt | 11 +- .../presenter/DetailScreenPresenter.kt | 46 -------- .../compose/screen/detail/SongLikeAction.kt | 96 ----------------- .../com/lalilu/lplaylist/PlaylistActions.kt | 100 +++++++++++++++++- 4 files changed, 107 insertions(+), 146 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongLikeAction.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt index d25ce2093..5875c4696 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import com.lalilu.R +import com.lalilu.common.ext.requestFor import com.lalilu.component.base.LocalEnhanceSheetState import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory @@ -25,11 +26,12 @@ import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.override.ModalBottomSheetValue import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.compose.screen.detail.provideSongLikeAction import com.lalilu.lmusic.compose.screen.detail.provideSongPlayAction import com.lalilu.lplayer.extensions.QueueAction import com.zhangke.krouter.annotation.Destination import com.zhangke.krouter.annotation.Param +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named @Destination("/pages/songs/detail") data class SongDetailScreen( @@ -46,8 +48,11 @@ data class SongDetailScreen( @Composable override fun provideScreenActions(): List = remember(this) { - listOf( - provideSongLikeAction(mediaId), + listOfNotNull( + requestFor( + qualifier = named("like_action"), + parameters = { parametersOf(mediaId) } + ), provideSongPlayAction(mediaId), ScreenAction.Static( title = { stringResource(id = R.string.button_set_song_to_next) }, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt b/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt deleted file mode 100644 index 4c04159a7..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.lalilu.lmusic.compose.presenter - -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import com.lalilu.component.base.UiAction -import com.lalilu.component.base.UiState -import com.lalilu.lplaylist.repository.PlaylistRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.koin.compose.koinInject - -sealed class DetailScreenAction : UiAction { - data object Like : DetailScreenAction() - data object UnLike : DetailScreenAction() - data object PlayPause : DetailScreenAction() -} - -data class DetailScreenLikeBtnState( - val isLiked: Boolean, - val onAction: (action: UiAction) -> Unit -) : UiState - -@SuppressLint("ComposableNaming") -@Composable -fun DetailScreenLikeBtnPresenter( - mediaId: String, - playlistRepo: PlaylistRepository = koinInject(), -): DetailScreenLikeBtnState { - val scope = rememberCoroutineScope { Dispatchers.IO } - val isLiked by playlistRepo.isItemInFavourite(mediaId).collectAsState(initial = false) - - return DetailScreenLikeBtnState(isLiked = isLiked) { - when (it) { - DetailScreenAction.Like -> scope.launch { - playlistRepo.addMediaIdsToFavourite(mediaIds = listOf(mediaId)) - } - - DetailScreenAction.UnLike -> scope.launch { - playlistRepo.removeMediaIdsFromFavourite(mediaIds = listOf(mediaId)) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongLikeAction.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongLikeAction.kt deleted file mode 100644 index f7d3fd70a..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongLikeAction.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.lalilu.lmusic.compose.screen.detail - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.selection.toggleable -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.lalilu.RemixIcon -import com.lalilu.component.base.screen.ScreenAction -import com.lalilu.lmusic.compose.presenter.DetailScreenAction -import com.lalilu.lmusic.compose.presenter.DetailScreenLikeBtnPresenter -import com.lalilu.remixicon.HealthAndMedical -import com.lalilu.remixicon.healthandmedical.heart3Fill -import com.lalilu.remixicon.healthandmedical.heart3Line - -fun provideSongLikeAction(mediaId: String): ScreenAction.Dynamic { - return ScreenAction.Dynamic { actionContext -> - val state = DetailScreenLikeBtnPresenter(mediaId) - - val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } - val haptic = LocalHapticFeedback.current - val pressedState = interactionSource.collectIsPressedAsState() - val iconColor by animateColorAsState( - targetValue = if (state.isLiked) MaterialTheme.colors.primary - else MaterialTheme.colors.onBackground.copy(0.3f), - label = "" - ) - val scaleValue by animateFloatAsState( - animationSpec = SpringSpec(dampingRatio = Spring.DampingRatioMediumBouncy), - targetValue = if (pressedState.value) 1.2f else 1f, - label = "" - ) - - Surface( - modifier = Modifier, - color = iconColor.copy(0.15f) - ) { - Row( - modifier = Modifier - .padding(horizontal = 20.dp) - .toggleable( - value = state.isLiked, - onValueChange = { - state.onAction(if (it) DetailScreenAction.Like else DetailScreenAction.UnLike) - if (it) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - }, - role = Role.Checkbox, - interactionSource = interactionSource, - indication = null - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - modifier = Modifier - .size(24.dp) - .scale(scaleValue), - imageVector = if (state.isLiked) RemixIcon.HealthAndMedical.heart3Fill else RemixIcon.HealthAndMedical.heart3Line, - tint = iconColor, - contentDescription = "A Checkable Button" - ) - - if (actionContext.isFullyExpanded) { - Text( - text = "收藏", - fontSize = 14.sp, - lineHeight = 14.sp, - color = iconColor, - fontWeight = FontWeight.Medium - ) - } - } - } - } -} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt index 82bad6c96..fb4c28330 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt @@ -1,7 +1,34 @@ package com.lalilu.lplaylist +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.blankj.utilcode.util.ToastUtils import com.lalilu.RemixIcon import com.lalilu.common.ext.requestFor @@ -11,8 +38,10 @@ import com.lalilu.lmedia.entity.LSong import com.lalilu.lplaylist.repository.PlaylistRepository import com.lalilu.remixicon.HealthAndMedical import com.lalilu.remixicon.Media +import com.lalilu.remixicon.healthandmedical.heart3Fill import com.lalilu.remixicon.healthandmedical.heart3Line import com.lalilu.remixicon.media.playListAddLine +import kotlinx.coroutines.launch import org.koin.core.annotation.Factory import org.koin.core.annotation.Named @@ -50,4 +79,73 @@ fun provideAddToFavouriteAction( ToastUtils.showShort("已添加${items.size}首歌曲至我喜欢") } } -) \ No newline at end of file +) + +@Factory(binds = [ScreenAction::class]) +@Named("like_action") +fun provideLikeAction( + mediaId: String, + playlistRepo: PlaylistRepository, +) = ScreenAction.Dynamic { actionContext -> + val isLiked by playlistRepo.isItemInFavourite(mediaId) + .collectAsState(initial = false) + + val scope = rememberCoroutineScope() + val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + val haptic = LocalHapticFeedback.current + val pressedState = interactionSource.collectIsPressedAsState() + val iconColor by animateColorAsState( + targetValue = if (isLiked) MaterialTheme.colors.primary + else MaterialTheme.colors.onBackground.copy(0.3f), + label = "" + ) + val scaleValue by animateFloatAsState( + animationSpec = SpringSpec(dampingRatio = Spring.DampingRatioMediumBouncy), + targetValue = if (pressedState.value) 1.2f else 1f, + label = "" + ) + + Surface( + modifier = Modifier, + color = iconColor.copy(0.15f) + ) { + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .toggleable( + value = isLiked, + onValueChange = { like -> + scope.launch { + if (like) playlistRepo.addMediaIdsToFavourite(mediaIds = listOf(mediaId)) + else playlistRepo.removeMediaIdsFromFavourite(mediaIds = listOf(mediaId)) + } + if (like) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + role = Role.Checkbox, + interactionSource = interactionSource, + indication = null + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier + .size(24.dp) + .scale(scaleValue), + imageVector = if (isLiked) RemixIcon.HealthAndMedical.heart3Fill else RemixIcon.HealthAndMedical.heart3Line, + tint = iconColor, + contentDescription = "A Checkable Button" + ) + + if (actionContext.isFullyExpanded) { + Text( + text = "收藏", + fontSize = 14.sp, + lineHeight = 14.sp, + color = iconColor, + fontWeight = FontWeight.Medium + ) + } + } + } +} \ No newline at end of file From 18fddbc4a60117c43223ec55efd4e4665c038c80 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Mon, 9 Dec 2024 20:33:42 +0800 Subject: [PATCH 134/213] =?UTF-8?q?[refactor]=E5=BC=80=E5=A7=8B=E9=87=8D?= =?UTF-8?q?=E6=9E=84SongDetailScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../new_screen/detail/SongDetailContent.kt | 158 ------------------ .../screen/detail/SongDetailContent.kt | 153 +++++++++++++++++ .../detail/SongDetailScreen.kt | 41 +---- 3 files changed, 157 insertions(+), 195 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt rename app/src/main/java/com/lalilu/lmusic/compose/{new_screen => screen}/detail/SongDetailScreen.kt (63%) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt deleted file mode 100644 index a49e27906..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailContent.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.lalilu.lmusic.compose.new_screen.detail - -import android.net.Uri -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade -import com.lalilu.common.base.SourceType -import com.lalilu.component.base.LocalSmartBarPadding -import com.lalilu.lmedia.entity.FileInfo -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmedia.entity.Metadata - -private val lSong = LSong( - id = "inceptos", - metadata = Metadata( - title = "maluisset", - album = "honestatis", - artist = "persius", - albumArtist = "simul", - composer = "eum", - lyricist = "eos", - comment = "morbi", - genre = "dolore", - track = "oratio", - disc = "sapien", - date = "iudicabit", - duration = 5920, - dateAdded = 2540, - dateModified = 3267 - ), fileInfo = FileInfo( - mimeType = "molestiae", - directoryPath = "amet", - pathStr = null, - fileName = null, - size = 5613 - ), uri = Uri.EMPTY, - sourceType = SourceType.Local, albumId = null -) - -@Composable -fun SongDetailContent( - modifier: Modifier = Modifier, - song: LSong = lSong, - progress: Float = 0f, -) { - Column( - modifier = modifier - .statusBarsPadding() - .padding(bottom = LocalSmartBarPadding.current.value.calculateBottomPadding() + 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Surface( - modifier = Modifier, - elevation = 2.dp, - shape = RoundedCornerShape(10.dp) - ) { - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - model = ImageRequest.Builder(LocalContext.current) - .data(song) - .size(width = 1024, height = 1024) - .crossfade(true) - .build(), - contentScale = ContentScale.FillWidth, - contentDescription = "" - ) - } - - Text( - modifier = Modifier - .graphicsLayer { - scaleX = 1f + (0.1f * progress) - scaleY = scaleX - - transformOrigin = TransformOrigin(0f, 0f) - }, - text = song.name, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - lineHeight = 16.sp - ) - - SongArtistsRow( - modifier = Modifier.fillMaxWidth(), - artists = song.artists - ) - - song.album?.let { - SongAlbumInfoCard( - modifier = Modifier.fillMaxWidth(), - album = it - ) - } - - SongActionsCard( - modifier = Modifier.fillMaxWidth(), - song = song - ) - - SongInformationCard( - modifier = Modifier.fillMaxWidth(), - song = song - ) - } -} - -@Preview(showSystemUi = true, showBackground = true) -@Composable -private fun SongDetailContentPreview() { - val expended = remember { mutableStateOf(false) } - val progress by animateFloatAsState( - targetValue = if (expended.value) 1f else 0f, - label = "progress" - ) - - SongDetailContent( - progress = progress, - ) -} - -@Preview(showSystemUi = true, showBackground = true) -@Composable -private fun SongDetailContentPreview2() { - val expended = remember { mutableStateOf(false) } - val progress by animateFloatAsState( - targetValue = if (expended.value) 0f else 1f, - label = "progressReverse" - ) - - SongDetailContent( - progress = progress, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt new file mode 100644 index 000000000..6f9b269b3 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt @@ -0,0 +1,153 @@ +package com.lalilu.lmusic.compose.screen.detail + +import android.net.Uri +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.lalilu.common.base.SourceType +import com.lalilu.component.base.LocalSmartBarPadding +import com.lalilu.lmedia.entity.FileInfo +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.entity.Metadata +import com.lalilu.lmusic.compose.new_screen.detail.SongActionsCard +import com.lalilu.lmusic.compose.new_screen.detail.SongAlbumInfoCard +import com.lalilu.lmusic.compose.new_screen.detail.SongArtistsRow +import com.lalilu.lmusic.compose.new_screen.detail.SongInformationCard + + +@Composable +fun SongDetailContent( + modifier: Modifier = Modifier, + song: () -> LSong? = { lSong }, + progress: Float = 0f, +) { + val bottomPadding = LocalSmartBarPadding.current.value + val navigationBar = WindowInsets.navigationBars.asPaddingValues() + + LazyColumn( + modifier = modifier + .fillMaxSize(), + contentPadding = PaddingValues( + bottom = navigationBar.calculateBottomPadding() + + bottomPadding.calculateBottomPadding() + + 16.dp + ) + ) { + item(key = "MAIN_COVER") { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + .height(300.dp), + model = ImageRequest.Builder(LocalContext.current) + .data(song()) + .size(width = 1024, height = 1024) + .crossfade(true) + .build(), + contentScale = ContentScale.FillWidth, + contentDescription = "" + ) + } + + song()?.let { song -> + item(key = "TITLE") { + Text( + modifier = Modifier + .graphicsLayer { + scaleX = 1f + (0.1f * progress) + scaleY = scaleX + + transformOrigin = TransformOrigin(0f, 0f) + }, + text = song.name, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + lineHeight = 16.sp + ) + } + + item(key = "ARTISTS") { + SongArtistsRow( + modifier = Modifier.fillMaxWidth(), + artists = song.artists + ) + } + + item(key = "ALBUM") { + song.album?.let { + SongAlbumInfoCard( + modifier = Modifier.fillMaxWidth(), + album = it + ) + } + } + + item(key = "ACTIONS") { + SongActionsCard( + modifier = Modifier.fillMaxWidth(), + song = song + ) + } + + item(key = "INFOS") { + SongInformationCard( + modifier = Modifier.fillMaxWidth(), + song = song + ) + } + } + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun SongDetailContentPreview() { + SongDetailContent() +} + +private val lSong = LSong( + id = "inceptos", + metadata = Metadata( + title = "maluisset", + album = "honestatis", + artist = "persius", + albumArtist = "simul", + composer = "eum", + lyricist = "eos", + comment = "morbi", + genre = "dolore", + track = "oratio", + disc = "sapien", + date = "iudicabit", + duration = 5920, + dateAdded = 2540, + dateModified = 3267 + ), fileInfo = FileInfo( + mimeType = "molestiae", + directoryPath = "amet", + pathStr = null, + fileName = null, + size = 5613 + ), uri = Uri.EMPTY, + sourceType = SourceType.Local, albumId = null +) \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt similarity index 63% rename from app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt rename to app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt index 5875c4696..1bb76b01f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt @@ -1,32 +1,22 @@ -package com.lalilu.lmusic.compose.new_screen.detail +package com.lalilu.lmusic.compose.screen.detail -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import com.lalilu.R import com.lalilu.common.ext.requestFor -import com.lalilu.component.base.LocalEnhanceSheetState import com.lalilu.component.base.screen.ScreenAction import com.lalilu.component.base.screen.ScreenActionFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.screen.ScreenType import com.lalilu.component.extension.DynamicTipsItem -import com.lalilu.component.override.ModalBottomSheetValue import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.compose.screen.detail.provideSongPlayAction import com.lalilu.lplayer.extensions.QueueAction import com.zhangke.krouter.annotation.Destination import com.zhangke.krouter.annotation.Param @@ -76,31 +66,8 @@ data class SongDetailScreen( val song = LMedia.getFlow(id = mediaId) .collectAsState(initial = null) - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - when { - song.value != null -> { - val enhanceSheetState = LocalEnhanceSheetState.current - val progress by remember(enhanceSheetState) { - derivedStateOf { - enhanceSheetState?.progress( - ModalBottomSheetValue.HalfExpanded, - ModalBottomSheetValue.Expanded, - ) ?: 0f - } - } - - SongDetailContent( - song = song.value!!, - progress = progress - ) - } - - else -> {} - } - } + SongDetailContent( + song = { song.value }, + ) } } \ No newline at end of file From 58085191821b1becd5e789bb9a8e3e53a5b681b4 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 10 Dec 2024 02:13:36 +0800 Subject: [PATCH 135/213] =?UTF-8?q?[refactor]=E4=BC=98=E5=8C=96=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E8=AF=A6=E6=83=85=E9=A1=B5=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../new_screen/detail/SongArtistsRow.kt | 14 +-- .../screen/detail/SongDetailContent.kt | 105 +++++++++++------- .../compose/screen/detail/SongDetailScreen.kt | 2 +- .../component/extension/ComposeModifierExt.kt | 84 +++++++++++++- 4 files changed, 150 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt index 934948c1a..f87d75eb4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt @@ -8,15 +8,11 @@ import androidx.compose.material.ChipDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.lartist.screen.ArtistDetailScreen import com.lalilu.lmedia.entity.LArtist @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) @@ -32,18 +28,18 @@ fun SongArtistsRow( artists.forEach { Chip( onClick = { - AppRouter.intent( - NavIntent.Push(ArtistDetailScreen(artistName = it.name)) - ) + AppRouter.route("/pages/artist/detail") + .with("artistName", it.name) + .push() }, colors = ChipDefaults.outlinedChipColors(), ) { Text( text = it.name, fontSize = 14.sp, + lineHeight = 14.sp, maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = contentColorFor(backgroundColor = MaterialTheme.colors.background) + color = MaterialTheme.colors.onBackground .copy(alpha = 0.7f) ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt index 6f9b269b3..6f75757ac 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt @@ -1,20 +1,23 @@ package com.lalilu.lmusic.compose.screen.detail import android.net.Uri -import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -26,6 +29,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import com.lalilu.common.base.SourceType import com.lalilu.component.base.LocalSmartBarPadding +import com.lalilu.component.extension.clipFade import com.lalilu.lmedia.entity.FileInfo import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.entity.Metadata @@ -39,7 +43,6 @@ import com.lalilu.lmusic.compose.new_screen.detail.SongInformationCard fun SongDetailContent( modifier: Modifier = Modifier, song: () -> LSong? = { lSong }, - progress: Float = 0f, ) { val bottomPadding = LocalSmartBarPadding.current.value val navigationBar = WindowInsets.navigationBars.asPaddingValues() @@ -51,52 +54,64 @@ fun SongDetailContent( bottom = navigationBar.calculateBottomPadding() + bottomPadding.calculateBottomPadding() + 16.dp - ) + ), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { item(key = "MAIN_COVER") { - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .animateContentSize() - .height(300.dp), - model = ImageRequest.Builder(LocalContext.current) - .data(song()) - .size(width = 1024, height = 1024) - .crossfade(true) - .build(), - contentScale = ContentScale.FillWidth, - contentDescription = "" - ) - } - - song()?.let { song -> - item(key = "TITLE") { - Text( + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.BottomCenter + ) { + AsyncImage( modifier = Modifier - .graphicsLayer { - scaleX = 1f + (0.1f * progress) - scaleY = scaleX - - transformOrigin = TransformOrigin(0f, 0f) - }, - text = song.name, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - lineHeight = 16.sp + .fillMaxWidth() + .aspectRatio(1f) + .clipFade( + lengthDp = 300.dp, + alignmentY = Alignment.Bottom + ), + model = ImageRequest.Builder(LocalContext.current) + .data(song()) + .size(1024) + .crossfade(true) + .build(), + contentScale = ContentScale.FillWidth, + contentDescription = "" ) - } - item(key = "ARTISTS") { - SongArtistsRow( - modifier = Modifier.fillMaxWidth(), - artists = song.artists - ) + song()?.let { song -> + Column( + modifier = Modifier + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + text = song.name, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + lineHeight = 26.sp, + color = MaterialTheme.colors.onBackground, + ) + + SongArtistsRow( + modifier = Modifier.fillMaxWidth(), + artists = song.artists + ) + } + } } + } + song()?.let { song -> item(key = "ALBUM") { song.album?.let { SongAlbumInfoCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), album = it ) } @@ -104,14 +119,18 @@ fun SongDetailContent( item(key = "ACTIONS") { SongActionsCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), song = song ) } item(key = "INFOS") { SongInformationCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), song = song ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt index 1bb76b01f..a0d2c445b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt @@ -38,7 +38,7 @@ data class SongDetailScreen( @Composable override fun provideScreenActions(): List = remember(this) { - listOfNotNull( + listOfNotNull( requestFor( qualifier = named("like_action"), parameters = { parametersOf(mediaId) } diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt index cb3e88606..dfb3b1c0f 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt @@ -10,14 +10,26 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -73,7 +85,7 @@ fun Modifier.longClickable( } // 取消计时器 - timer.cancel() + timer?.cancel() onRelease() }, onTap = { onClick() }, @@ -90,4 +102,72 @@ fun Modifier.enableFor( enable: () -> Boolean, forFalse: @Composable Modifier.() -> Modifier = { this }, forTrue: @Composable Modifier.() -> Modifier, -): Modifier = composed { if (enable()) this.forTrue() else this.forFalse() } \ No newline at end of file +): Modifier = composed { if (enable()) this.forTrue() else this.forFalse() } + +fun Modifier.clipFade( + cutting: Int = 10, + lengthDp: Dp = 100.dp, + alignmentX: Alignment.Horizontal? = null, + alignmentY: Alignment.Vertical? = Alignment.Bottom, + func: (x: Float) -> Float = { it * it } +) = composed { + graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithCache { + val alignment = alignmentX ?: alignmentY + val length = lengthDp.toPx() + val colorStops = (0..cutting step 1) + .map { it / cutting.toFloat() } + .map { it to Color.Black.copy(alpha = func(it)) } + .toTypedArray() + + val (startValue, topLeft, drawSize) = when (alignment) { + is Alignment.Vertical -> { + val startValue = size.height - length + val topLeft = Offset(x = 0.0F, y = startValue) + val drawSize = Size(width = size.width, height = length) + + Triple(startValue, topLeft, drawSize) + } + + is Alignment.Horizontal -> { + val startValue = size.width - length + val topLeft = Offset(x = startValue, y = 0f) + val drawSize = Size(width = length, height = size.height) + + Triple(startValue, topLeft, drawSize) + } + + else -> Triple(0f, Offset.Zero, size) + } + + onDrawWithContent { + drawContent() + + if (alignment is Alignment.Vertical) { + rotate(degrees = if (alignment == Alignment.Top) 180f else 0f) { + drawRect( + brush = Brush.verticalGradient( + colorStops = colorStops, + startY = startValue + ), + topLeft = topLeft, + size = drawSize, + blendMode = BlendMode.DstOut + ) + } + } else if (alignment is Alignment.Horizontal) { + rotate(degrees = if (alignment == Alignment.Start) 180f else 0f) { + drawRect( + brush = Brush.horizontalGradient( + colorStops = colorStops, + startX = startValue + ), + topLeft = topLeft, + size = drawSize, + blendMode = BlendMode.DstOut + ) + } + } + } + } +} \ No newline at end of file From 4a6e9d83e4c205b09c973509936aff1d60a8f852 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 11 Dec 2024 09:32:18 +0800 Subject: [PATCH 136/213] =?UTF-8?q?[refactor]=E8=B0=83=E6=95=B4=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E8=AF=A6=E6=83=85=E9=A1=B5=E5=85=83=E7=B4=A0=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../new_screen/detail/SongActionsCard.kt | 3 +- .../new_screen/detail/SongAlbumInfoCard.kt | 138 ++++++++++++++---- .../new_screen/detail/SongInformationCard.kt | 3 +- .../screen/detail/SongDetailContent.kt | 4 +- 4 files changed, 115 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt index d034a1455..beec3441b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt @@ -45,7 +45,8 @@ fun SongActionsCard( Surface( modifier = modifier, - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(12.dp), + elevation = 1.dp ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt index 109879ede..ae21e6425 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt @@ -1,10 +1,18 @@ package com.lalilu.lmusic.compose.new_screen.detail +import androidx.compose.foundation.MarqueeSpacing +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme @@ -12,14 +20,21 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.request.error +import coil3.request.placeholder +import com.cheonjaeung.compose.grid.SimpleGridCells +import com.cheonjaeung.compose.grid.VerticalGrid +import com.lalilu.R import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.navigation.AppRouter -import com.lalilu.component.navigation.NavIntent -import com.lalilu.lalbum.screen.AlbumDetailScreen import com.lalilu.lmedia.entity.LAlbum -import com.lalilu.lmusic.compose.component.card.RecommendCardCover - @OptIn(ExperimentalMaterialApi::class) @Composable @@ -29,42 +44,107 @@ fun SongAlbumInfoCard( ) { Surface( modifier = modifier, - shape = RoundedCornerShape(20.dp), + shape = RoundedCornerShape(12.dp), + elevation = 1.dp, onClick = { - AppRouter.intent( - NavIntent.Push( - AlbumDetailScreen(albumId = album.id) - ) - ) + AppRouter.route("/pages/albums/detail") + .with("albumId", album.id) + .push() } ) { - Row( + Box( modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp) + .fillMaxSize() ) { - RecommendCardCover( - width = { 125.dp }, - height = { 125.dp }, - imageData = { album } - ) - Column( - modifier = Modifier.fillMaxWidth() + // TODO Animation BG + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - Text( - text = album.name, - style = MaterialTheme.typography.subtitle1, - color = dayNightTextColor() + AsyncImage( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colors.onBackground.copy(0.1f), + shape = RoundedCornerShape(8.dp) + ), + model = ImageRequest.Builder(LocalContext.current) + .data(album) + .placeholder(R.drawable.ic_music_2_line_100dp) + .error(R.drawable.ic_music_2_line_100dp) + .build(), + contentScale = ContentScale.Crop, + contentDescription = "Recommend Card Cover Image" ) - album.artistName?.let { artist -> + Column( + modifier = Modifier.fillMaxWidth() + ) { Text( - text = artist, - style = MaterialTheme.typography.subtitle2, - color = dayNightTextColor(0.5f) + text = album.name, + style = MaterialTheme.typography.subtitle1, + color = dayNightTextColor() ) + album.artistName?.let { artist -> + Text( + text = artist, + style = MaterialTheme.typography.subtitle2, + color = dayNightTextColor(0.5f) + ) + } } } } } +} + +@Composable +fun GridAnimation( + modifier: Modifier = Modifier, + images: List = emptyList() +) { + Box( + modifier = modifier + .height(72.dp) + .aspectRatio(1f) + .basicMarquee( + iterations = Int.MAX_VALUE, + spacing = MarqueeSpacing(8.dp), + velocity = 30.dp, + repeatDelayMillis = 0, + initialDelayMillis = 0 + ) + ) { + VerticalGrid( + modifier = Modifier.size(36.dp * 3f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + columns = SimpleGridCells.Fixed(3) + ) { + images.forEach { song -> + AsyncImage( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colors.onBackground.copy(0.1f), + shape = RoundedCornerShape(8.dp) + ), + model = ImageRequest.Builder(LocalContext.current) + .data(song) + .crossfade(true) + .placeholder(R.drawable.ic_music_2_line_100dp) + .error(R.drawable.ic_music_2_line_100dp) + .build(), + contentScale = ContentScale.Crop, + contentDescription = "Recommend Card Cover Image" + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt index 8d89dcb9a..c2b069ec0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt @@ -26,7 +26,8 @@ fun SongInformationCard( ) { Surface( modifier = modifier, - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(12.dp), + elevation = 1.dp ) { Column( modifier = Modifier diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt index 6f75757ac..1900da4bb 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt @@ -91,8 +91,8 @@ fun SongDetailContent( .padding(horizontal = 2.dp), text = song.name, fontWeight = FontWeight.Bold, - fontSize = 18.sp, - lineHeight = 26.sp, + fontSize = 24.sp, + lineHeight = 30.sp, color = MaterialTheme.colors.onBackground, ) From 26794e64be55ccba434ad0343174364894ef5dca Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 12 Dec 2024 15:20:45 +0800 Subject: [PATCH 137/213] =?UTF-8?q?[refactor]=E4=BC=98=E5=8C=96SmartBar?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=92=8C=E8=BF=87=E6=B8=A1=E7=9A=84=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../navigation/NavigationSmartBar.kt | 39 ++++++----- .../component/smartbar/NavigateCommonBar.kt | 70 +++++++++---------- 2 files changed, 53 insertions(+), 56 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt index c5ae813fd..7cfd262b3 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -42,24 +42,7 @@ fun NavigationSmartBar( val currentScreen = LocalNavigator.current ?.lastItemOrNull - val previousScreen = LocalNavigator.current - ?.previousScreen() - ?.value - - val previousTitle = (previousScreen as? ScreenInfoFactory) - ?.provideScreenInfo() - ?.title?.invoke() - ?: "返回" - val mainContent = (currentScreen as? ScreenBarFactory)?.content() - val tabScreenRoutes = remember { - listOf("/pages/home", "/pages/playlist", "/pages/search") - } - - val tabScreens = remember(tabScreenRoutes) { - tabScreenRoutes.mapNotNull { AppRouter.route(it).get() as? TabScreen } - } - val navigationBar: NavigationBarType = remember(mainContent, currentScreen) { when { mainContent != null -> NavigationBarType.NormalBar(mainContent) @@ -76,7 +59,10 @@ fun NavigationSmartBar( slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Up, animationSpec = spring(stiffness = Spring.StiffnessMediumLow) - ) togetherWith slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Down) + ) togetherWith slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) }, contentAlignment = Alignment.BottomCenter, targetState = navigationBar, @@ -96,6 +82,14 @@ fun NavigationSmartBar( } is NavigationBarType.TabBar -> { + val tabScreenRoutes = remember { + listOf("/pages/home", "/pages/playlist", "/pages/search") + } + + val tabScreens = remember(tabScreenRoutes) { + tabScreenRoutes.mapNotNull { AppRouter.route(it).get() as? TabScreen } + } + NavigateTabBar( modifier = Modifier.fillMaxHeight(), currentScreen = { currentScreen }, @@ -105,6 +99,15 @@ fun NavigationSmartBar( } is NavigationBarType.CommonBar -> { + val previousScreen = LocalNavigator.current + ?.previousScreen() + ?.value + + val previousTitle = (previousScreen as? ScreenInfoFactory) + ?.provideScreenInfo() + ?.title?.invoke() + ?: "返回" + NavigateCommonBar( modifier = Modifier.fillMaxHeight(), previousTitle = previousTitle, diff --git a/component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt b/component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt index afca9c0e0..13a77ae94 100644 --- a/component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt +++ b/component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -76,51 +75,46 @@ fun NavigateCommonBarContent( actions = screenActions ?: emptyList() ) - Row( - modifier = modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton( - modifier = Modifier.fillMaxHeight(), - shape = RectangleShape, - contentPadding = PaddingValues(start = 12.dp, end = 20.dp), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colors.onBackground - ), - onClick = { - if (onBackPress != null) { - onBackPress() - } else { - onBackPressedDispatcher?.onBackPressed() - } - } + AnimatedContent( + modifier = modifier.fillMaxHeight(), + transitionSpec = { fadeIn() togetherWith fadeOut() }, + targetState = previousTitle to screenActions, + label = "ExtraActions" + ) { (title, actions) -> + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = previousIcon, - tint = MaterialTheme.colors.onBackground, - contentDescription = null - ) - AnimatedContent( - targetState = previousTitle, label = "" + TextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(start = 12.dp, end = 20.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colors.onBackground + ), + onClick = { + if (onBackPress != null) { + onBackPress() + } else { + onBackPressedDispatcher?.onBackPressed() + } + } ) { + Icon( + imageVector = previousIcon, + tint = MaterialTheme.colors.onBackground, + contentDescription = null + ) Text( - text = it, + text = title, fontSize = 14.sp ) } - } - AnimatedContent( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - transitionSpec = { fadeIn() togetherWith fadeOut() }, - targetState = screenActions, - label = "ExtraActions" - ) { actions -> SubcomposeLayout( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxHeight() + .weight(1f) ) { constraints -> // 若actions为空,则不显示 if (actions == null) return@SubcomposeLayout layout(0, 0) {} From 2d238dded8533e5312f4b3b4868d71bc7b6df7e1 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 12 Dec 2024 17:15:10 +0800 Subject: [PATCH 138/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1=E5=88=87=E6=8D=A2=E6=92=AD=E6=94=BE=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E5=9F=BA=E7=A1=80=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/PlayingLayout.kt | 19 +++ .../screen/playing/PlayingLayoutExpended.kt | 20 +-- .../screen/playing/seekbar/SeekbarLayout.kt | 125 +++++++++++------- .../main/java/com/lalilu/lplayer/MPlayer.kt | 5 + .../com/lalilu/lplayer/extensions/PlayMode.kt | 50 +++---- .../lalilu/lplayer/extensions/PlayerAction.kt | 1 + 6 files changed, 132 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index e6dfb4f36..99ce3548f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -44,6 +44,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.lalilu.component.base.LocalEnhanceSheetState +import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.extension.hideControl import com.lalilu.lmedia.lyric.LyricItem import com.lalilu.lmedia.lyric.LyricSourceEmbedded @@ -54,6 +55,7 @@ import com.lalilu.lmusic.compose.screen.playing.seekbar.ClickPart import com.lalilu.lmusic.compose.screen.playing.seekbar.SeekbarLayout import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.extensions.PlayMode import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive @@ -330,6 +332,23 @@ fun PlayingLayout( onSeekTo = { position -> PlayerAction.SeekTo(position.toLong()).action() }, + onSwitchTo = { index -> + val playMode = when (index) { + 1 -> PlayMode.RepeatOne + 2 -> PlayMode.Shuffle + else -> PlayMode.ListRecycle + } + PlayerAction.SetPlayMode(playMode) + .action() + DynamicTipsItem.Static( + title = when (playMode) { + PlayMode.ListRecycle -> "列表循环" + PlayMode.RepeatOne -> "单曲循环" + PlayMode.Shuffle -> "随机播放" + }, + subTitle = "切换播放模式", + ).show() + }, onClick = { clickPart -> when (clickPart) { ClickPart.Start -> PlayerAction.SkipToPrevious.action() diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt index d7c45c97e..a394ca5e0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt @@ -36,12 +36,14 @@ import coil3.request.ImageRequest import coil3.request.crossfade import coil3.request.transformations import com.lalilu.R +import com.lalilu.RemixIcon import com.lalilu.component.extension.singleViewModel import com.lalilu.lmusic.utils.coil.BlurTransformation import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.extensions.PlayerAction -import com.lalilu.lplayer.extensions.PlayMode +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.media.playLine @Composable fun PlayingLayoutExpended( @@ -194,13 +196,15 @@ fun ControlPanel( } IconButton(onClick = { playMode = (playMode + 1) % 3 }) { Image( - painter = painterResource( - when (PlayMode.values()[playMode]) { - PlayMode.ListRecycle -> R.drawable.ic_order_play_line - PlayMode.RepeatOne -> R.drawable.ic_repeat_one_line - PlayMode.Shuffle -> R.drawable.ic_shuffle_line - } - ), + // TODO 待完善播放模式的显示 + imageVector = RemixIcon.Media.playLine, +// painter = painterResource( +// when (PlayMode.values()[playMode]) { +// PlayMode.ListRecycle -> R.drawable.ic_order_play_line +// PlayMode.RepeatOne -> R.drawable.ic_repeat_one_line +// PlayMode.Shuffle -> R.drawable.ic_shuffle_line +// } +// ), contentDescription = "play_pause", colorFilter = ColorFilter.tint(color = Color.White), modifier = Modifier.size(24.dp) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt index 50bfda90a..89b2e27c6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt @@ -1,22 +1,29 @@ package com.lalilu.lmusic.compose.screen.playing.seekbar +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberDraggable2DState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -26,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius @@ -56,8 +64,13 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.lerp +import com.lalilu.RemixIcon import com.lalilu.common.AccumulatedValue import com.lalilu.lmusic.utils.extension.durationToTime +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.media.orderPlayFill +import com.lalilu.remixicon.media.repeatOneFill +import com.lalilu.remixicon.media.shuffleFill import kotlinx.coroutines.launch import kotlin.math.absoluteValue @@ -159,6 +172,24 @@ fun SeekbarLayout( visibilityThreshold = 0.001f, label = "" ) + val yProgressValue = remember { + derivedStateOf { + val value = seekbarOffsetY.floatValue.coerceAtMost(0f) + .absoluteValue + .takeIf { it < (scrollThreadHold / 2f) } + ?: 0f + + (value / (scrollThreadHold / 2f)).coerceIn(0f, 1f) + } + } + val yTranslationAnimateValue = animateFloatAsState( + targetValue = yProgressValue.value, + animationSpec = spring( + stiffness = Spring.StiffnessLow, + dampingRatio = Spring.DampingRatioMediumBouncy + ), + label = "" + ) val textStyle = remember { TextStyle.Default.copy( fontSize = 16.sp, @@ -304,6 +335,17 @@ fun SeekbarLayout( scope.launch { onDragStart(it) } }, onDragEnd = { + if (isSwitching) { + val actualWidth = boxSize.width - density.run { 4.dp.roundToPx() } + val singleWidth = actualWidth / 3f + + when (switchModeX.floatValue) { + in 0f..singleWidth -> onSwitchTo(0) + in singleWidth..(singleWidth * 2) -> onSwitchTo(1) + else -> onSwitchTo(2) + } + } + switchMode.value = false seekbarState.value = SeekbarState.Idle @@ -314,35 +356,16 @@ fun SeekbarLayout( draggableState.dispatchRawDelta(dragAmount) } ) - ) { - val yProgressValue = remember { - derivedStateOf { - val value = seekbarOffsetY.floatValue.coerceAtMost(0f) - .absoluteValue - .takeIf { it < (scrollThreadHold / 2f) } - ?: 0f - - (value / (scrollThreadHold / 2f)).coerceIn(0f, 1f) + .graphicsLayer { + translationY = -yTranslationAnimateValue.value * (scrollThreadHold / 2f) + scaleX = 1f - (yTranslationAnimateValue.value * 0.1f) + scaleY = scaleX } - } - val yTranslationAnimateValue = animateFloatAsState( - targetValue = yProgressValue.value, - animationSpec = spring( - stiffness = Spring.StiffnessLow, - dampingRatio = Spring.DampingRatioMediumBouncy - ), - label = "" - ) - + ) { Canvas( modifier = Modifier .fillMaxWidth() .height(56.dp) - .graphicsLayer { - translationY = -yTranslationAnimateValue.value * (scrollThreadHold / 2f) - scaleX = 1f - (yTranslationAnimateValue.value * 0.1f) - scaleY = scaleX - } .clip(RoundedCornerShape(16.dp)) ) { val innerPath = Path() @@ -388,7 +411,7 @@ fun SeekbarLayout( val thumbLeft = lerp( start = paddingValue, - stop = paddingValue + switchModeX.floatValue - (innerWidth / 3f) / 2f, + stop = switchModeX.floatValue - (innerWidth / 3f) / 2f, fraction = switchingProgress.value ).coerceIn( paddingValue, @@ -460,28 +483,36 @@ fun SeekbarLayout( } } -// Box( -// modifier = Modifier -// .fillMaxHeight() -// .fillMaxWidth() -// ) { -// Row { -// Icon( -// imageVector = RemixIcon.Media.repeatFill, -// contentDescription = null -// ) -// -// Icon( -// imageVector = RemixIcon.Media.repeatFill, -// contentDescription = null -// ) -// -// Icon( -// imageVector = RemixIcon.Media.repeatFill, -// contentDescription = null -// ) -// } -// } + AnimatedVisibility( + modifier = Modifier.fillMaxSize(), + visible = isSwitching, + enter = fadeIn(), + exit = fadeOut() + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround + ) { + Icon( + imageVector = RemixIcon.Media.orderPlayFill, + contentDescription = null, + tint = Color.White + ) + Icon( + imageVector = RemixIcon.Media.repeatOneFill, + contentDescription = null, + tint = Color.White + ) + Icon( + imageVector = RemixIcon.Media.shuffleFill, + contentDescription = null, + tint = Color.White + ) + } + } } } diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index 39ae572ce..ec7dbca8c 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -16,6 +16,7 @@ import androidx.media3.session.SessionToken import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.Utils import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.extensions.playMode import com.lalilu.lplayer.service.MService import com.lalilu.lplayer.service.getHistoryItems import com.lalilu.lplayer.service.saveHistoryIds @@ -128,6 +129,10 @@ object MPlayer : CoroutineScope { is PlayerAction.PauseWhenCompletion -> { // if (action.cancel) cancelPauseWhenCompletion() else pauseWhenCompletion() } + + is PlayerAction.SetPlayMode -> { + browser.playMode = action.playMode + } } } diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt index 030f644ed..831b1532d 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt @@ -1,41 +1,25 @@ package com.lalilu.lplayer.extensions -import android.support.v4.media.session.PlaybackStateCompat.REPEAT_MODE_ALL -import android.support.v4.media.session.PlaybackStateCompat.REPEAT_MODE_ONE -import android.support.v4.media.session.PlaybackStateCompat.SHUFFLE_MODE_ALL -import android.support.v4.media.session.PlaybackStateCompat.SHUFFLE_MODE_GROUP -import android.support.v4.media.session.PlaybackStateCompat.SHUFFLE_MODE_NONE +import androidx.media3.common.Player -enum class PlayMode( - val repeatMode: Int, - val shuffleMode: Int, - val value: Int -) { - ListRecycle(repeatMode = REPEAT_MODE_ALL, shuffleMode = SHUFFLE_MODE_NONE, value = 0), - RepeatOne(repeatMode = REPEAT_MODE_ONE, shuffleMode = SHUFFLE_MODE_NONE, value = 1), - Shuffle(repeatMode = REPEAT_MODE_ALL, shuffleMode = SHUFFLE_MODE_ALL, value = 2); - - fun next(): PlayMode { - return when (this) { - ListRecycle -> RepeatOne - RepeatOne -> Shuffle - Shuffle -> ListRecycle - } - } +sealed interface PlayMode { + data object ListRecycle : PlayMode + data object RepeatOne : PlayMode + data object Shuffle : PlayMode companion object { - const val KEY = "PLAY_MODE" - - fun of(value: Int): PlayMode = when (value) { - 1 -> RepeatOne - 2 -> Shuffle - else -> ListRecycle - } - - fun of(repeatMode: Int, shuffleMode: Int): PlayMode { - if (repeatMode == REPEAT_MODE_ONE) return RepeatOne - if (shuffleMode == SHUFFLE_MODE_ALL || shuffleMode == SHUFFLE_MODE_GROUP) return Shuffle + fun of(repeatMode: Int, shuffleModeEnabled: Boolean): PlayMode { + if (repeatMode == Player.REPEAT_MODE_ONE) return RepeatOne + if (shuffleModeEnabled) return Shuffle return ListRecycle } } -} \ No newline at end of file +} + +var Player.playMode + get() = PlayMode.of(repeatMode, shuffleModeEnabled) + set(value) { + shuffleModeEnabled = value is PlayMode.Shuffle + repeatMode = if (value is PlayMode.RepeatOne) Player.REPEAT_MODE_ONE + else Player.REPEAT_MODE_ALL + } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt index 4784e8396..7718a2577 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt @@ -14,6 +14,7 @@ sealed class PlayerAction : Action { data class PlayById(val mediaId: String) : PlayerAction() data class SeekTo(val positionMs: Long) : PlayerAction() data class PauseWhenCompletion(val cancel: Boolean = false) : PlayerAction() + data class SetPlayMode(val playMode: PlayMode) : PlayerAction() sealed class CustomAction(val name: String) : PlayerAction() data object PlayOrPause : CustomAction(PlayOrPause::class.java.name) From e34646866c96e792f3056a50c103aa3b583589e1 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Thu, 12 Dec 2024 22:02:52 +0800 Subject: [PATCH 139/213] =?UTF-8?q?[refactor]=E4=BD=BF=E7=94=A8CustomComma?= =?UTF-8?q?nd=E6=9B=BF=E6=8D=A2=E9=9A=8F=E6=9C=BA=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E6=97=B6=E7=9A=84seekToNext=E7=9A=84=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8Dcontroller=E5=92=8Csession=E9=97=B4?= =?UTF-8?q?=E4=BA=A7=E7=94=9F=E5=B7=AE=E5=BC=82=E5=AF=BC=E8=87=B4=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E6=9B=B4=E6=96=B0=E6=97=B6=E9=97=AA=E5=8A=A8=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lplayer/MPlayer.kt | 26 +++++++- .../lplayer/extensions/QueueControlPlayer.kt | 37 +++++++++++ .../lalilu/lplayer/service/CustomCommand.kt | 26 ++++++++ .../com/lalilu/lplayer/service/MService.kt | 65 +++++++++++++++---- 4 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/service/CustomCommand.kt diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index ec7dbca8c..ef1826061 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -1,6 +1,7 @@ package com.lalilu.lplayer import android.content.ComponentName +import android.os.Bundle import androidx.annotation.OptIn import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf @@ -15,8 +16,10 @@ import androidx.media3.session.MediaBrowser import androidx.media3.session.SessionToken import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.Utils +import com.lalilu.lplayer.extensions.PlayMode import com.lalilu.lplayer.extensions.PlayerAction import com.lalilu.lplayer.extensions.playMode +import com.lalilu.lplayer.service.CustomCommand import com.lalilu.lplayer.service.MService import com.lalilu.lplayer.service.getHistoryItems import com.lalilu.lplayer.service.saveHistoryIds @@ -93,8 +96,27 @@ object MPlayer : CoroutineScope { PlayerAction.Play -> browser.play() PlayerAction.Pause -> browser.pause() - PlayerAction.SkipToNext -> browser.seekToNext() - PlayerAction.SkipToPrevious -> browser.seekToPrevious() + PlayerAction.SkipToNext -> { + if (browser.playMode is PlayMode.Shuffle) { + browser.sendCustomCommand( + CustomCommand.SeekToNext.toSessionCommand(), + Bundle.EMPTY + ) + } else { + browser.seekToNext() + } + } + + PlayerAction.SkipToPrevious -> { + if (browser.playMode is PlayMode.Shuffle) { + browser.sendCustomCommand( + CustomCommand.SeekToPrevious.toSessionCommand(), + Bundle.EMPTY + ) + } else { + browser.seekToPrevious() + } + } PlayerAction.PlayOrPause -> { if (browser.isPlaying) { diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt new file mode 100644 index 000000000..afa7fd40d --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt @@ -0,0 +1,37 @@ +package com.lalilu.lplayer.extensions + +import androidx.annotation.OptIn +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ShuffleOrder + +@OptIn(UnstableApi::class) +internal class QueueControlPlayer(player: Player) : ForwardingPlayer(player) { + override fun seekToNext() { + if (playMode == PlayMode.Shuffle) { + // TODO 待完善随机播放模式的列表变换效果 + super.seekToPrevious() + return + } + + super.seekToNext() + } + + override fun seekToPrevious() { + if (playMode == PlayMode.Shuffle) { + super.seekToNext() + return + } + + super.seekToPrevious() + } +} + +@OptIn(UnstableApi::class) +internal fun ExoPlayer.setUpQueueControl(): Player { + return QueueControlPlayer( + this.apply { setShuffleOrder(ShuffleOrder.UnshuffledShuffleOrder(0)) } + ) +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/CustomCommand.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/CustomCommand.kt new file mode 100644 index 000000000..74425dbf4 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/CustomCommand.kt @@ -0,0 +1,26 @@ +package com.lalilu.lplayer.service + +import android.os.Bundle +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionCommands +import com.lalilu.lplayer.service.CustomCommand.SeekToNext +import com.lalilu.lplayer.service.CustomCommand.SeekToPrevious + +internal enum class CustomCommand(val action: String) { + SeekToNext(action = "com.lalilu.lplayer.service.command.next"), + SeekToPrevious(action = "com.lalilu.lplayer.service.command.previous"); + + fun toSessionCommand(): SessionCommand = SessionCommand(action, Bundle.EMPTY) +} + +internal fun SessionCommand.toCustomCommendOrNull(): CustomCommand? { + return when (customAction) { + SeekToNext.action -> SeekToNext + SeekToPrevious.action -> SeekToPrevious + else -> null + } +} + +internal fun SessionCommands.Builder.registerCustomCommands(): SessionCommands.Builder = apply { + addSessionCommands(CustomCommand.entries.map { it.toSessionCommand() }) +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index a64f1af4e..9a30a5642 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -3,11 +3,13 @@ package com.lalilu.lplayer.service import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Bundle import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.LibraryResult @@ -15,7 +17,10 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder +import androidx.media3.session.SessionCommand import androidx.media3.session.SessionError +import androidx.media3.session.SessionResult import com.blankj.utilcode.util.ActivityUtils import com.blankj.utilcode.util.AppUtils import com.google.common.collect.ImmutableList @@ -24,6 +29,9 @@ import com.google.common.util.concurrent.ListenableFuture import com.lalilu.lmedia.LMedia import com.lalilu.lplayer.MPlayerKV import com.lalilu.lplayer.extensions.FadeTransitionRenderersFactory +import com.lalilu.lplayer.extensions.setUpQueueControl +import com.lalilu.lplayer.service.CustomCommand.SeekToNext +import com.lalilu.lplayer.service.CustomCommand.SeekToPrevious import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -38,7 +46,7 @@ import kotlin.coroutines.CoroutineContext class MService : MediaLibraryService(), CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() - private var exoPlayer: ExoPlayer? = null + private var exoPlayer: Player? = null private var mediaSession: MediaLibrarySession? = null private val defaultAudioAttributes by lazy { AudioAttributes.Builder() @@ -56,15 +64,15 @@ class MService : MediaLibraryService(), CoroutineScope { MNotificationProvider(this) ) - exoPlayer = ExoPlayer - .Builder(this) + exoPlayer = ExoPlayer.Builder(this) .setRenderersFactory(FadeTransitionRenderersFactory(this, this)) - .setHandleAudioBecomingNoisy(MPlayerKV.handleBecomeNoisy.value ?: true) - .setAudioAttributes(defaultAudioAttributes, MPlayerKV.handleAudioFocus.value ?: true) + .setHandleAudioBecomingNoisy(MPlayerKV.handleBecomeNoisy.value != false) + .setAudioAttributes(defaultAudioAttributes, MPlayerKV.handleAudioFocus.value != false) .build() + .setUpQueueControl() mediaSession = MediaLibrarySession - .Builder(this, exoPlayer!!, MServiceCallback()) + .Builder(this, exoPlayer!!, MServiceCallback(exoPlayer!!)) .setSessionActivity(getLauncherPendingIntent()) .build() @@ -88,20 +96,52 @@ class MService : MediaLibraryService(), CoroutineScope { private fun startListenForValuesUpdate() = launch { MPlayerKV.handleAudioFocus.flow().onEach { withContext(Dispatchers.Main) { - exoPlayer?.setAudioAttributes(defaultAudioAttributes, it ?: true) + exoPlayer?.setAudioAttributes(defaultAudioAttributes, it != false) } }.launchIn(this) MPlayerKV.handleBecomeNoisy.flow().onEach { withContext(Dispatchers.Main) { - exoPlayer?.setHandleAudioBecomingNoisy(it ?: true) + (exoPlayer as? ExoPlayer) + ?.setHandleAudioBecomingNoisy(it != false) } }.launchIn(this) } } @OptIn(UnstableApi::class) -private class MServiceCallback : MediaLibrarySession.Callback { +private class MServiceCallback(private val player: Player) : MediaLibrarySession.Callback { + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val sessionCommands = MediaSession.ConnectionResult + .DEFAULT_SESSION_COMMANDS.buildUpon() + .registerCustomCommands() + .build() + + return AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + val action = customCommand.toCustomCommendOrNull() + ?: return Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED)) + + when (action) { + SeekToNext -> player.seekToNext() + SeekToPrevious -> player.seekToPrevious() + } + + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + private fun buildBrowsableItem(id: String, title: String): MediaItem { val metadata = MediaMetadata.Builder() .setTitle(title) @@ -202,15 +242,12 @@ private class MServiceCallback : MediaLibrarySession.Callback { private fun Context.getLauncherPendingIntent(): PendingIntent { return PendingIntent.getActivity( - this, - 0, - Intent().apply { + this, 0, Intent().apply { setClassName( AppUtils.getAppPackageName(), ActivityUtils.getLauncherActivity() ) - }, - PendingIntent.FLAG_IMMUTABLE + }, PendingIntent.FLAG_IMMUTABLE ) } From 8fd69e6ed95dce58bb5f40e3b9548380cfb555dd Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 13 Dec 2024 09:37:27 +0800 Subject: [PATCH 140/213] =?UTF-8?q?[refactor]=E4=BF=AE=E6=AD=A3MService?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt | 12 ++---------- .../main/java/com/lalilu/lplayer/service/MService.kt | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index ef1826061..9f9a05464 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -187,14 +187,6 @@ object MPlayer : CoroutineScope { updateItems(timeline) } - override fun onRepeatModeChanged(repeatMode: Int) { - super.onRepeatModeChanged(repeatMode) - } - - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - super.onShuffleModeEnabledChanged(shuffleModeEnabled) - } - fun updateItems( timeline: Timeline = browser.currentTimeline, currentIndex: Int = browser.currentMediaItemIndex @@ -208,12 +200,12 @@ object MPlayer : CoroutineScope { } } -fun Timeline.toMediaItems(): List { +private fun Timeline.toMediaItems(): List { return (0 until this.windowCount) .mapNotNull { this.getWindow(it, Timeline.Window()).mediaItem } } -fun Timeline.indexOf(mediaId: String): Int { +private fun Timeline.indexOf(mediaId: String): Int { return (0 until this.windowCount).firstOrNull { this.getWindow(it, Timeline.Window()) .mediaItem.mediaId == mediaId diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index 9a30a5642..60afaacc3 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -116,7 +116,7 @@ private class MServiceCallback(private val player: Player) : MediaLibrarySession controller: MediaSession.ControllerInfo ): MediaSession.ConnectionResult { val sessionCommands = MediaSession.ConnectionResult - .DEFAULT_SESSION_COMMANDS.buildUpon() + .DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() .registerCustomCommands() .build() From 9ae2ccc3b9bb54afe7bf37598aa3e2db3f83e7c7 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 17 Dec 2024 00:52:29 +0800 Subject: [PATCH 141/213] =?UTF-8?q?[refactor]=E5=AE=9E=E7=8E=B0=E7=AE=80?= =?UTF-8?q?=E6=98=93=E7=9A=84=E9=9A=8F=E6=9C=BA=E6=92=AD=E6=94=BE=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lplayer/extensions/QueueControlPlayer.kt | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt index afa7fd40d..aaa07a839 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt @@ -11,8 +11,24 @@ import androidx.media3.exoplayer.source.ShuffleOrder internal class QueueControlPlayer(player: Player) : ForwardingPlayer(player) { override fun seekToNext() { if (playMode == PlayMode.Shuffle) { - // TODO 待完善随机播放模式的列表变换效果 - super.seekToPrevious() + val maxIndex = currentTimeline.windowCount - 1 + val currentIndex = currentMediaItemIndex + + // 获取下一个元素的index + val nextIndex = (0..maxIndex) + .filter { (it - currentIndex) / maxIndex.toFloat() > 0.5f } // 获取距离当前元素至少0.5f总长度距离的元素 + .randomOrNull() + + if (nextIndex != null) { + moveMediaItem(nextIndex, currentIndex) + seekTo(currentIndex, 0) + } else { + val previousIndex = (currentIndex - 1) + .takeIf { it >= 0 } + ?: maxIndex + + seekTo(previousIndex, 0) + } return } From 0e180ed76918d7efc3f1c35cc03c23d6b73f5545 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 21 Dec 2024 02:25:35 +0800 Subject: [PATCH 142/213] =?UTF-8?q?[refactor]=E5=AE=8C=E5=96=84=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E9=9A=8F=E6=9C=BA=E6=92=AD=E6=94=BE=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lplayer/extensions/QueueControlPlayer.kt | 120 ++++++++++++++---- 1 file changed, 93 insertions(+), 27 deletions(-) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt index aaa07a839..2d93307f6 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt @@ -1,53 +1,119 @@ package com.lalilu.lplayer.extensions import androidx.annotation.OptIn +import androidx.media3.common.C import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ShuffleOrder +import kotlin.math.abs +import kotlin.math.min @OptIn(UnstableApi::class) -internal class QueueControlPlayer(player: Player) : ForwardingPlayer(player) { - override fun seekToNext() { +internal class QueueControlPlayer(player: ExoPlayer) : ForwardingPlayer(player), Player.Listener { + + init { + player.addListener(this) + player.setShuffleOrder(CustomShuffleOrder(0)) + } + + private fun tryMoveNext() { if (playMode == PlayMode.Shuffle) { - val maxIndex = currentTimeline.windowCount - 1 - val currentIndex = currentMediaItemIndex - - // 获取下一个元素的index - val nextIndex = (0..maxIndex) - .filter { (it - currentIndex) / maxIndex.toFloat() > 0.5f } // 获取距离当前元素至少0.5f总长度距离的元素 - .randomOrNull() - - if (nextIndex != null) { - moveMediaItem(nextIndex, currentIndex) - seekTo(currentIndex, 0) - } else { - val previousIndex = (currentIndex - 1) - .takeIf { it >= 0 } - ?: maxIndex - - seekTo(previousIndex, 0) + val target = getRandomNextIndex() + + val targetMediaItem = currentTimeline + .getWindow(target, Timeline.Window()) + .mediaItem + val nextMediaItem = currentTimeline + .getWindow(nextMediaItemIndex, Timeline.Window()) + .mediaItem + replaceMediaItem(target, nextMediaItem) + replaceMediaItem(nextMediaItemIndex, targetMediaItem) + } + } + + private fun getRandomNextIndex(): Int { + val maxIndex = currentTimeline.windowCount - 1 + val currentIndex = currentMediaItemIndex + + // 获取下一个元素的index + val nextIndex = (0..maxIndex) + .filter { + min( + abs(it - currentIndex), + abs(maxIndex - currentIndex + it) + ) / maxIndex.toFloat() > 0.25f } - return + .randomOrNull() + ?: nextMediaItemIndex + + return nextIndex + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { + tryMoveNext() } + } + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + super.setShuffleModeEnabled(shuffleModeEnabled) + tryMoveNext() + } + + override fun seekToNext() { + tryMoveNext() super.seekToNext() } override fun seekToPrevious() { - if (playMode == PlayMode.Shuffle) { - super.seekToNext() - return + super.seekToPrevious() + tryMoveNext() + } + + @UnstableApi + private class CustomShuffleOrder(private val size: Int) : ShuffleOrder { + override fun getLength(): Int { + return size } - super.seekToPrevious() + override fun getNextIndex(index: Int): Int { + val target = index - 1 + return if (target < 0) size - 1 else target + } + + override fun getPreviousIndex(index: Int): Int { + val target = index + 1 + return if (target >= size) 0 else target + } + + override fun getLastIndex(): Int { + return if (size > 0) size - 1 else C.INDEX_UNSET + } + + override fun getFirstIndex(): Int { + return if (size > 0) 0 else C.INDEX_UNSET + } + + override fun cloneAndInsert(insertionIndex: Int, insertionCount: Int): ShuffleOrder { + return CustomShuffleOrder(length + insertionCount) + } + + override fun cloneAndRemove(indexFrom: Int, indexToExclusive: Int): ShuffleOrder { + return CustomShuffleOrder(length - indexToExclusive + indexFrom) + } + + override fun cloneAndClear(): ShuffleOrder { + return CustomShuffleOrder(0) + } } } + @OptIn(UnstableApi::class) internal fun ExoPlayer.setUpQueueControl(): Player { - return QueueControlPlayer( - this.apply { setShuffleOrder(ShuffleOrder.UnshuffledShuffleOrder(0)) } - ) + return QueueControlPlayer(this) } \ No newline at end of file From 16a9d58996ebf99976a624198fb136dc19d7c4ed Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Sat, 21 Dec 2024 19:22:17 +0800 Subject: [PATCH 143/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=90=9C=E7=B4=A2=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 2 - .../lmusic/compose/component/base/InputBar.kt | 95 -------- .../compose/component/base/SearchInputBar.kt | 23 -- .../compose/component/card/SearchInputBar.kt | 11 +- .../lmusic/compose/screen/search/SearchBar.kt | 202 ++++++++++++++++++ .../search/SearchScreen.kt | 50 ++--- .../com/lalilu/lmusic/datastore/SettingsSp.kt | 2 - .../{SearchViewModel.kt => SearchVM.kt} | 33 +-- 8 files changed, 241 insertions(+), 177 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchBar.kt rename app/src/main/java/com/lalilu/lmusic/compose/{new_screen => screen}/search/SearchScreen.kt (85%) rename app/src/main/java/com/lalilu/lmusic/viewmodel/{SearchViewModel.kt => SearchVM.kt} (65%) diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index 6d3dac8f0..408f0b2e2 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -26,7 +26,6 @@ import com.lalilu.lmusic.utils.coil.keyer.MediaItemKeyer import com.lalilu.lmusic.utils.extension.toBitmap import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchLyricViewModel -import com.lalilu.lmusic.viewmodel.SearchViewModel import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext @@ -84,7 +83,6 @@ val AppModule = module { val ViewModelModule = module { viewModelOf(::PlayingViewModel) viewModel { get() } - viewModelOf(::SearchViewModel) viewModelOf(::SearchLyricViewModel) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt deleted file mode 100644 index b79124c11..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.lalilu.lmusic.compose.component.base - -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.lalilu.component.extension.dayNightTextColor - -@Composable -fun InputBar( - modifier: Modifier = Modifier, - hint: String = "", - defaultValue: String = "", - value: MutableState = remember { mutableStateOf(defaultValue) }, - onValueChange: (String) -> Unit = { }, - onSubmit: (String) -> Unit = {}, -) { - val focusRequest = remember { FocusRequester() } - val focused = remember { mutableStateOf(false) } - val color = MaterialTheme.colors.onBackground.copy(alpha = 0.3f) - val borderColor = animateColorAsState( - targetValue = if (focused.value) Color(0xFF135CB6) else color, - label = "TextField border color with focus" - ) - - BasicTextField( - modifier = modifier - .focusRequester(focusRequest) - .onFocusChanged { - focused.value = it.hasFocus && it.isFocused - } - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - keyboardActions = KeyboardActions(onSearch = { - onSubmit(value.value) - }), - minLines = 1, - textStyle = TextStyle.Default.copy( - color = dayNightTextColor(), - fontSize = 18.sp - ), - cursorBrush = SolidColor(dayNightTextColor()), - value = value.value, - onValueChange = { - value.value = it - onValueChange(it) - } - ) { innerTextField -> - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Surface( - border = BorderStroke(2.dp, borderColor.value), - shape = RoundedCornerShape(4.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - innerTextField() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt deleted file mode 100644 index 9085c0ae5..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.lalilu.lmusic.compose.component.base - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.ui.Modifier - -@Composable -fun SearchInputBar( - modifier: Modifier = Modifier, - hint: String = "", - value: MutableState, - onValueChange: (String) -> Unit = {}, - onSubmit: (String) -> Unit = {}, -) { - InputBar( - modifier = modifier.fillMaxWidth(), - hint = hint, - value = value, - onValueChange = onValueChange, - onSubmit = onSubmit - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/card/SearchInputBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/card/SearchInputBar.kt index 73e850baf..103835f94 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/card/SearchInputBar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/card/SearchInputBar.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.R -import com.lalilu.lmusic.compose.component.base.InputBar @Composable @@ -45,11 +44,11 @@ fun SearchInputBar( verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(5.dp) ) { - InputBar( - modifier = Modifier.weight(1f), - value = text, - onSubmit = onSearchFor - ) +// InputBar( +// modifier = Modifier.weight(1f), +// value = text, +// onSubmit = onSearchFor +// ) IconButton(onClick = { onSearchFor(text.value) }) { Icon( painter = painterResource(id = R.drawable.ic_search_2_line), diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchBar.kt new file mode 100644 index 000000000..fb605ba2b --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchBar.kt @@ -0,0 +1,202 @@ +package com.lalilu.lmusic.compose.screen.search + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ScreenBarFactory +import com.lalilu.lmusic.viewmodel.SearchVM +import com.lalilu.remixicon.Arrows +import com.lalilu.remixicon.System +import com.lalilu.remixicon.arrows.arrowLeftSLine +import com.lalilu.remixicon.system.closeLine +import org.koin.compose.koinInject + + +@Composable +internal fun ScreenBarFactory.SearchBar( + searchVM: SearchVM = koinInject(), +) { + val visible = remember { mutableStateOf(true) } + + RegisterContent( + isVisible = { visible.value }, + onDismiss = { visible.value = false }, + onBackPressed = null, + content = { + SearchBarContent( + keyword = { searchVM.keywordStr }, + onUpdateKeyword = { searchVM.keywordStr = it } + ) + } + ) +} + + +@Composable +internal fun SearchBarContent( + modifier: Modifier = Modifier, + keyword: () -> String = { "" }, + onUpdateKeyword: (String) -> Unit = {}, + onBackPress: (() -> Unit)? = null +) { + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current + ?.onBackPressedDispatcher + val keyboard = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + + Row( + modifier = modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(start = 12.dp, end = 20.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colors.onBackground + ), + onClick = { + keyboard?.hide() + + if (onBackPress != null) { + onBackPress() + } else { + onBackPressedDispatcher?.onBackPressed() + } + } + ) { + Icon( + imageVector = RemixIcon.Arrows.arrowLeftSLine, + tint = MaterialTheme.colors.onBackground, + contentDescription = null + ) + Text( + text = "关闭", + fontSize = 14.sp, + lineHeight = 14.sp, + color = MaterialTheme.colors.onBackground, + ) + } + + BasicTextField( + modifier = Modifier + .focusRequester(focusRequester) + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colors.onBackground.copy(0.05f)), + value = keyword(), + onValueChange = onUpdateKeyword, + singleLine = true, + maxLines = 1, + cursorBrush = SolidColor(MaterialTheme.colors.onBackground), + textStyle = TextStyle.Default.copy( + fontSize = 16.sp, + lineHeight = 16.sp, + letterSpacing = 1.sp, + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.Bold + ), + decorationBox = { content -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + this@Row.AnimatedVisibility( + enter = fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)), + exit = fadeOut(animationSpec = spring(stiffness = Spring.StiffnessMedium)), + visible = keyword().isEmpty() + ) { + Text( + modifier = Modifier.padding(start = 2.dp), + text = "输入关键词以匹配元素", + fontSize = 14.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground.copy(0.3f) + ) + } + + Row( + modifier = Modifier + .fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(1f), + contentAlignment = Alignment.CenterStart + ) { + content() + } + + AnimatedVisibility( + enter = fadeIn() + scaleIn( + animationSpec = spring( + stiffness = Spring.StiffnessMedium, + dampingRatio = Spring.DampingRatioMediumBouncy + ), + initialScale = 0f + ), + exit = fadeOut() + scaleOut( + animationSpec = spring(stiffness = Spring.StiffnessMedium), + targetScale = 0f + ), + visible = keyword().isNotEmpty() + ) { + IconButton( + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + onClick = { onUpdateKeyword("") } + ) { + Icon( + imageVector = RemixIcon.System.closeLine, + contentDescription = "clear" + ) + } + } + } + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt similarity index 85% rename from app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt rename to app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt index 1f9d8b5a3..6aaee5d86 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/search/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.new_screen.search +package com.lalilu.lmusic.compose.screen.search import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -18,31 +18,27 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen -import com.blankj.utilcode.util.KeyboardUtils import com.lalilu.R import com.lalilu.RemixIcon import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo -import com.lalilu.component.extension.singleViewModel -import com.lalilu.lmusic.compose.component.base.SearchInputBar +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.lmusic.compose.component.card.RecommendTitle -import com.lalilu.lmusic.utils.extension.getActivity -import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.lalilu.lmusic.viewmodel.SearchViewModel +import com.lalilu.lmusic.viewmodel.SearchVM import com.lalilu.remixicon.System import com.lalilu.remixicon.system.search2Line import com.zhangke.krouter.annotation.Destination +import org.koin.compose.koinInject @Destination("/pages/search") -object SearchScreen : Screen, TabScreen, ScreenBarFactory { +data object SearchScreen : Screen, TabScreen, ScreenInfoFactory, ScreenBarFactory { private fun readResolve(): Any = SearchScreen @Composable @@ -55,46 +51,26 @@ object SearchScreen : Screen, TabScreen, ScreenBarFactory { @Composable override fun Content() { - val visible = remember { mutableStateOf(true) } + val searchVM: SearchVM = koinInject() - RegisterContent( - isVisible = { visible.value }, - onDismiss = { visible.value = false }, - onBackPressed = null, - content = { SearchBar() } - ) + SearchBar(searchVM = searchVM) - SearchScreen() + SearchScreenContent(searchVM = searchVM) } } -@Composable -fun SearchBar( - searchVM: SearchViewModel = singleViewModel(), -) { - SearchInputBar( - modifier = Modifier, - value = searchVM.keyword, - onValueChange = { searchVM.searchFor(it) }, - onSubmit = { searchVM.searchFor(it) } - ) -} - @OptIn( ExperimentalFoundationApi::class, ExperimentalMaterialApi::class ) @Composable -private fun Screen.SearchScreen( - playingVM: PlayingViewModel = singleViewModel(), - searchVM: SearchViewModel = singleViewModel(), +private fun SearchScreenContent( + searchVM: SearchVM = koinInject(), ) { - val context = LocalContext.current + val keyboard = LocalSoftwareKeyboardController.current DisposableEffect(Unit) { - onDispose { - context.getActivity()?.let { KeyboardUtils.hideSoftInput(it) } - } + onDispose { keyboard?.hide() } } // Songs( diff --git a/app/src/main/java/com/lalilu/lmusic/datastore/SettingsSp.kt b/app/src/main/java/com/lalilu/lmusic/datastore/SettingsSp.kt index 12aa237bc..167a399be 100644 --- a/app/src/main/java/com/lalilu/lmusic/datastore/SettingsSp.kt +++ b/app/src/main/java/com/lalilu/lmusic/datastore/SettingsSp.kt @@ -10,8 +10,6 @@ class SettingsSp(private val context: Application) : BaseSp() { return context.getSharedPreferences(context.packageName, Application.MODE_PRIVATE) } - val excludePath = obtainSet("EXCLUDE_PATH") - val playMode = obtain( Config.KEY_SETTINGS_PLAY_MODE, Config.DEFAULT_SETTINGS_PLAY_MODE diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt similarity index 65% rename from app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt rename to app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt index 238b9fc7f..c6e2b5b8b 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt @@ -17,23 +17,36 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.mapLatest +import org.koin.core.annotation.Single @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -class SearchViewModel : ViewModel() { - val keyword = mutableStateOf("") - private val keywordStr = MutableStateFlow("") - private val keywords = keywordStr.debounce(200).mapLatest { +@Single +class SearchVM : ViewModel() { + private val _keywordStr = mutableStateOf("") + var keywordStr: String + get() = _keywordStr.value + set(value) { + _keywordStr.value = value + _keywordsFlow.value = value + } + + private val _keywordsFlow = MutableStateFlow("") + private val keywords = _keywordsFlow.debounce(200).mapLatest { if (it.isEmpty()) return@mapLatest emptyList() it.trim().uppercase().split(' ') } - val songsResult = LMedia.getFlow().searchFor(keywords) + val songsResult = LMedia.getFlow() + .searchFor(keywords) .toState(emptyList(), viewModelScope) - val artistsResult = LMedia.getFlow().searchFor(keywords) + val artistsResult = LMedia.getFlow() + .searchFor(keywords) .toState(emptyList(), viewModelScope) - val albumsResult = LMedia.getFlow().searchFor(keywords) + val albumsResult = LMedia.getFlow() + .searchFor(keywords) .toState(emptyList(), viewModelScope) - val genresResult = LMedia.getFlow().searchFor(keywords) + val genresResult = LMedia.getFlow() + .searchFor(keywords) .toState(emptyList(), viewModelScope) private fun Flow>.searchFor(keywords: Flow>): Flow> = @@ -41,8 +54,4 @@ class SearchViewModel : ViewModel() { if (keywordList.isEmpty()) return@combine emptyList() items.filter { item -> keywordList.all { item.getMatchStr().contains(it) } } } - - fun searchFor(str: String) { - keywordStr.tryEmit(str) - } } \ No newline at end of file From ae315cbc8d7347b4ac759e3effd408e928db46df Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 23 Dec 2024 01:37:19 +0800 Subject: [PATCH 144/213] =?UTF-8?q?[refactor]=E4=BC=98=E5=8C=96padding?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/component/base/LocalObject.kt | 53 ++++++++++++++----- .../lalbum/screen/AlbumsScreenContent.kt | 15 +----- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/base/LocalObject.kt b/component/src/main/java/com/lalilu/component/base/LocalObject.kt index af894de3b..75bd6c765 100644 --- a/component/src/main/java/com/lalilu/component/base/LocalObject.kt +++ b/component/src/main/java/com/lalilu/component/base/LocalObject.kt @@ -9,7 +9,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier @@ -21,20 +26,42 @@ val LocalWindowSize = compositionLocalOf { error("WindowSizeClass hasn't been initialized") } +@Composable +private fun SmartPaddingContent() { + val bottomHeight = LocalSmartBarPadding.current.value.calculateBottomPadding() + + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + WindowInsets.ime.asPaddingValues().calculateBottomPadding() + + 16.dp + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(bottomHeight) + ) +} + fun LazyListScope.smartBarPadding() { item( key = "smartBarPadding", - contentType = "smartBarPadding" - ) { - val bottomHeight = LocalSmartBarPadding.current.value.calculateBottomPadding() + - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + - WindowInsets.ime.asPaddingValues().calculateBottomPadding() + - 16.dp - - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(bottomHeight) - ) - } + contentType = "smartBarPadding", + content = { SmartPaddingContent() } + ) +} + +fun LazyGridScope.smartBarPadding() { + item( + key = "smartBarPadding", + contentType = "smartBarPadding", + span = { GridItemSpan(maxLineSpan) }, + content = { SmartPaddingContent() } + ) +} + +fun LazyStaggeredGridScope.smartBarPadding() { + item( + key = "smartBarPadding", + contentType = "smartBarPadding", + span = StaggeredGridItemSpan.FullLine, + content = { SmartPaddingContent() } + ) } \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt index 7d45f2e6b..cc4a01da4 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt @@ -3,11 +3,9 @@ package com.lalilu.lalbum.screen import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid @@ -21,13 +19,12 @@ import androidx.compose.material.Text import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.blankj.utilcode.util.LogUtils -import com.lalilu.component.base.LocalSmartBarPadding import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.base.NavigatorHeader +import com.lalilu.component.base.smartBarPadding import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent import com.lalilu.lalbum.component.AlbumCard @@ -119,14 +116,6 @@ internal fun AlbumsScreenContent( } } - item(span = StaggeredGridItemSpan.FullLine) { - val padding by LocalSmartBarPadding.current - - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(padding.calculateBottomPadding() + 20.dp) - ) - } + smartBarPadding() } } \ No newline at end of file From 496f5b6319476176126ab592b7667f66d04ca76f Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 23 Dec 2024 02:08:19 +0800 Subject: [PATCH 145/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=AE=8C=E5=96=84=E6=90=9C=E7=B4=A2=E9=A1=B5=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/search/SearchScreen.kt | 287 +++++++----------- .../search/extensions/SearchArtistsResult.kt | 69 +++++ .../search/extensions/SearchSongsResult.kt | 57 ++++ .../com/lalilu/lmusic/viewmodel/SearchVM.kt | 41 ++- .../com/lalilu/lmusic/viewmodel/SongsVM.kt | 2 +- 5 files changed, 268 insertions(+), 188 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt index 6aaee5d86..489efb140 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt @@ -1,39 +1,51 @@ package com.lalilu.lmusic.compose.screen.search -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Chip -import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import com.lalilu.R import com.lalilu.RemixIcon +import com.lalilu.component.base.LocalSmartBarPadding import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory -import com.lalilu.lmusic.compose.component.card.RecommendTitle +import com.lalilu.component.base.smartBarPadding +import com.lalilu.lmusic.compose.screen.search.extensions.SearchArtistsResult +import com.lalilu.lmusic.compose.screen.search.extensions.SearchSongsResult +import com.lalilu.lmusic.viewmodel.SearchScreenState import com.lalilu.lmusic.viewmodel.SearchVM import com.lalilu.remixicon.System import com.lalilu.remixicon.system.search2Line +import com.lalilu.remixicon.system.searchLine import com.zhangke.krouter.annotation.Destination import org.koin.compose.koinInject @@ -55,186 +67,111 @@ data object SearchScreen : Screen, TabScreen, ScreenInfoFactory, ScreenBarFactor SearchBar(searchVM = searchVM) - SearchScreenContent(searchVM = searchVM) + SearchScreenContent( + searchVM = searchVM + ) } } -@OptIn( - ExperimentalFoundationApi::class, - ExperimentalMaterialApi::class -) @Composable private fun SearchScreenContent( searchVM: SearchVM = koinInject(), ) { val keyboard = LocalSoftwareKeyboardController.current + val statusBar = WindowInsets.statusBars.asPaddingValues() + val state = searchVM.searchState.value DisposableEffect(Unit) { onDispose { keyboard?.hide() } } -// Songs( -// mediaIds = searchVM.songsResult.value.take(5).map { it.mediaId }, -// sortFor = "SearchResult", -// supportListAction = { emptyList() }, -// headerContent = { -// item(key = "Song_Header") { -// RecommendTitle( -// modifier = Modifier.height(64.dp), -// title = "歌曲", -// onClick = { -// if (searchVM.songsResult.value.isNotEmpty()) { -// AppRouter.intent(NavIntent.Push( -// SongsScreen( -// title = "[${searchVM.keyword.value}]\n歌曲搜索结果", -// mediaIds = searchVM.songsResult.value.map { it.mediaId } -// ) -// )) -// } -// } -// ) -// } -// }, -// footerContent = { -// val onAlbumHeaderClick = { -// if (searchVM.albumsResult.value.isNotEmpty()) { -//// navigator.navigate( -//// AlbumsScreenDestination( -//// title = "[${keyword.value}]\n专辑搜索结果", -//// sortFor = "SearchResultForAlbum", -//// albumIdsText = searchVM.albumsResult.value.map(LAlbum::id).json() -//// ) -//// ) -// } -// } -// -// item(key = "AlbumHeader") { -// RecommendTitle( -// title = "专辑", -// modifier = Modifier.height(64.dp), -// onClick = onAlbumHeaderClick -// ) { -// AnimatedVisibility(visible = searchVM.albumsResult.value.isNotEmpty()) { -// Chip( -// onClick = onAlbumHeaderClick, -// ) { -// Text( -// text = "${searchVM.albumsResult.value.size} 条结果", -// style = MaterialTheme.typography.caption, -// ) -// } -// } -// } -// } -// item(key = "AlbumItems") { -// AnimatedContent( -// targetState = searchVM.albumsResult.value.isNotEmpty(), -// label = "" -// ) { show -> -// if (show) { -// RecommendRow( -// items = { searchVM.albumsResult.value }, -// getId = { it.id } -// ) { -// RecommendCardForAlbum( -// modifier = Modifier.animateItemPlacement(), -// width = { 100.dp }, -// height = { 100.dp }, -// item = { it }, -// onClick = { -//// navigator.navigate(AlbumDetailScreenDestination(albumId = it.id)) -// } -// ) -// } -// } else { -// Text(modifier = Modifier.padding(20.dp), text = "无匹配专辑") -// } -// } -// } -// -// searchItem( -// name = "艺术家", -// showCount = 5, -// getId = { it.id }, -// items = searchVM.artistsResult.value, -// getContentType = { LArtist::class }, -// onClickHeader = { -// if (searchVM.artistsResult.value.isNotEmpty()) { -//// navigator.navigate( -//// ArtistsScreenDestination( -//// title = "[${keyword.value}]\n艺术家搜索结果", -//// sortFor = "SearchResultForArtist", -//// artistIdsText = searchVM.artistsResult.value.map(LArtist::name).json() -//// ) -//// ) -// } -// } -// ) { item -> -// ArtistCard( -// artist = item, -// isPlaying = { -// playingVM.isItemPlaying { playing -> -// playing.let { it as? LSong } -// ?.let { song -> song.artists.any { it.name == item.name } } -// ?: false -// } -// }, -// onClick = { -//// navigator.navigate(ArtistDetailScreenDestination(artistName = item.name)) -// } -// ) -// } -// } -// ) -} + AnimatedContent( + modifier = Modifier.fillMaxSize(), + targetState = when { + state is SearchScreenState.Idle -> "Idle" + state is SearchScreenState.Empty -> "Empty" + else -> "Searching" + }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { searchState -> + if (searchState == "Idle") { + SearchTips( + modifier = Modifier + .fillMaxSize() + ) + return@AnimatedContent + } -@OptIn(ExperimentalMaterialApi::class) -fun LazyListScope.searchItem( - name: String, - items: List, - getId: (I) -> Any, - showCount: Int = items.size, - getContentType: (I) -> Any, - onClickHeader: () -> Unit = {}, - itemContent: @Composable LazyItemScope.(I) -> Unit, -) { - item(key = "${name}_Header") { - RecommendTitle( - modifier = Modifier.height(64.dp), - title = name, - onClick = onClickHeader - ) { - AnimatedVisibility(visible = items.isNotEmpty()) { - Chip( - onClick = onClickHeader, - ) { - Text( - text = "${items.size} 条结果", - style = MaterialTheme.typography.caption, - ) - } + if (searchState == "Empty") { + SearchTips( + modifier = Modifier + .fillMaxSize(), + title = "暂无搜索结果" + ) + return@AnimatedContent + } + + val songsResult = remember { + SearchSongsResult { + (searchVM.searchState.value as? SearchScreenState.Searching) + ?.songs ?: emptyList() + } + }.register() + + val artistsResult = remember { + SearchArtistsResult { + (searchVM.searchState.value as? SearchScreenState.Searching) + ?.artists ?: emptyList() } + }.register() + + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + contentPadding = statusBar, + columns = GridCells.Fixed(6) + ) { + songsResult(this) + artistsResult(this) + smartBarPadding() } } - item(key = "${name}_EmptyTips") { - AnimatedVisibility( - modifier = Modifier.fillMaxWidth(), - visible = items.isEmpty(), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - text = "无匹配$name" - ) +} + +@Preview +@Composable +fun SearchTips( + modifier: Modifier = Modifier, + title: String = "搜索曲库内所有内容" +) { + val paddingBottom = LocalSmartBarPadding.current.value.calculateBottomPadding() + val imePadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + + Box( + modifier = modifier + .fillMaxSize() + .padding(bottom = paddingBottom + imePadding), + contentAlignment = Alignment.Center + ) { + Column { + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = RemixIcon.System.searchLine, + contentDescription = null, + tint = MaterialTheme.colors.onBackground.copy(0.4f) + ) + Text( + text = title, + fontSize = 14.sp, + lineHeight = 14.sp, + color = MaterialTheme.colors.onBackground.copy(0.6f) + ) + } } } - items( - items = items.take(showCount), - key = getId, - contentType = getContentType, - itemContent = itemContent - ) } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt new file mode 100644 index 000000000..b32aac103 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt @@ -0,0 +1,69 @@ +package com.lalilu.lmusic.compose.screen.search.extensions + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.LazyGridContent +import com.lalilu.component.navigation.AppRouter +import com.lalilu.lartist.component.ArtistCard +import com.lalilu.lmedia.entity.LArtist +import com.lalilu.lplayer.MPlayer + +class SearchArtistsResult( + private val artistsResult: () -> List +) : LazyGridContent { + + @Composable + override fun register(): LazyGridScope.() -> Unit { + return fun LazyGridScope.() { + if (artistsResult().isNotEmpty()){ + item( + key = "${this@SearchArtistsResult::class.java.name}_Header", + span = { GridItemSpan(maxLineSpan) } + ) { + Text( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(16.dp), + text = "艺术家搜索结果", + fontSize = 16.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + + itemsIndexed( + items = artistsResult(), + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + span = { _, _ -> GridItemSpan(maxLineSpan) } + ) { index, item -> + ArtistCard( + modifier = Modifier + .fillMaxWidth() + .animateItem(), + title = item.name, + subTitle = "#$index", + songCount = item.songs.size.toLong(), + imageSource = { item.songs.firstOrNull() }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, + onClick = { + AppRouter.route("/pages/artist/detail") + .with("artistName", item.id) + .push() + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt new file mode 100644 index 000000000..b78d2c234 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt @@ -0,0 +1,57 @@ +package com.lalilu.lmusic.compose.screen.search.extensions + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.LazyGridContent +import com.lalilu.component.card.SongCard +import com.lalilu.lmedia.entity.LSong + +class SearchSongsResult( + private val songsResult: () -> List +) : LazyGridContent { + + @Composable + override fun register(): LazyGridScope.() -> Unit { + return fun LazyGridScope.() { + if (songsResult().isNotEmpty()) { + item( + key = "${this@SearchSongsResult::class.java.name}_Header", + span = { GridItemSpan(maxLineSpan) } + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = "歌曲搜索结果", + fontSize = 16.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + + items( + items = songsResult(), + key = { it.id }, + contentType = { it::class.java }, + span = { GridItemSpan(maxLineSpan) } + ) { + SongCard( + modifier = Modifier + .fillMaxWidth() + .animateItem(), + song = { it } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt index c6e2b5b8b..00df9c112 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt @@ -19,6 +19,17 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.mapLatest import org.koin.core.annotation.Single +sealed interface SearchScreenState { + data object Idle : SearchScreenState + data object Empty : SearchScreenState + data class Searching( + val songs: List, + val artists: List, + val albums: List, + val genres: List + ) : SearchScreenState +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Single class SearchVM : ViewModel() { @@ -36,18 +47,24 @@ class SearchVM : ViewModel() { it.trim().uppercase().split(' ') } - val songsResult = LMedia.getFlow() - .searchFor(keywords) - .toState(emptyList(), viewModelScope) - val artistsResult = LMedia.getFlow() - .searchFor(keywords) - .toState(emptyList(), viewModelScope) - val albumsResult = LMedia.getFlow() - .searchFor(keywords) - .toState(emptyList(), viewModelScope) - val genresResult = LMedia.getFlow() - .searchFor(keywords) - .toState(emptyList(), viewModelScope) + val searchState = combine( + flow = keywords, + flow2 = LMedia.getFlow().searchFor(keywords), + flow3 = LMedia.getFlow().searchFor(keywords), + flow4 = LMedia.getFlow().searchFor(keywords), + flow5 = LMedia.getFlow().searchFor(keywords) + ) { keywords, songs, artists, albums, genres -> + if (keywords.isEmpty()) return@combine SearchScreenState.Idle + if (songs.isEmpty() && artists.isEmpty() && albums.isEmpty() && genres.isEmpty()) + return@combine SearchScreenState.Empty + + SearchScreenState.Searching( + songs = songs, + artists = artists, + albums = albums, + genres = genres + ) + }.toState(SearchScreenState.Idle, viewModelScope) private fun Flow>.searchFor(keywords: Flow>): Flow> = combine(keywords) { items, keywordList -> diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt index cfbfd5ee1..b830e16ae 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt @@ -59,7 +59,7 @@ data class SongsState( } val searchResult = source.mapLatest { flow -> - flow.filter { item -> keywords.all { item.getMatchStr().contains(it) } } + flow.filter { item -> keywords.all { item.getMatchStr().contains(it.uppercase()) } } } return when (selectedSortAction) { From a261a4a4b0fc5aad2c944d3f5f6f41e207c3194a Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 28 Dec 2024 14:11:00 +0800 Subject: [PATCH 146/213] =?UTF-8?q?[refactor]=E8=B0=83=E6=95=B4=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=85=83=E7=B4=A0=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt | 1 - .../lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt | 1 - .../lmusic/compose/new_screen/detail/SongInformationCard.kt | 3 --- lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt | 1 + 4 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt index beec3441b..fa9c1a7af 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt @@ -46,7 +46,6 @@ fun SongActionsCard( Surface( modifier = modifier, shape = RoundedCornerShape(12.dp), - elevation = 1.dp ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt index ae21e6425..421c9f44b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt @@ -45,7 +45,6 @@ fun SongAlbumInfoCard( Surface( modifier = modifier, shape = RoundedCornerShape(12.dp), - elevation = 1.dp, onClick = { AppRouter.route("/pages/albums/detail") .with("albumId", album.id) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt index c2b069ec0..fae68cd7e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt @@ -2,11 +2,9 @@ package com.lalilu.lmusic.compose.new_screen.detail import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -27,7 +25,6 @@ fun SongInformationCard( Surface( modifier = modifier, shape = RoundedCornerShape(12.dp), - elevation = 1.dp ) { Column( modifier = Modifier diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index 493ed8786..acd08c27a 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -50,6 +50,7 @@ data class AlbumsScreen( listOf( ScreenAction.Static( title = { if (state.showText) "隐藏专辑名" else "显示专辑名" }, + color = { Color(0xFF6E4AC3) }, icon = { if (state.showText) RemixIcon.Editor.text else RemixIcon.Editor.formatClear }, onAction = { albumsVM.intent(AlbumsAction.ToggleShowText) } ), From 3e63d5572eecfb365a0dd934a5e0d07ca7a75e82 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 28 Dec 2024 14:54:59 +0800 Subject: [PATCH 147/213] =?UTF-8?q?[refactor]=E8=A7=A3=E5=86=B3=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=90=AF=E5=8A=A8=E6=97=B6=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E5=8A=A0=E8=BD=BD=E6=98=BE=E7=A4=BA=E6=9B=B2?= =?UTF-8?q?=E5=BA=93=E5=86=85=E5=AE=B9=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/screen/guiding/PermissionsScreen.kt | 8 ++++++++ lmedia | 2 +- lplayer/src/main/java/com/lalilu/lplayer/Startup.kt | 5 ++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt index 03c51f95a..7d5227238 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -14,6 +15,7 @@ import com.google.accompanist.permissions.rememberPermissionState import com.lalilu.R import com.lalilu.component.base.CustomScreen import com.lalilu.component.base.ScreenInfo +import com.lalilu.lmedia.LMedia import com.lalilu.lmusic.Config.REQUIRE_PERMISSIONS import com.lalilu.lmusic.MainActivity import com.lalilu.lmusic.datastore.SettingsSp @@ -42,6 +44,12 @@ private fun PermissionsPage( var isGuidingOver by settingsSp.isGuidingOver val context = LocalContext.current + LaunchedEffect(permission.status) { + if (permission.status is PermissionStatus.Granted) { + LMedia.init(context) + } + } + Column( modifier = Modifier .padding(horizontal = 20.dp) diff --git a/lmedia b/lmedia index 4d9c6e7e4..81e9ec900 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 4d9c6e7e44444e88a0ec857bc12b11b16652860d +Subproject commit 81e9ec90040bc3861c27036c69a6475305035370 diff --git a/lplayer/src/main/java/com/lalilu/lplayer/Startup.kt b/lplayer/src/main/java/com/lalilu/lplayer/Startup.kt index 6c5283607..1e67d59d6 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/Startup.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/Startup.kt @@ -2,11 +2,14 @@ package com.lalilu.lplayer import android.content.Context import androidx.startup.Initializer +import com.lalilu.lmedia.LMedia class Startup : Initializer { override fun create(context: Context) { - MPlayer.init() + LMedia.whenReady { + MPlayer.init() + } } override fun dependencies(): MutableList>> { From 4b5267f83d6007203befc96d3b9286d13ef694a4 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 29 Dec 2024 14:05:41 +0800 Subject: [PATCH 148/213] =?UTF-8?q?[fix]=E8=A7=A3=E5=86=B3=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E5=8A=A8=E7=94=BB=E7=9A=84=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E5=AF=BC=E8=87=B4viewModel=E5=BC=82=E5=B8=B8=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/lalilu/component/extension/ComposeExt.kt | 4 ++-- .../java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt | 8 ++++++-- .../main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt | 9 +++++++-- .../java/com/lalilu/lartist/screen/ArtistDetailScreen.kt | 8 ++++++-- .../lplaylist/screen/detail/PlaylistDetailScreen.kt | 2 ++ 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt index 2bae1f533..92abad659 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt @@ -276,9 +276,9 @@ inline fun Screen.screenVM( qualifier: Qualifier? = null, viewModelStoreOwner: ViewModelStoreOwner = checkNotNull( value = getScreenViewModelStoreOwner() ?: LocalViewModelStoreOwner.current, - lazyMessage = { "No Registered ViewModelStoreOwner was provided via registerMap for ${T::class.java}" } + lazyMessage = { "No ViewModelStoreOwner was provided for ${T::class.java}" } ), - key: String? = null, + key: String? = this.key, extras: CreationExtras = defaultExtras(viewModelStoreOwner), scope: Scope = currentKoinScope(), noinline parameters: ParametersDefinition? = null, diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt index 3f2495f79..b17bbd01b 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt @@ -51,7 +51,9 @@ data class AlbumDetailScreen( @Composable override fun provideScreenActions(): List { - val vm = screenVM() + val vm = screenVM( + parameters = { parametersOf(albumId) } + ) val state by vm.state return remember { @@ -97,7 +99,9 @@ data class AlbumDetailScreen( @Composable override fun Content() { - val vm = screenVM(parameters = { parametersOf(albumId) }) + val vm = screenVM( + parameters = { parametersOf(albumId) } + ) val songs by vm.songs val state by vm.state val album by vm.album diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index acd08c27a..6f2ff7406 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -28,6 +28,7 @@ import com.lalilu.remixicon.editor.text import com.lalilu.remixicon.media.albumFill import com.lalilu.remixicon.system.menuSearchLine import com.zhangke.krouter.annotation.Destination +import org.koin.core.parameter.parametersOf @Destination("/pages/albums") data class AlbumsScreen( @@ -43,7 +44,9 @@ data class AlbumsScreen( @Composable override fun provideScreenActions(): List { - val albumsVM = screenVM() + val albumsVM = screenVM( + parameters = { parametersOf(albumsId) } + ) val state by albumsVM.state return remember { @@ -83,7 +86,9 @@ data class AlbumsScreen( @Composable override fun Content() { - val vm = screenVM() + val vm = screenVM( + parameters = { parametersOf(albumsId) } + ) val state by vm.state val albums by vm.albums diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt index db4b60090..a1d51e3d3 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt @@ -53,7 +53,9 @@ data class ArtistDetailScreen( @Composable override fun provideScreenActions(): List { - val vm = screenVM() + val vm = screenVM( + parameters = { parametersOf(artistName) } + ) val state by vm.state return remember { @@ -99,7 +101,9 @@ data class ArtistDetailScreen( @Composable override fun Content() { - val vm = screenVM(parameters = { parametersOf(artistName) }) + val vm = screenVM( + parameters = { parametersOf(artistName) } + ) val songs by vm.songs val state by vm.state val artist by vm.artist diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt index 26020b348..5ffe330f8 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey import com.lalilu.RemixIcon import com.lalilu.common.ext.requestFor import com.lalilu.component.base.screen.ScreenAction @@ -41,6 +42,7 @@ import org.koin.core.qualifier.named data class PlaylistDetailScreen( val playlistId: String ) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { + override val key: ScreenKey = "${super.key}:$playlistId" @Composable override fun provideScreenInfo(): ScreenInfo = remember { From 6a1ae8c972a335ae44b71db07b0c72964f73c3cc Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 29 Dec 2024 21:48:43 +0800 Subject: [PATCH 149/213] =?UTF-8?q?[refactor]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=92=AD=E6=94=BE=E6=95=B0=E6=8D=AE=E7=9A=84=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lhistory/HistoryAnalyticsListener.kt | 155 ++++++++++++++++++ .../{ExtendSortRule.kt => SortActions.kt} | 1 + .../com/lalilu/lhistory/entity/LHistory.kt | 18 +- .../lalilu/lhistory/repository/HistoryDao.kt | 28 ++-- .../lhistory/repository/HistoryRepository.kt | 8 +- .../repository/HistoryRepositoryImpl.kt | 21 +-- .../lalilu/lhistory/screen/HistoryScreen.kt | 57 +++---- .../com/lalilu/lplayer/service/MService.kt | 5 + 8 files changed, 206 insertions(+), 87 deletions(-) create mode 100644 lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt rename lhistory/src/main/java/com/lalilu/lhistory/{ExtendSortRule.kt => SortActions.kt} (99%) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt new file mode 100644 index 000000000..a218b0211 --- /dev/null +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt @@ -0,0 +1,155 @@ +package com.lalilu.lhistory + +import android.os.Handler +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.analytics.AnalyticsListener +import com.lalilu.lhistory.entity.LHistory +import com.lalilu.lhistory.repository.HistoryRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single + +@Single +@OptIn(UnstableApi::class) +@Named("history_analytics_listener") +class HistoryAnalyticsListener( + private val historyRepo: HistoryRepository +) : AnalyticsListener { + private val scope = CoroutineScope(Dispatchers.IO) + SupervisorJob() + private var playingItem: PlayingItemHandler? = null + private val handler = Handler(Looper.getMainLooper()) + + init { + loopUpdate() + } + + fun loopUpdate() { + saveOldPlayingItem(force = true) + handler.postDelayed(::loopUpdate, 5000L) + } + + override fun onMediaItemTransition( + eventTime: AnalyticsListener.EventTime, + mediaItem: MediaItem?, + reason: Int + ) { + val mediaId = mediaItem?.mediaId ?: return + + when { + playingItem == null -> { + setNewPlayingItem( + mediaId = mediaId, + title = mediaItem.mediaMetadata.title.toString() + ) + } + + playingItem?.mediaId != mediaId -> { + saveOldPlayingItem() + setNewPlayingItem( + mediaId = mediaId, + title = mediaItem.mediaMetadata.title.toString(), + isPlaying = reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO + ) + } + + reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> { + playingItem?.updateRepeatCount(1) + saveOldPlayingItem() + } + } + } + + override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { + if (playingItem == null) return + + playingItem?.updateIsPlaying(isPlaying) + if (!isPlaying) { + saveOldPlayingItem() + } + } + + private fun saveOldPlayingItem(force: Boolean = false) { + val item = playingItem ?: return + if (force) { + item.tryUpdateDuration() + } else { + if (item.isPlaying) item.updateIsPlaying(false) + } + + scope.launch { + historyRepo.updateHistory( + id = item.primaryKey, + duration = item.duration, + repeatCount = item.repeatCount + ) + } + } + + private fun setNewPlayingItem( + mediaId: String, + title: String, + isPlaying: Boolean = false + ) = scope.launch(Dispatchers.Main.immediate) { + val startTime = System.currentTimeMillis() + val primaryKey = historyRepo.preSaveHistory( + LHistory( + contentId = mediaId, + contentTitle = title, + startTime = startTime, + duration = -1, + ) + ) + + playingItem = PlayingItemHandler( + primaryKey = primaryKey, + mediaId = mediaId, + startTime = startTime + ).apply { + updateIsPlaying(isPlaying) + } + } +} + +private class PlayingItemHandler( + val primaryKey: Long, + val mediaId: String, + val startTime: Long = System.currentTimeMillis(), +) { + var lastPlayTime = startTime + private set + var isPlaying: Boolean = false + private set + var duration: Long = 0 + private set + var repeatCount: Int = 0 + private set + + fun updateRepeatCount(repeatCount: Int) { + this.repeatCount += repeatCount + } + + fun updateIsPlaying(isPlaying: Boolean) { + if (isPlaying) { + lastPlayTime = System.currentTimeMillis() + } else { + duration += System.currentTimeMillis() - lastPlayTime + } + this.isPlaying = isPlaying + } + + fun tryUpdateDuration() { + if (!isPlaying) return + + val now = System.currentTimeMillis() + duration += now - lastPlayTime + lastPlayTime = now + } +} \ No newline at end of file diff --git a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt b/lhistory/src/main/java/com/lalilu/lhistory/SortActions.kt similarity index 99% rename from lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt rename to lhistory/src/main/java/com/lalilu/lhistory/SortActions.kt index 3ba53142d..58c8ec82f 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/SortActions.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.combine import org.koin.core.annotation.Named import org.koin.core.annotation.Single + @Named("sort_rule_play_count") @Single(binds = [ListAction::class]) class SortRulePlayCount( diff --git a/lhistory/src/main/java/com/lalilu/lhistory/entity/LHistory.kt b/lhistory/src/main/java/com/lalilu/lhistory/entity/LHistory.kt index 74f57f90c..18e025908 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/entity/LHistory.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/entity/LHistory.kt @@ -1,18 +1,8 @@ package com.lalilu.lhistory.entity -import androidx.annotation.IntDef -import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -const val HISTORY_TYPE_SONG = 0 -const val HISTORY_TYPE_ALBUM = 1 -const val HISTORY_TYPE_PLAYLIST = 2 -const val HISTORY_TYPE_ARTIST = 3 - -@IntDef(HISTORY_TYPE_SONG, HISTORY_TYPE_ALBUM, HISTORY_TYPE_PLAYLIST, HISTORY_TYPE_ARTIST) -@Retention(AnnotationRetention.SOURCE) -annotation class HistoryType @Entity(tableName = "m_history") data class LHistory( @@ -20,13 +10,13 @@ data class LHistory( val id: Long = 0L, val contentId: String, + val contentTitle: String, + val parentId: String = "", + val parentTitle: String = "", // 数据库层面0为正常值,而-1代表预保存记录,即会被清除的记录 - @ColumnInfo(name = "duration", defaultValue = "0") val duration: Long = -1L, + val repeatCount: Int = 0, val startTime: Long = System.currentTimeMillis(), - - @HistoryType - val type: Int ) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt index 129f501f8..64b553a4a 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt @@ -1,27 +1,25 @@ package com.lalilu.lhistory.repository -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.MapInfo +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update import com.lalilu.lhistory.entity.LHistory import kotlinx.coroutines.flow.Flow @Dao interface HistoryDao { - @Transaction @Insert(onConflict = OnConflictStrategy.REPLACE) - fun save(vararg history: LHistory) - - @Transaction - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun save(history: List) + fun save(history: LHistory): Long @Update(entity = LHistory::class) fun update(vararg history: LHistory) - @Query("UPDATE m_history SET duration = :duration WHERE contentId = :contentId AND duration = -1;") - fun updatePreSavedHistory(contentId: String, duration: Long) - - @Query("DELETE FROM m_history WHERE contentId = :contentId AND duration = -1;") - fun deletePreSavedHistory(contentId: String) + @Query("UPDATE m_history SET duration = :duration, repeatCount = :repeatCount WHERE id = :id;") + fun updateHistory(id: Long, duration: Long, repeatCount: Int) @Query("DELETE FROM m_history;") fun clear() @@ -40,7 +38,7 @@ interface HistoryDao { */ @Query( "SELECT * FROM " + - "(SELECT id, contentId, duration, type, max(startTime) as 'startTime' FROM m_history GROUP BY contentId) as A " + + "(SELECT id, contentId, contentTitle, parentId, parentTitle, duration, repeatCount, max(startTime) as 'startTime' FROM m_history GROUP BY contentId) as A " + "ORDER BY A.startTime DESC LIMIT :limit;" ) fun getFlow(limit: Int): Flow> @@ -51,14 +49,14 @@ interface HistoryDao { @MapInfo(valueColumn = "count") @Query( "SELECT * FROM " + - "(SELECT id, contentId, duration, type, count(contentId) as 'count', max(startTime) as 'startTime' FROM m_history GROUP BY contentId) as A " + + "(SELECT id, contentId, contentTitle, parentId, parentTitle, duration, repeatCount, (count(contentId) + repeatCount) as 'count', max(startTime) as 'startTime' FROM m_history GROUP BY contentId) as A " + "ORDER BY A.startTime DESC LIMIT :limit;" ) fun getFlowWithCount(limit: Int): Flow> @MapInfo(keyColumn = "contentId", valueColumn = "count") @Query( - "SELECT contentId, count(contentId) as 'count' FROM m_history GROUP BY contentId " + + "SELECT contentId, (count(contentId) + repeatCount) as 'count' FROM m_history GROUP BY contentId " + "LIMIT :limit;" ) fun getFlowIdsMapWithCount(limit: Int): Flow> diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt index 37dbc50da..5db504090 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt @@ -4,12 +4,8 @@ import com.lalilu.lhistory.entity.LHistory import kotlinx.coroutines.flow.Flow interface HistoryRepository { - fun saveHistory(vararg history: LHistory) - fun saveHistories(history: List) - - fun preSaveHistory(history: LHistory) - fun updatePreSavedHistory(contentId: String, duration: Long) - fun removePreSavedHistory(contentId: String) + suspend fun preSaveHistory(history: LHistory): Long + suspend fun updateHistory(id: Long, duration: Long, repeatCount: Int) fun clearHistories() fun getHistoriesFlow(limit: Int): Flow> diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt index c00471891..4bb9b348c 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import kotlin.coroutines.CoroutineContext @@ -29,24 +30,12 @@ class HistoryRepositoryImpl( .toCachedFlow() .also { it.launchIn(this) } - override fun saveHistory(vararg history: LHistory) { - launch { historyDao.save(history.toList()) } + override suspend fun preSaveHistory(history: LHistory): Long = withContext(Dispatchers.IO) { + historyDao.save(history.copy(duration = -1L)) } - override fun saveHistories(history: List) { - launch { historyDao.save(history) } - } - - override fun preSaveHistory(history: LHistory) { - launch { historyDao.save(history.copy(duration = -1L)) } - } - - override fun updatePreSavedHistory(contentId: String, duration: Long) { - launch { historyDao.updatePreSavedHistory(contentId, duration) } - } - - override fun removePreSavedHistory(contentId: String) { - launch { historyDao.deletePreSavedHistory(contentId) } + override suspend fun updateHistory(id: Long, duration: Long, repeatCount: Int) { + historyDao.updateHistory(id = id, duration = duration, repeatCount = repeatCount) } override fun clearHistories() { diff --git a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt index 70996be57..c2f2cc709 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt @@ -1,51 +1,36 @@ package com.lalilu.lhistory.screen import androidx.compose.runtime.Composable -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.koin.getScreenModel -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.ScreenInfo -import com.lalilu.component.base.collectAsLoadingState -import com.lalilu.lhistory.R -import com.lalilu.lhistory.repository.HistoryRepository -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.mapLatest -import org.koin.core.annotation.Factory -import com.lalilu.component.R as ComponentR +import androidx.compose.runtime.remember +import cafe.adriel.voyager.core.screen.Screen +import com.lalilu.RemixIcon +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.remixicon.Editor +import com.lalilu.remixicon.editor.draggable -data object HistoryScreen : DynamicScreen() { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.history_screen_title, - icon = ComponentR.drawable.ic_play_list_fill - ) +data object HistoryScreen : Screen, ScreenInfoFactory { + private fun readResolve(): Any = HistoryScreen @Composable - override fun Content() { - val historySM = getScreenModel() - - HistoryScreen(historySM = historySM) + override fun provideScreenInfo(): ScreenInfo { + return remember { + ScreenInfo( + title = { "History" }, + icon = RemixIcon.Editor.draggable + ) + } } -} -@Factory -class HistoryScreenModel( - historyRepo: HistoryRepository -) : ScreenModel { - @OptIn(ExperimentalCoroutinesApi::class) - val mediaIds = historyRepo - .getHistoriesIdsMapWithLastTime() - .mapLatest { map -> - map.toList() - .sortedByDescending { it.second } - .map { it.first } - } + @Composable + override fun Content() { + HistoryScreenContent() + } } @Composable -private fun DynamicScreen.HistoryScreen( - historySM: HistoryScreenModel +private fun HistoryScreenContent( ) { - val mediaIdsState = historySM.mediaIds.collectAsLoadingState() // LoadingScaffold( // modifier = Modifier.fillMaxSize(), diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index 60afaacc3..e714ffd5c 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -12,6 +12,7 @@ import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.LibraryParams @@ -40,11 +41,14 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject +import org.koin.core.qualifier.named import kotlin.coroutines.CoroutineContext @OptIn(UnstableApi::class) class MService : MediaLibraryService(), CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() + private val historyAnalyticsListener by inject(named("history_analytics_listener")) private var exoPlayer: Player? = null private var mediaSession: MediaLibrarySession? = null @@ -69,6 +73,7 @@ class MService : MediaLibraryService(), CoroutineScope { .setHandleAudioBecomingNoisy(MPlayerKV.handleBecomeNoisy.value != false) .setAudioAttributes(defaultAudioAttributes, MPlayerKV.handleAudioFocus.value != false) .build() + .apply { addAnalyticsListener(historyAnalyticsListener) } .setUpQueueControl() mediaSession = MediaLibrarySession From 78e9074c7dac5a488dc56b1023826cff85cceb33 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 30 Dec 2024 00:40:11 +0800 Subject: [PATCH 150/213] =?UTF-8?q?[refactor]=E8=BD=AC=E7=A7=BBHistory?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6=E8=87=B3=E5=AF=B9=E5=BA=94?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/home/HomeScreenContent.kt | 11 +++-- .../java/com/lalilu/lhistory}/HistoryPanel.kt | 45 +++++-------------- .../lalilu/lhistory/screen/HistoryScreen.kt | 23 +++------- .../lalilu/lhistory/viewmodel/HistoryVM.kt | 16 ++----- 4 files changed, 30 insertions(+), 65 deletions(-) rename {app/src/main/java/com/lalilu/lmusic/extension => lhistory/src/main/java/com/lalilu/lhistory}/HistoryPanel.kt (60%) rename app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt => lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt (70%) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt index 6ff2633f4..c9883db52 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt @@ -8,14 +8,17 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.lalilu.common.ext.requestFor +import com.lalilu.component.LazyGridContent import com.lalilu.component.base.LocalSmartBarPadding import com.lalilu.component.divider import com.lalilu.lmusic.extension.DailyRecommend import com.lalilu.lmusic.extension.EntryPanel -import com.lalilu.lmusic.extension.HistoryPanel import com.lalilu.lmusic.extension.LatestPanel +import org.koin.core.qualifier.named @Composable fun HomeScreenContent( @@ -25,8 +28,10 @@ fun HomeScreenContent( val dailyRecommend = DailyRecommend.register() val entryPanel = EntryPanel.register() - val historyPanel = HistoryPanel.register() val latestPanel = LatestPanel.register() + val historyPanel = remember { + requestFor(named("history_panel")) + }?.register() LazyVerticalGrid( modifier = modifier, @@ -37,7 +42,7 @@ fun HomeScreenContent( latestPanel(this) - historyPanel(this) + historyPanel?.invoke(this) entryPanel(this) diff --git a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt similarity index 60% rename from app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt rename to lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt index 4d8761769..dcfaac150 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt @@ -1,15 +1,12 @@ -package com.lalilu.lmusic.extension +package com.lalilu.lhistory import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material.Chip -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback @@ -18,42 +15,28 @@ import com.lalilu.component.LazyGridContent import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.card.SongCard import com.lalilu.component.navigation.AppRouter -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.compose.component.card.RecommendTitle -import com.lalilu.lmusic.viewmodel.HistoryViewModel -import com.lalilu.lmusic.viewmodel.PlayingViewModel +import com.lalilu.lhistory.viewmodel.HistoryVM import com.lalilu.lplayer.MPlayer import org.koin.compose.koinInject +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single -object HistoryPanel : LazyGridContent { - @OptIn(ExperimentalMaterialApi::class) +@Named("history_panel") +@Single(binds = [LazyGridContent::class]) +class HistoryPanel : LazyGridContent { + @Composable override fun register(): LazyGridScope.() -> Unit { - val historyVM: HistoryViewModel = koinInject() - val playingVM: PlayingViewModel = koinInject() + val historyVM = koinInject() val widthSizeClass = LocalWindowSize.current.widthSizeClass val haptic = LocalHapticFeedback.current - val items = historyVM.historyState.value + val items by historyVM.historyState return fun LazyGridScope.() { // 若列表为空,则不显示 if (items.isEmpty()) return - item(span = { GridItemSpan(maxLineSpan) }) { - RecommendTitle( - title = "最近播放", - onClick = { } - ) { - Chip( - onClick = { // navigator.navigate(HistoryScreenDestination) - }, - ) { - Text(style = MaterialTheme.typography.caption, text = "历史记录") - } - } - } - items( items = items, key = { it.id }, @@ -70,11 +53,7 @@ object HistoryPanel : LazyGridContent { song = { item }, isPlaying = { MPlayer.isItemPlaying(item.id) }, onClick = { - playingVM.play( - mediaId = item.id, - mediaIds = items.map(LSong::id), - playOrPause = true - ) + }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt index c2f2cc709..682941f00 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt @@ -1,7 +1,10 @@ package com.lalilu.lhistory.screen +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import cafe.adriel.voyager.core.screen.Screen import com.lalilu.RemixIcon import com.lalilu.component.base.screen.ScreenInfo @@ -31,21 +34,9 @@ data object HistoryScreen : Screen, ScreenInfoFactory { @Composable private fun HistoryScreenContent( ) { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { -// LoadingScaffold( -// modifier = Modifier.fillMaxSize(), -// targetState = mediaIdsState -// ) { mediaIds -> -// Songs( -// modifier = Modifier.fillMaxSize(), -// mediaIds = mediaIds, -// supportListAction = { listOf() }, -// headerContent = { -// item { -// NavigatorHeader(title = stringResource(id = R.string.history_screen_title)) -// } -// }, -// footerContent = {} -// ) -// } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt similarity index 70% rename from app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt rename to lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt index 945acdb75..04951c2ba 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.viewmodel +package com.lalilu.lhistory.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -11,9 +11,9 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single -@Single @OptIn(ExperimentalCoroutinesApi::class) -class HistoryViewModel( +@Single +class HistoryVM( private val historyRepo: HistoryRepository ) : ViewModel() { val historyState = historyRepo @@ -26,15 +26,5 @@ class HistoryViewModel( }.map { it.take(6) } .toState(emptyList(), viewModelScope) - private val historyCountState = historyRepo - .getHistoriesIdsMapWithCount() - .toState(emptyMap(), viewModelScope) - - fun requiteHistoryCountById(mediaId: String): Int { - return historyCountState.value[mediaId] ?: 0 - } - fun clearHistories() { - historyRepo.clearHistories() - } } \ No newline at end of file From 93b065cefc2b21348bf247b4007cc6a770faebe1 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 30 Dec 2024 00:51:11 +0800 Subject: [PATCH 151/213] =?UTF-8?q?[fix]=E8=A7=A3=E5=86=B3=E9=94=81?= =?UTF-8?q?=E5=B1=8F=E6=9C=9F=E9=97=B4=E5=88=87=E6=8D=A2=E6=AD=8C=E6=9B=B2?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E6=92=AD=E6=94=BE=E5=88=97=E8=A1=A8=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=BC=82=E5=B8=B8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lmusic/compose/screen/playing/PlaylistLayout.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 4d6fcf1ca..f94967aa0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +39,7 @@ import com.lalilu.component.navigation.AppRouter import com.lalilu.lmusic.compose.screen.playing.util.DiffUtil import com.lalilu.lmusic.compose.screen.playing.util.ListUpdateCallback import com.lalilu.lplayer.extensions.PlayerAction +import kotlinx.coroutines.launch data class Item( @@ -102,6 +104,7 @@ fun PlaylistLayout( ) { val haptic = LocalHapticFeedback.current val view = LocalView.current + val scope = rememberCoroutineScope() val listState = rememberLazyListState() var actualItems by remember { mutableStateOf(emptyList>()) } @@ -129,7 +132,10 @@ fun PlaylistLayout( if (isNewListTopVisible || isOldListTopVisible || forceRefresh()) { actualItems = emptyList() - view.post { actualItems = newList } + view.post { + actualItems = newList + scope.launch { listState.animateScrollToItem(0) } + } } else { actualItems = newList } From 13438e2a8936bb2fc3450fd1ca858bd123249a06 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 Jan 2025 12:05:09 +0800 Subject: [PATCH 152/213] =?UTF-8?q?[modify]=E6=B7=BB=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=8A=A0=E8=BD=BD=E5=8E=86=E5=8F=B2=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 4 + lhistory/build.gradle.kts | 4 + .../lalilu/lhistory/repository/HistoryDao.kt | 5 +- .../lhistory/repository/HistoryRepository.kt | 2 + .../repository/HistoryRepositoryImpl.kt | 5 + .../lalilu/lhistory/screen/HistoryScreen.kt | 95 ++++++++++++++++++- .../lalilu/lhistory/viewmodel/HistoryVM.kt | 2 +- 7 files changed, 112 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 049130dde..c67615eb5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ media = "1.7.0" media3 = "1.5.0" gson = "2.11.0" flyjingfish-aop = "1.9.7" +paging_version = "3.3.5" krouter_version = "0.0.1" @@ -94,7 +95,10 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref room-ktx = { module = "androidx.room:room-ktx", version.ref = "room_version" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room_version" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room_version" } +room-paging = { module = "androidx.room:room-paging", version.ref = "room_version" } media = { module = "androidx.media:media", version.ref = "media" } +paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" } +paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" } # [Apache-2.0 License] 安卓工具类库 https://github.com/Blankj/AndroidUtilCode/ utilcodex = { module = "com.blankj:utilcodex", version.ref = "utilcodex_version" } diff --git a/lhistory/build.gradle.kts b/lhistory/build.gradle.kts index 7c25227e1..9a836c3f8 100644 --- a/lhistory/build.gradle.kts +++ b/lhistory/build.gradle.kts @@ -42,8 +42,12 @@ composeCompiler { dependencies { implementation(libs.room.ktx) implementation(libs.room.runtime) + implementation(libs.room.paging) ksp(libs.room.compiler) + implementation(libs.paging.runtime) + implementation(libs.paging.compose) + implementation(project(":component")) ksp(libs.koin.compiler) } \ No newline at end of file diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt index 64b553a4a..a4bd888f6 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt @@ -1,5 +1,6 @@ package com.lalilu.lhistory.repository +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert @@ -27,8 +28,8 @@ interface HistoryDao { @Delete(entity = LHistory::class) fun delete(vararg history: LHistory) - @Query("SELECT * FROM m_history;") - fun getAll(): List + @Query("SELECT * FROM m_history ORDER BY startTime DESC") + fun getAllData(): PagingSource @Query("SELECT * FROM m_history WHERE id = :id;") fun getById(id: Long): LHistory? diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt index 5db504090..0d24c9930 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt @@ -1,5 +1,6 @@ package com.lalilu.lhistory.repository +import androidx.paging.PagingSource import com.lalilu.lhistory.entity.LHistory import kotlinx.coroutines.flow.Flow @@ -8,6 +9,7 @@ interface HistoryRepository { suspend fun updateHistory(id: Long, duration: Long, repeatCount: Int) fun clearHistories() + fun getAllData(): PagingSource fun getHistoriesFlow(limit: Int): Flow> fun getHistoriesWithCount(limit: Int): Flow> fun getHistoriesIdsMapWithCount(): Flow> diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt index 4bb9b348c..bfbb16961 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.lalilu.lhistory.repository +import androidx.paging.PagingSource import com.lalilu.common.toCachedFlow import com.lalilu.lhistory.entity.LHistory import kotlinx.coroutines.CoroutineScope @@ -42,6 +43,10 @@ class HistoryRepositoryImpl( launch { historyDao.clear() } } + override fun getAllData(): PagingSource { + return historyDao.getAllData() + } + override fun getHistoriesFlow(limit: Int): Flow> { return historyDao .getFlow(limit) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt index 682941f00..7db846e5f 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt @@ -1,16 +1,39 @@ package com.lalilu.lhistory.screen +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.LoadState +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey import cafe.adriel.voyager.core.screen.Screen import com.lalilu.RemixIcon import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.base.smartBarPadding +import com.lalilu.lhistory.entity.LHistory +import com.lalilu.lhistory.viewmodel.HistoryVM import com.lalilu.remixicon.Editor import com.lalilu.remixicon.editor.draggable +import org.koin.compose.koinInject data object HistoryScreen : Screen, ScreenInfoFactory { private fun readResolve(): Any = HistoryScreen @@ -27,16 +50,84 @@ data object HistoryScreen : Screen, ScreenInfoFactory { @Composable override fun Content() { - HistoryScreenContent() + val historyVM = koinInject() + val pager = remember { + Pager( + config = PagingConfig( + pageSize = 20, + enablePlaceholders = true, + ), + pagingSourceFactory = { + historyVM.historyRepo.getAllData() + } + ) + } + + HistoryScreenContent( + pager = pager + ) } } @Composable private fun HistoryScreenContent( + pager: Pager, ) { + val items = pager.flow.collectAsLazyPagingItems() + val listState = rememberLazyListState() + LazyColumn( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + state = listState ) { + item(key = "历史记录") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "历史记录", + fontSize = 20.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + Text( + text = "播放过的歌曲记录", + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + } + + items( + count = items.itemCount, + key = items.itemKey { it.id } + ) { index -> + val item = items[index] + Text( + modifier = Modifier + .animateItem() + .padding(16.dp), + text = "${item?.contentTitle} ${item?.repeatCount}", + fontSize = 14.sp + ) + } + + if (items.loadState.append == LoadState.Loading) { + item { + CircularProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally) + ) + } + } + smartBarPadding() } } \ No newline at end of file diff --git a/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt index 04951c2ba..f4db296c3 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt @@ -14,7 +14,7 @@ import org.koin.core.annotation.Single @OptIn(ExperimentalCoroutinesApi::class) @Single class HistoryVM( - private val historyRepo: HistoryRepository + val historyRepo: HistoryRepository ) : ViewModel() { val historyState = historyRepo .getHistoriesIdsMapWithLastTime() From 2e71f5b3ece0888858aa03615315af6a0b9f0a88 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 Jan 2025 13:43:12 +0800 Subject: [PATCH 153/213] =?UTF-8?q?[modify]=E6=B7=BB=E5=8A=A0=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E7=9A=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E6=97=A0=E7=94=A8=E7=9A=84=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lhistory/HistoryAnalyticsListener.kt | 6 ++++-- .../lalilu/lhistory/repository/HistoryDao.kt | 7 +++++-- .../lhistory/repository/HistoryRepository.kt | 3 ++- .../repository/HistoryRepositoryImpl.kt | 20 +++++++++++++++++-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt index a218b0211..82661583b 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt @@ -88,7 +88,8 @@ class HistoryAnalyticsListener( historyRepo.updateHistory( id = item.primaryKey, duration = item.duration, - repeatCount = item.repeatCount + repeatCount = item.repeatCount, + startTime = item.startTime ) } } @@ -99,7 +100,8 @@ class HistoryAnalyticsListener( isPlaying: Boolean = false ) = scope.launch(Dispatchers.Main.immediate) { val startTime = System.currentTimeMillis() - val primaryKey = historyRepo.preSaveHistory( + val unUsedHistory = historyRepo.getUnUsedPreSaveHistory(mediaId) + val primaryKey = unUsedHistory?.id ?: historyRepo.preSaveHistory( LHistory( contentId = mediaId, contentTitle = title, diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt index a4bd888f6..728bb5279 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryDao.kt @@ -19,8 +19,8 @@ interface HistoryDao { @Update(entity = LHistory::class) fun update(vararg history: LHistory) - @Query("UPDATE m_history SET duration = :duration, repeatCount = :repeatCount WHERE id = :id;") - fun updateHistory(id: Long, duration: Long, repeatCount: Int) + @Query("UPDATE m_history SET duration = :duration, repeatCount = :repeatCount, startTime = :startTime WHERE id = :id;") + fun updateHistory(id: Long, duration: Long, repeatCount: Int, startTime: Long) @Query("DELETE FROM m_history;") fun clear() @@ -34,6 +34,9 @@ interface HistoryDao { @Query("SELECT * FROM m_history WHERE id = :id;") fun getById(id: Long): LHistory? + @Query("SELECT * FROM m_history ORDER BY id DESC LIMIT 1") + fun getLatestHistory(): LHistory? + /** * 查询播放历史,去除重复的记录,只保留最近的一条,按照最近播放时间排序 */ diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt index 0d24c9930..5d1f999b9 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt @@ -5,8 +5,9 @@ import com.lalilu.lhistory.entity.LHistory import kotlinx.coroutines.flow.Flow interface HistoryRepository { + suspend fun getUnUsedPreSaveHistory(mediaId: String): LHistory? suspend fun preSaveHistory(history: LHistory): Long - suspend fun updateHistory(id: Long, duration: Long, repeatCount: Int) + suspend fun updateHistory(id: Long, duration: Long, repeatCount: Int, startTime: Long) fun clearHistories() fun getAllData(): PagingSource diff --git a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt index bfbb16961..af42e6391 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepositoryImpl.kt @@ -31,12 +31,28 @@ class HistoryRepositoryImpl( .toCachedFlow() .also { it.launchIn(this) } + override suspend fun getUnUsedPreSaveHistory(mediaId: String): LHistory? = + withContext(Dispatchers.IO) { + historyDao.getLatestHistory() + ?.takeIf { it.contentId == mediaId && it.duration <= 1000L } + } + override suspend fun preSaveHistory(history: LHistory): Long = withContext(Dispatchers.IO) { historyDao.save(history.copy(duration = -1L)) } - override suspend fun updateHistory(id: Long, duration: Long, repeatCount: Int) { - historyDao.updateHistory(id = id, duration = duration, repeatCount = repeatCount) + override suspend fun updateHistory( + id: Long, + duration: Long, + repeatCount: Int, + startTime: Long + ) { + historyDao.updateHistory( + id = id, + duration = duration, + repeatCount = repeatCount, + startTime = startTime + ) } override fun clearHistories() { From 0d8b147eeb39d51c7475a127aed6743e9297c007 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 Jan 2025 13:43:51 +0800 Subject: [PATCH 154/213] =?UTF-8?q?[modify]=E5=88=9D=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- component/build.gradle.kts | 2 + gradle/libs.versions.toml | 3 + .../lhistory/component/HistoryItemCard.kt | 172 ++++++++++++++++++ .../lalilu/lhistory/screen/HistoryScreen.kt | 43 +++-- .../lalilu/lhistory/viewmodel/HistoryVM.kt | 12 ++ 5 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 lhistory/src/main/java/com/lalilu/lhistory/component/HistoryItemCard.kt diff --git a/component/build.gradle.kts b/component/build.gradle.kts index c35d703dc..e856321da 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -47,6 +47,8 @@ dependencies { api(libs.bundles.voyager) api(libs.bundles.coil3) api(libs.lottie.compose) + api(libs.human.readable) + api(libs.kotlinx.datetime) api(project(":lmedia")) api(project(":common")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c67615eb5..3c3feaef7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ krouter_version = "0.0.1" kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.3" } kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version = "1.9.0" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.1" } # compose compose-bom-alpha = { module = "androidx.compose:compose-bom-alpha", version.ref = "compose_bom_alpha_version" } @@ -113,6 +114,8 @@ flyjingfish-aop-ksp = { module = "io.github.FlyJingFish.AndroidAop:android-aop-k krouter-core = { module = "com.github.cy745.KRouter:core", version.ref = "krouter_version" } krouter-plugin = { module = "com.github.cy745.KRouter:plugin", version.ref = "krouter_version" } +human-readable = { module = "nl.jacobras:Human-Readable", version = "1.10.0" } + [plugins] application = { id = "com.android.application", version.ref = "agp_version" } library = { id = "com.android.library", version.ref = "agp_version" } diff --git a/lhistory/src/main/java/com/lalilu/lhistory/component/HistoryItemCard.kt b/lhistory/src/main/java/com/lalilu/lhistory/component/HistoryItemCard.kt new file mode 100644 index 000000000..942c5813b --- /dev/null +++ b/lhistory/src/main/java/com/lalilu/lhistory/component/HistoryItemCard.kt @@ -0,0 +1,172 @@ +package com.lalilu.lhistory.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.request.error +import coil3.request.placeholder +import com.lalilu.RemixIcon +import com.lalilu.component.R +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.media.repeatLine +import kotlinx.datetime.Instant +import nl.jacobras.humanreadable.HumanReadable +import kotlin.time.DurationUnit +import kotlin.time.toDuration + + +@Preview +@Composable +fun HistoryItemCard( + modifier: Modifier = Modifier, + title: () -> String = { "title" }, + imageData: () -> Any? = { null }, + startTime: () -> Long = { System.currentTimeMillis() }, + repeatCount: () -> Int = { 0 }, + duration: () -> Long = { 0 }, + onClick: () -> Unit = {} +) { + val context = LocalContext.current + val data = remember(imageData()) { + ImageRequest.Builder(context) + .data(imageData()) + .placeholder(R.drawable.ic_music_line_bg_64dp) + .error(R.drawable.ic_music_line_bg_64dp) + .crossfade(true) + .build() + } + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + modifier = Modifier, + text = title(), + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.onBackground, + fontSize = 14.sp + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = repeatCount() > 0, + enter = fadeIn() + expandHorizontally(clip = false), + exit = fadeOut() + shrinkHorizontally(clip = false) + ) { + Row( + modifier = Modifier + .padding(end = 8.dp) + .clip(RoundedCornerShape(50)) + .background(color = MaterialTheme.colors.onBackground.copy(0.1f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size(10.dp), + imageVector = RemixIcon.Media.repeatLine, + contentDescription = null, + tint = MaterialTheme.colors.onBackground, + ) + + Text( + modifier = Modifier, + text = "${repeatCount()}", + color = MaterialTheme.colors.onBackground, + fontSize = 10.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Bold + ) + } + } + + val timeAgo = remember(startTime()) { + HumanReadable.timeAgo(Instant.fromEpochMilliseconds(startTime())) + } + Text( + modifier = Modifier + .weight(1f), + text = timeAgo, + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 12.sp, + lineHeight = 18.sp + ) + + AnimatedVisibility( + visible = duration() > 1000, + enter = fadeIn() + expandHorizontally(clip = false), + exit = fadeOut() + shrinkHorizontally(clip = false) + ) { + val durationStr = remember(duration()) { + HumanReadable.duration(duration().toDuration(DurationUnit.MILLISECONDS)) + } + Text( + modifier = Modifier + .padding(start = 8.dp), + text = durationStr, + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 12.sp, + lineHeight = 12.sp + ) + } + } + } + + Surface( + modifier = modifier, + elevation = 2.dp, + shape = RoundedCornerShape(5.dp) + ) { + AsyncImage( + modifier = Modifier + .size(60.dp) + .aspectRatio(1f), + model = data, + contentScale = ContentScale.Crop, + contentDescription = "Song Card Image" + ) + } + } +} \ No newline at end of file diff --git a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt index 7db846e5f..692abc80b 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt @@ -20,8 +20,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.paging.LoadState -import androidx.paging.Pager -import androidx.paging.PagingConfig +import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import cafe.adriel.voyager.core.screen.Screen @@ -29,8 +28,12 @@ import com.lalilu.RemixIcon import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.base.smartBarPadding +import com.lalilu.component.navigation.AppRouter +import com.lalilu.lhistory.component.HistoryItemCard import com.lalilu.lhistory.entity.LHistory import com.lalilu.lhistory.viewmodel.HistoryVM +import com.lalilu.lmedia.LMedia +import com.lalilu.lmedia.entity.LSong import com.lalilu.remixicon.Editor import com.lalilu.remixicon.editor.draggable import org.koin.compose.koinInject @@ -51,29 +54,18 @@ data object HistoryScreen : Screen, ScreenInfoFactory { @Composable override fun Content() { val historyVM = koinInject() - val pager = remember { - Pager( - config = PagingConfig( - pageSize = 20, - enablePlaceholders = true, - ), - pagingSourceFactory = { - historyVM.historyRepo.getAllData() - } - ) - } + val items = historyVM.pager.collectAsLazyPagingItems() HistoryScreenContent( - pager = pager + items = items ) } } @Composable private fun HistoryScreenContent( - pager: Pager, + items: LazyPagingItems ) { - val items = pager.flow.collectAsLazyPagingItems() val listState = rememberLazyListState() LazyColumn( @@ -109,12 +101,19 @@ private fun HistoryScreenContent( key = items.itemKey { it.id } ) { index -> val item = items[index] - Text( - modifier = Modifier - .animateItem() - .padding(16.dp), - text = "${item?.contentTitle} ${item?.repeatCount}", - fontSize = 14.sp + + HistoryItemCard( + modifier = Modifier.animateItem(), + imageData = { LMedia.get(item?.contentId) }, + title = { item?.contentTitle ?: "" }, + startTime = { item?.startTime ?: System.currentTimeMillis() }, + duration = { item?.duration ?: 0 }, + repeatCount = { item?.repeatCount ?: 0 }, + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", item?.contentId) + .jump() + } ) } diff --git a/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt index f4db296c3..a26855f7e 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt @@ -2,6 +2,9 @@ package com.lalilu.lhistory.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn import com.lalilu.component.extension.toState import com.lalilu.lhistory.repository.HistoryRepository import com.lalilu.lmedia.LMedia @@ -26,5 +29,14 @@ class HistoryVM( }.map { it.take(6) } .toState(emptyList(), viewModelScope) + val pager = Pager( + config = PagingConfig( + pageSize = 20, + enablePlaceholders = true, + ), + pagingSourceFactory = { + historyRepo.getAllData() + } + ).flow.cachedIn(viewModelScope) } \ No newline at end of file From 4596ecc84a0628da8e8a8587937f3a563cc19419 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 Jan 2025 18:43:29 +0800 Subject: [PATCH 155/213] =?UTF-8?q?[modify]=E6=9B=B4=E6=96=B0KRouter?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 9 +++------ gradle/libs.versions.toml | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 026039ee5..c5f8f20b2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,11 +6,8 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.flyjingfish.aop) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.krouter.plugin) } -buildscript { - dependencies { classpath(libs.krouter.plugin) } -} - -ext { set("targetInjectProjectName", "app") } -apply(plugin = "krouter-plugin") +// 配置注入遍历的起点项目 +ext { set("targetInjectProjectName", "app") } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c3feaef7..3d491061a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,8 +30,7 @@ media3 = "1.5.0" gson = "2.11.0" flyjingfish-aop = "1.9.7" paging_version = "3.3.5" - -krouter_version = "0.0.1" +krouter_version = "0.0.3" [libraries] # kotlin @@ -110,10 +109,7 @@ flyjingfish-aop-core = { module = "io.github.FlyJingFish.AndroidAop:android-aop- flyjingfish-aop-annotation = { module = "io.github.FlyJingFish.AndroidAop:android-aop-annotation", version.ref = "flyjingfish-aop" } flyjingfish-aop-ksp = { module = "io.github.FlyJingFish.AndroidAop:android-aop-ksp", version.ref = "flyjingfish-aop" } -# KRouter -krouter-core = { module = "com.github.cy745.KRouter:core", version.ref = "krouter_version" } -krouter-plugin = { module = "com.github.cy745.KRouter:plugin", version.ref = "krouter_version" } - +krouter-core = { module = "io.github.cy745.KRouter:core", version.ref = "krouter_version" } human-readable = { module = "nl.jacobras:Human-Readable", version = "1.10.0" } [plugins] @@ -124,6 +120,7 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi ksp = { id = "com.google.devtools.ksp", version.ref = "ksp_version" } flyjingfish-aop = { id = "io.github.FlyJingFish.AndroidAop.android-aop", version.ref = "flyjingfish-aop" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin_version" } +krouter-plugin = { id = "io.github.cy745.KRouter.plugin", version.ref = "krouter_version" } [bundles] From a839771109787ec314a9100e2dbfef4b65aa2985 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 Jan 2025 21:50:38 +0800 Subject: [PATCH 156/213] =?UTF-8?q?[modify]=E6=9B=B4=E6=96=B0RemixIcon?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- component/build.gradle.kts | 2 +- gradle/libs.versions.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/component/build.gradle.kts b/component/build.gradle.kts index e856321da..f008abae5 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { api(libs.lottie.compose) api(libs.human.readable) api(libs.kotlinx.datetime) + api(libs.remixicon.kmp) api(project(":lmedia")) api(project(":common")) @@ -60,7 +61,6 @@ dependencies { api("com.github.cy745:AnyPopDialog-Compose:cb92c5b6dc") api("me.rosuh:AndroidFilePicker:1.0.1") api("com.cheonjaeung.compose.grid:grid:2.0.0") - api("com.github.cy745.RemixIcon-Kmp:core:1a3c554a35") api("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") api("com.github.GIGAMOLE:ComposeFadingEdges:1.0.4") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d491061a..af2a4d2fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -111,6 +111,7 @@ flyjingfish-aop-ksp = { module = "io.github.FlyJingFish.AndroidAop:android-aop-k krouter-core = { module = "io.github.cy745.KRouter:core", version.ref = "krouter_version" } human-readable = { module = "nl.jacobras:Human-Readable", version = "1.10.0" } +remixicon-kmp = { module = "io.github.cy745:remixicon-kmp", version = "0.0.2" } [plugins] application = { id = "com.android.application", version.ref = "agp_version" } From 783795b0de497bb395d7ca1e7718a40a9a3fa2ff Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 Jan 2025 23:27:21 +0800 Subject: [PATCH 157/213] =?UTF-8?q?[modify]=E4=BF=AE=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E7=B1=BB=E7=9A=84=E5=91=BD=E5=90=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF=EF=BC=8C=E5=8E=BB=E9=99=A4=E6=97=A0=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 +- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 4 +- .../java/com/lalilu/lmusic/aop/LogOverride.kt | 2 +- .../compose/screen/guiding/AgreementScreen.kt | 15 ++- .../compose/screen/guiding/GuidingScreen.kt | 33 +++-- .../screen/guiding/PermissionsScreen.kt | 20 ++-- .../com/lalilu/lmusic/extension/EntryPanel.kt | 21 ++-- .../com/lalilu/component/base/CustomScreen.kt | 48 +------- .../component/base/screen/ScreenBarFactory.kt | 5 +- .../navigation/NavigationSmartBar.kt | 2 +- gradle/libs.versions.toml | 18 +-- .../lalilu/ldictionary/DictionaryModule.kt | 9 -- {ldictionary => lfolder}/.gitignore | 0 {ldictionary => lfolder}/build.gradle.kts | 2 +- {ldictionary => lfolder}/consumer-rules.pro | 0 {ldictionary => lfolder}/proguard-rules.pro | 0 .../src/main/AndroidManifest.xml | 0 .../java/com/lalilu/lfolder/FolderModule.kt | 9 ++ .../lalilu/lfolder/screen/FoldersScreen.kt | 113 ++++++++++-------- .../src/main/res/values-zh-rCN/strings.xml | 2 +- .../src/main/res/values/strings.xml | 2 +- .../lalilu/lhistory/screen/HistoryScreen.kt | 10 +- lmedia | 2 +- settings.gradle.kts | 2 +- 24 files changed, 147 insertions(+), 174 deletions(-) delete mode 100644 ldictionary/src/main/java/com/lalilu/ldictionary/DictionaryModule.kt rename {ldictionary => lfolder}/.gitignore (100%) rename {ldictionary => lfolder}/build.gradle.kts (94%) rename {ldictionary => lfolder}/consumer-rules.pro (100%) rename {ldictionary => lfolder}/proguard-rules.pro (100%) rename {ldictionary => lfolder}/src/main/AndroidManifest.xml (100%) create mode 100644 lfolder/src/main/java/com/lalilu/lfolder/FolderModule.kt rename ldictionary/src/main/java/com/lalilu/ldictionary/screen/DictionaryScreen.kt => lfolder/src/main/java/com/lalilu/lfolder/screen/FoldersScreen.kt (68%) rename {ldictionary => lfolder}/src/main/res/values-zh-rCN/strings.xml (50%) rename {ldictionary => lfolder}/src/main/res/values/strings.xml (50%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b98977bc..ad812c5d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -172,7 +172,7 @@ dependencies { implementation(project(":lhistory")) implementation(project(":lartist")) implementation(project(":lalbum")) - implementation(project(":ldictionary")) + implementation(project(":lfolder")) ksp(libs.koin.compiler) implementation(libs.room.ktx) diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index b36949d64..f60f29069 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -7,7 +7,7 @@ import coil3.SingletonImageLoader import com.blankj.utilcode.util.LogUtils import com.lalilu.lalbum.AlbumModule import com.lalilu.lartist.ArtistModule -import com.lalilu.ldictionary.DictionaryModule +import com.lalilu.lfolder.FolderModule import com.lalilu.lhistory.HistoryModule import com.lalilu.lmedia.LMedia import com.lalilu.lmusic.utils.extension.ignoreSSLVerification @@ -40,7 +40,7 @@ class LMusicApp : Application(), ViewModelStoreOwner { PlaylistModule.module, ArtistModule.module, AlbumModule.module, - DictionaryModule, + FolderModule, LMedia.module, MPlayer.module, ) diff --git a/app/src/main/java/com/lalilu/lmusic/aop/LogOverride.kt b/app/src/main/java/com/lalilu/lmusic/aop/LogOverride.kt index e27d80597..fae53ea0f 100644 --- a/app/src/main/java/com/lalilu/lmusic/aop/LogOverride.kt +++ b/app/src/main/java/com/lalilu/lmusic/aop/LogOverride.kt @@ -5,7 +5,7 @@ import com.blankj.utilcode.util.LogUtils import com.flyjingfish.android_aop_annotation.anno.AndroidAopReplaceClass import com.flyjingfish.android_aop_annotation.anno.AndroidAopReplaceMethod -@AndroidAopReplaceClass("android.util.Log") +//@AndroidAopReplaceClass("android.util.Log") class LogOverride { companion object { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/AgreementScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/AgreementScreen.kt index 85f002ec8..e18046aa2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/AgreementScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/AgreementScreen.kt @@ -4,22 +4,29 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow import com.lalilu.R -import com.lalilu.component.base.CustomScreen -import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory import kotlin.system.exitProcess class AgreementScreen( private val nextScreen: Screen -) : CustomScreen { +) : Screen, ScreenInfoFactory { - override fun getScreenInfo(): ScreenInfo = ScreenInfo(title = R.string.screen_title_agreement) + @Composable + override fun provideScreenInfo(): ScreenInfo { + return remember { + ScreenInfo(title = { stringResource(R.string.screen_title_agreement) }) + } + } @Composable override fun Content() { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt index b0cf04b6f..9368d5c24 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/GuidingScreen.kt @@ -42,13 +42,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.navigator.Navigator import com.lalilu.R -import com.lalilu.component.base.CustomScreen import com.lalilu.component.base.LocalWindowSize +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.component.extension.rememberIsPad import com.lalilu.component.navigation.CustomTransition @@ -63,14 +62,10 @@ fun GuidingScreen() { val navigatorState = remember { mutableStateOf(null) } val backStackSize = remember { derivedStateOf { navigatorState.value?.items?.size ?: 0 } } val showPopUpBtn = remember { derivedStateOf { backStackSize.value > 1 } } - val currentScreenTitleRes by remember { - derivedStateOf { - (navigatorState.value?.lastItemOrNull as? CustomScreen) - ?.getScreenInfo() - ?.title - ?: R.string.app_name - } - } + val currentScreen by remember { derivedStateOf { (navigatorState.value?.lastItemOrNull as? ScreenInfoFactory) } } + val currentScreenTitle = currentScreen + ?.provideScreenInfo() + ?.title?.invoke() Surface(color = MaterialTheme.colors.background) { Box( @@ -107,13 +102,17 @@ fun GuidingScreen() { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start ) { - AnimatedContent(targetState = currentScreenTitleRes, transitionSpec = { - ((slideInVertically { height -> height } + fadeIn()).togetherWith( - slideOutVertically { height -> -height } + fadeOut())).using( - SizeTransform(clip = false) - ) - }, label = "") { - Text(text = stringResource(id = it), fontSize = 22.sp) + AnimatedContent( + targetState = currentScreenTitle, + transitionSpec = { + ((slideInVertically { height -> height } + fadeIn()).togetherWith( + slideOutVertically { height -> -height } + fadeOut())).using( + SizeTransform(clip = false) + ) + }, + label = "" + ) { title -> + Text(text = title ?: "", fontSize = 22.sp) } Text( text = "${backStackSize.value} / 3", diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt index 7d5227238..aaeac593e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/guiding/PermissionsScreen.kt @@ -5,16 +5,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen import com.blankj.utilcode.util.ActivityUtils import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.lalilu.R -import com.lalilu.component.base.CustomScreen -import com.lalilu.component.base.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory import com.lalilu.lmedia.LMedia import com.lalilu.lmusic.Config.REQUIRE_PERMISSIONS import com.lalilu.lmusic.MainActivity @@ -23,11 +26,14 @@ import com.lalilu.lmusic.utils.extension.getActivity import org.koin.compose.koinInject import kotlin.system.exitProcess -class PermissionsScreen( -) : CustomScreen { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_permissions - ) +class PermissionsScreen : Screen, ScreenInfoFactory { + + @Composable + override fun provideScreenInfo(): ScreenInfo { + return remember { + ScreenInfo(title = { stringResource(R.string.screen_title_permissions) }) + } + } @Composable override fun Content() { diff --git a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt index e1ec4fa5c..bdbc340e6 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.LazyGridContent import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.base.screen.ScreenInfoFactory @@ -28,12 +29,6 @@ import com.lalilu.component.divider import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent import com.lalilu.component.rememberGridItemPadding -import com.lalilu.lalbum.screen.AlbumsScreen -import com.lalilu.ldictionary.screen.DictionaryScreen -import com.lalilu.lhistory.screen.HistoryScreen -import com.lalilu.lmusic.compose.new_screen.SettingsScreen -import com.lalilu.lmusic.compose.screen.songs.SongsScreen -import com.lalilu.lplaylist.screen.PlaylistScreen import com.zhangke.krouter.KRouter @@ -42,14 +37,14 @@ object EntryPanel : LazyGridContent { @Composable override fun register(): LazyGridScope.() -> Unit { val screenEntry = remember { - listOfNotNull( - SongsScreen(), + listOfNotNull( + KRouter.route("/pages/songs"), KRouter.route("/pages/artists"), - AlbumsScreen(), - PlaylistScreen, - HistoryScreen, - DictionaryScreen, - SettingsScreen + KRouter.route("/pages/albums"), + KRouter.route("/pages/playlist"), + KRouter.route("/pages/history"), + KRouter.route("/pages/folders"), + KRouter.route("/pages/settings") ) } val defaultString = "Undefined" diff --git a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt index 0cf1000d4..87095764e 100644 --- a/component/src/main/java/com/lalilu/component/base/CustomScreen.kt +++ b/component/src/main/java/com/lalilu/component/base/CustomScreen.kt @@ -1,48 +1,11 @@ package com.lalilu.component.base -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color import cafe.adriel.voyager.core.screen.Screen import com.lalilu.component.base.screen.ScreenInfoFactory import kotlinx.coroutines.CoroutineScope -/** - * 定义一个页面的信息 - */ -@Deprecated("弃用", replaceWith = ReplaceWith("com.lalilu.component.base.screen.ScreenInfo")) -data class ScreenInfo( - @StringRes val title: Int, - @DrawableRes val icon: Int? = null, - val immerseStatusBar: Boolean = true, -) - -/** - * 定义某个页面可执行的动作 - */ -@Deprecated("弃用") -sealed interface ScreenAction { - data class StaticAction( - @StringRes val title: Int, - @DrawableRes val icon: Int? = null, - @StringRes val info: Int? = null, - val color: Color = Color.White, - val isLongClickAction: Boolean = false, - val onAction: () -> Unit - ) : ScreenAction -} - -data class ScreenBarComponent( - val key: String, - val content: @Composable () -> Unit -) - -interface CustomScreen : Screen { - fun getScreenInfo(): ScreenInfo? = null -} - +@Deprecated("弃用,待移除") interface TabScreen : Screen, ScreenInfoFactory interface UiState @@ -52,12 +15,3 @@ interface UiPresenter : CoroutineScope { fun presentState(): T fun onAction(action: UiAction) } - -@Deprecated("TODO 替换完成后删除") -abstract class DynamicScreen : CustomScreen { - @Composable - open fun registerActions(): List { - return remember { emptyList() } - } -} - diff --git a/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt index f2f899b23..a7436903c 100644 --- a/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt @@ -8,8 +8,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.lalilu.component.base.ScreenBarComponent +data class ScreenBarComponent( + val key: String, + val content: @Composable () -> Unit +) class ComponentStack { var stack: List by mutableStateOf(emptyList()) diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt index 7cfd262b3..2887de03f 100644 --- a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import cafe.adriel.voyager.navigator.LocalNavigator -import com.lalilu.component.base.ScreenBarComponent +import com.lalilu.component.base.screen.ScreenBarComponent import com.lalilu.component.base.TabScreen import com.lalilu.component.base.screen.ScreenBarFactory import com.lalilu.component.base.screen.ScreenInfoFactory diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af2a4d2fa..1aa5d056c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,19 +2,19 @@ compile_version = "35" min_sdk_version = "21" -agp_version = "8.6.0" -kotlin_version = "2.0.0" -ksp_version = "2.0.0-1.0.22" +agp_version = "8.6.1" +kotlin_version = "2.1.0" +ksp_version = "2.1.0-1.0.29" koin_version = "4.0.0" koin_ksp_version = "1.4.0" -compose_bom_alpha_version = "2024.11.00" -compose_bom_version = "2024.11.00" +compose_bom_alpha_version = "2024.12.01" +compose_bom_version = "2024.12.01" accompanist_version = "0.32.0" voyager = "1.1.0-beta03" -lottie-compose = "5.2.0" +lottie-compose = "6.6.0" -coil3_version = "3.0.0-alpha07" +coil3_version = "3.0.2" utilcodex_version = "1.31.1" # androidx @@ -24,9 +24,9 @@ palette-ktx = "1.0.0" dynamicanimation-ktx = "1.0.0-alpha03" startup-runtime = "1.2.0" activity-compose = "1.9.3" -room_version = "2.5.2" +room_version = "2.6.1" media = "1.7.0" -media3 = "1.5.0" +media3 = "1.5.1" gson = "2.11.0" flyjingfish-aop = "1.9.7" paging_version = "3.3.5" diff --git a/ldictionary/src/main/java/com/lalilu/ldictionary/DictionaryModule.kt b/ldictionary/src/main/java/com/lalilu/ldictionary/DictionaryModule.kt deleted file mode 100644 index 435a87c9e..000000000 --- a/ldictionary/src/main/java/com/lalilu/ldictionary/DictionaryModule.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.lalilu.ldictionary - -import com.lalilu.ldictionary.screen.DictionaryScreenModel -import org.koin.core.module.dsl.factoryOf -import org.koin.dsl.module - -val DictionaryModule = module { - factoryOf(::DictionaryScreenModel) -} \ No newline at end of file diff --git a/ldictionary/.gitignore b/lfolder/.gitignore similarity index 100% rename from ldictionary/.gitignore rename to lfolder/.gitignore diff --git a/ldictionary/build.gradle.kts b/lfolder/build.gradle.kts similarity index 94% rename from ldictionary/build.gradle.kts rename to lfolder/build.gradle.kts index cd8f3283f..9d79b4042 100644 --- a/ldictionary/build.gradle.kts +++ b/lfolder/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "com.lalilu.ldictionary" + namespace = "com.lalilu.lfolder" compileSdk = libs.versions.compile.version.get().toIntOrNull() defaultConfig { diff --git a/ldictionary/consumer-rules.pro b/lfolder/consumer-rules.pro similarity index 100% rename from ldictionary/consumer-rules.pro rename to lfolder/consumer-rules.pro diff --git a/ldictionary/proguard-rules.pro b/lfolder/proguard-rules.pro similarity index 100% rename from ldictionary/proguard-rules.pro rename to lfolder/proguard-rules.pro diff --git a/ldictionary/src/main/AndroidManifest.xml b/lfolder/src/main/AndroidManifest.xml similarity index 100% rename from ldictionary/src/main/AndroidManifest.xml rename to lfolder/src/main/AndroidManifest.xml diff --git a/lfolder/src/main/java/com/lalilu/lfolder/FolderModule.kt b/lfolder/src/main/java/com/lalilu/lfolder/FolderModule.kt new file mode 100644 index 000000000..e9dac03f0 --- /dev/null +++ b/lfolder/src/main/java/com/lalilu/lfolder/FolderModule.kt @@ -0,0 +1,9 @@ +package com.lalilu.lfolder + +import com.lalilu.lfolder.screen.DictionaryScreenModel +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +val FolderModule = module { + factoryOf(::DictionaryScreenModel) +} \ No newline at end of file diff --git a/ldictionary/src/main/java/com/lalilu/ldictionary/screen/DictionaryScreen.kt b/lfolder/src/main/java/com/lalilu/lfolder/screen/FoldersScreen.kt similarity index 68% rename from ldictionary/src/main/java/com/lalilu/ldictionary/screen/DictionaryScreen.kt rename to lfolder/src/main/java/com/lalilu/lfolder/screen/FoldersScreen.kt index b5340bd85..3ebd38084 100644 --- a/ldictionary/src/main/java/com/lalilu/ldictionary/screen/DictionaryScreen.kt +++ b/lfolder/src/main/java/com/lalilu/lfolder/screen/FoldersScreen.kt @@ -1,51 +1,57 @@ -package com.lalilu.ldictionary.screen +package com.lalilu.lfolder.screen import android.app.Application import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel import com.blankj.utilcode.util.ActivityUtils import com.blankj.utilcode.util.LogUtils -import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.LoadingScaffold +import com.lalilu.RemixIcon import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenAction -import com.lalilu.component.base.ScreenInfo -import com.lalilu.component.base.collectAsLoadingState -import com.lalilu.ldictionary.R +import com.lalilu.component.base.screen.ScreenAction +import com.lalilu.component.base.screen.ScreenActionFactory +import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.lfolder.R import com.lalilu.lmedia.repository.LMediaSp import com.lalilu.lmedia.scanner.FileSource +import com.lalilu.remixicon.Document +import com.lalilu.remixicon.System +import com.lalilu.remixicon.document.folderMusicLine +import com.lalilu.remixicon.system.addLine +import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.mapLatest import me.rosuh.filepicker.FilePickerActivity import me.rosuh.filepicker.bean.FileItemBeanImpl import me.rosuh.filepicker.config.AbstractFileFilter import me.rosuh.filepicker.config.FilePickerManager -import com.lalilu.component.R as ComponentR +@Deprecated("弃用") @OptIn(ExperimentalCoroutinesApi::class) class DictionaryScreenModel( private val application: Application, @@ -71,14 +77,22 @@ class DictionaryScreenModel( } } -object DictionaryScreen : DynamicScreen() { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.dictionary_screen_title, - icon = ComponentR.drawable.ic_disc_line - ) +@Destination("/pages/folders") +object FoldersScreen : Screen, ScreenInfoFactory, ScreenActionFactory { + private fun readResolve(): Any = FoldersScreen @Composable - override fun registerActions(): List { + override fun provideScreenInfo(): com.lalilu.component.base.screen.ScreenInfo { + return remember { + com.lalilu.component.base.screen.ScreenInfo( + title = { stringResource(R.string.folder_screen_title) }, + icon = RemixIcon.Document.folderMusicLine + ) + } + } + + @Composable + override fun provideScreenActions(): List { val context = LocalContext.current val dictionarySM = getScreenModel() @@ -101,15 +115,15 @@ object DictionaryScreen : DynamicScreen() { return remember { listOf( - ScreenAction.StaticAction( - title = R.string.dictionary_screen_title, - icon = ComponentR.drawable.ic_add_line, - color = Color(0xFF037200) + ScreenAction.Static( + title = { stringResource(R.string.folder_screen_title) }, + icon = { RemixIcon.System.addLine }, + color = { Color(0xFF037200) } ) { runCatching { pickFileLauncher.launch(null) } .getOrElse { val activity = - ActivityUtils.getActivityByContext(context) ?: return@StaticAction + ActivityUtils.getActivityByContext(context) ?: return@Static FilePickerManager.from(activity) .skipDirWhenSelect(false) .maxSelectable(Int.MAX_VALUE) @@ -134,46 +148,39 @@ object DictionaryScreen : DynamicScreen() { } } - @Composable private fun DictionaryScreen( dictionarySM: DictionaryScreenModel ) { - val targetDirectory = dictionarySM.targetDirectory.collectAsLoadingState() - - LoadingScaffold( - modifier = Modifier.fillMaxSize(), - targetState = targetDirectory, - ) { directory -> - LLazyColumn( - modifier = Modifier, - contentPadding = WindowInsets.statusBars.asPaddingValues() - ) { - item { - NavigatorHeader( - title = "文件夹", - subTitle = "长按以移除该文件夹" - ) - } + val directory by dictionarySM.targetDirectory.collectAsState(initial = emptyList()) - items(items = directory) { - DirectoryCard( - title = it.name() ?: "unknown", - subTitle = it.path() ?: "unknown", - onLongClick = { - val id = when (it) { - is FileSource.Document -> it.id - is FileSource.IOFile -> it.id - } - dictionarySM.remove(id) + LazyColumn( + modifier = Modifier, + contentPadding = WindowInsets.statusBars.asPaddingValues() + ) { + item { + NavigatorHeader( + title = "文件夹", + subTitle = "长按以移除该文件夹" + ) + } + + items(items = directory) { + DirectoryCard( + title = it.name() ?: "unknown", + subTitle = it.path() ?: "unknown", + onLongClick = { + val id = when (it) { + is FileSource.Document -> it.id + is FileSource.IOFile -> it.id } - ) - } + dictionarySM.remove(id) + } + ) } } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun DirectoryCard( title: String, @@ -201,4 +208,4 @@ fun DirectoryCardPreview() { title = "LocalMusic", subTitle = "/Music/LocalMusic/" ) -} \ No newline at end of file +} diff --git a/ldictionary/src/main/res/values-zh-rCN/strings.xml b/lfolder/src/main/res/values-zh-rCN/strings.xml similarity index 50% rename from ldictionary/src/main/res/values-zh-rCN/strings.xml rename to lfolder/src/main/res/values-zh-rCN/strings.xml index da49e9cb3..1ec8dc996 100644 --- a/ldictionary/src/main/res/values-zh-rCN/strings.xml +++ b/lfolder/src/main/res/values-zh-rCN/strings.xml @@ -1,4 +1,4 @@ - 文件夹 + 文件夹 \ No newline at end of file diff --git a/ldictionary/src/main/res/values/strings.xml b/lfolder/src/main/res/values/strings.xml similarity index 50% rename from ldictionary/src/main/res/values/strings.xml rename to lfolder/src/main/res/values/strings.xml index b9b3c5cdb..966dbd2b0 100644 --- a/ldictionary/src/main/res/values/strings.xml +++ b/lfolder/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - Dictionary + Folder \ No newline at end of file diff --git a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt index 692abc80b..fbfdde4d4 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt @@ -34,10 +34,12 @@ import com.lalilu.lhistory.entity.LHistory import com.lalilu.lhistory.viewmodel.HistoryVM import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LSong -import com.lalilu.remixicon.Editor -import com.lalilu.remixicon.editor.draggable +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.historyLine +import com.zhangke.krouter.annotation.Destination import org.koin.compose.koinInject +@Destination("/pages/history") data object HistoryScreen : Screen, ScreenInfoFactory { private fun readResolve(): Any = HistoryScreen @@ -45,8 +47,8 @@ data object HistoryScreen : Screen, ScreenInfoFactory { override fun provideScreenInfo(): ScreenInfo { return remember { ScreenInfo( - title = { "History" }, - icon = RemixIcon.Editor.draggable + title = { "历史记录" }, + icon = RemixIcon.System.historyLine ) } } diff --git a/lmedia b/lmedia index 81e9ec900..27e03c3c5 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 81e9ec90040bc3861c27036c69a6475305035370 +Subproject commit 27e03c3c5af1411b23d32626e5ad1bcffe77e22b diff --git a/settings.gradle.kts b/settings.gradle.kts index 07c33817d..7bf8016b4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,4 @@ include(":lplaylist") include(":lhistory") include(":lartist") include(":lalbum") -include(":ldictionary") +include(":lfolder") From 18a0ca9712e54c459ae5e0264086eb38d226754f Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 1 Jan 2025 23:58:39 +0800 Subject: [PATCH 158/213] =?UTF-8?q?[modify]=E6=B7=BB=E5=8A=A0=E6=B7=B7?= =?UTF-8?q?=E6=B7=86=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/proguard-rules.pro | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 305e96ab2..a7881a981 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -66,4 +66,26 @@ -dontwarn org.gradle.api.attributes.HasAttributes -dontwarn org.gradle.api.component.SoftwareComponent -dontwarn org.gradle.api.plugins.ExtensionAware --dontwarn org.gradle.api.tasks.util.PatternFilterable \ No newline at end of file +-dontwarn org.gradle.api.tasks.util.PatternFilterable + + +-dontwarn coil3.PlatformContext +-dontwarn libcore.icu.NativePluralRules +-dontwarn org.jetbrains.kotlin.library.BaseKotlinLibrary +-dontwarn org.jetbrains.kotlin.library.BaseWriter +-dontwarn org.jetbrains.kotlin.library.IrKotlinLibraryLayout +-dontwarn org.jetbrains.kotlin.library.IrLibrary +-dontwarn org.jetbrains.kotlin.library.IrWriter +-dontwarn org.jetbrains.kotlin.library.KotlinLibrary +-dontwarn org.jetbrains.kotlin.library.KotlinLibraryLayout +-dontwarn org.jetbrains.kotlin.library.KotlinLibraryProperResolverWithAttributes +-dontwarn org.jetbrains.kotlin.library.MetadataKotlinLibraryLayout +-dontwarn org.jetbrains.kotlin.library.MetadataLibrary +-dontwarn org.jetbrains.kotlin.library.MetadataWriter +-dontwarn org.jetbrains.kotlin.library.SearchPathResolver +-dontwarn org.jetbrains.kotlin.library.impl.BaseLibraryAccess +-dontwarn org.jetbrains.kotlin.library.impl.ExtractingKotlinLibraryLayout +-dontwarn org.jetbrains.kotlin.library.impl.FromZipBaseLibraryImpl +-dontwarn org.jetbrains.kotlin.library.impl.KotlinLibraryLayoutForWriter +-dontwarn org.jetbrains.kotlin.library.impl.KotlinLibraryLayoutImpl +-dontwarn org.jetbrains.kotlin.library.impl.KotlinLibraryLayoutImplKt \ No newline at end of file From f6a37d247341762a7f4cc803b027ad1901aba8a6 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 2 Jan 2025 02:08:11 +0800 Subject: [PATCH 159/213] =?UTF-8?q?[modify]=E6=9B=B4=E6=96=B0lmedia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lmedia | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lmedia b/lmedia index 27e03c3c5..082e0d61b 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 27e03c3c5af1411b23d32626e5ad1bcffe77e22b +Subproject commit 082e0d61b9662442a470127daf16d079e49ccfd0 From 6716b6a0a91dee55bf43e249acb82964429e83ce Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 5 Jan 2025 14:32:40 +0800 Subject: [PATCH 160/213] =?UTF-8?q?[modify]=E5=B1=8F=E8=94=BDFlowOperatorI?= =?UTF-8?q?nvokedInComposition=E7=9A=84lint=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lmedia | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lmedia b/lmedia index 082e0d61b..ada8917e7 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 082e0d61b9662442a470127daf16d079e49ccfd0 +Subproject commit ada8917e7aa1af3ab9f77fc73a40a1f1a341ba0c From 0a62f2bfdac2954e08f499b5ce039028be897138 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 5 Jan 2025 17:57:03 +0800 Subject: [PATCH 161/213] =?UTF-8?q?[modify]=E5=B1=8F=E8=94=BDKVItem?= =?UTF-8?q?=E7=9A=84UnrememberedMutableState=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/src/main/java/com/lalilu/common/kv/KVItem.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/src/main/java/com/lalilu/common/kv/KVItem.kt b/common/src/main/java/com/lalilu/common/kv/KVItem.kt index d78a0a3dc..918f0772c 100644 --- a/common/src/main/java/com/lalilu/common/kv/KVItem.kt +++ b/common/src/main/java/com/lalilu/common/kv/KVItem.kt @@ -1,5 +1,6 @@ package com.lalilu.common.kv +import android.annotation.SuppressLint import androidx.annotation.CallSuper import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf @@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +@SuppressLint("UnrememberedMutableState") abstract class KVItem : MutableState, ReadWriteProperty, T?>, UpdatableKV { var autoSave = true private set From b0910104e1836b0c7b02d7fa39881a5a0d67b034 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 5 Jan 2025 18:56:54 +0800 Subject: [PATCH 162/213] =?UTF-8?q?[modify]=E5=B1=8F=E8=94=BDcommon?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=9A=84UnrememberedMutableState=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 43e21139f..c72edb179 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -22,6 +22,9 @@ android { kotlinOptions { jvmTarget = "1.8" } + lint { + disable += "UnrememberedMutableState" + } } dependencies { From a78be4cfdc2524ca7b9117846bbb1f728ff859b5 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 6 Jan 2025 02:21:55 +0800 Subject: [PATCH 163/213] =?UTF-8?q?[modify]=E5=B1=8F=E8=94=BDlint=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lmedia | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lmedia b/lmedia index ada8917e7..cddaef629 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit ada8917e7aa1af3ab9f77fc73a40a1f1a341ba0c +Subproject commit cddaef6298a2972ef0935954e4d5b04c4afe11a1 From 3326e43f8f26fd6319d6082a2e0bc8bca0350d61 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Tue, 7 Jan 2025 02:12:03 +0800 Subject: [PATCH 164/213] =?UTF-8?q?[modify]=E5=AE=8C=E5=96=84=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E7=9B=B8=E5=85=B3=E4=BF=A1=E6=81=AF=E7=9A=84=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E5=92=8C=E6=98=BE=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../new_screen/detail/SongInformationCard.kt | 203 ++++++++++-------- .../java/com/lalilu/common/base/SourceType.kt | 13 +- .../java/com/lalilu/common/base/Sticker.kt | 8 +- .../com/lalilu/component/card/HasLyricIcon.kt | 7 +- .../com/lalilu/component/card/SongCard.kt | 48 +++-- lmedia | 2 +- 6 files changed, 159 insertions(+), 122 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt index fae68cd7e..e4de7ad35 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt @@ -1,5 +1,6 @@ package com.lalilu.lmusic.compose.new_screen.detail +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,11 +11,22 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.blankj.utilcode.util.ConvertUtils +import com.blankj.utilcode.util.ToastUtils import com.lalilu.lmedia.entity.LSong +import java.text.DateFormat import java.text.SimpleDateFormat @Composable @@ -29,119 +41,130 @@ fun SongInformationCard( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(vertical = 8.dp), ) { - song.fileInfo.mimeType.let { mimeType -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "文件类型", - style = MaterialTheme.typography.subtitle2 - ) - Text( - text = mimeType, - style = MaterialTheme.typography.caption - ) - } + song.metadata.genre.takeIf(String::isNotBlank)?.let { + ColumnItem( + title = "流派", + content = it, + ) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "文件大小", - style = MaterialTheme.typography.subtitle2 - ) - Text( - text = ConvertUtils.byte2FitMemorySize(song.fileInfo.size), - style = MaterialTheme.typography.caption + song.fileInfo.mimeType.let { mimeType -> + ColumnItem( + title = "文件类型", + content = mimeType, ) } + ColumnItem( + title = "文件大小", + content = remember(song) { + ConvertUtils.byte2FitMemorySize(song.fileInfo.size) + }, + ) + + ColumnItem( + title = "平均码率", + content = remember(song) { "%.1f kbps".format(song.fileInfo.bitrate / 1000f) }, + ) + song.metadata.dateAdded.let { date -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "添加日期", - style = MaterialTheme.typography.subtitle2 - ) - Text( - text = SimpleDateFormat.getDateInstance().format(date * 1000L), - style = MaterialTheme.typography.caption - ) - } + ColumnItem( + title = "添加日期", + content = remember(date) { + val time = date * 1000L + val dateS = SimpleDateFormat.getDateInstance(DateFormat.LONG).format(time) + val timeS = SimpleDateFormat.getTimeInstance(DateFormat.MEDIUM).format(time) + + "$dateS $timeS" + }, + ) } if (song.metadata.disc.isNotBlank() || song.metadata.track.isNotBlank()) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - song.metadata.disc.takeIf { it.isNotBlank() }?.let { disc -> - Row( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "光盘号", - style = MaterialTheme.typography.subtitle2 - ) - Text( - text = disc, - style = MaterialTheme.typography.caption - ) - } + song.metadata.disc.takeIf(String::isNotBlank)?.let { disc -> + ColumnItem( + modifier = Modifier.weight(1f), + title = "光盘号", + content = disc, + ) } - song.metadata.track.takeIf { it.isNotBlank() }?.let { track -> - Row( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "音轨号", - style = MaterialTheme.typography.subtitle2 - ) - Text( - text = track, - style = MaterialTheme.typography.caption - ) - } + song.metadata.track.takeIf(String::isNotBlank)?.let { track -> + ColumnItem( + modifier = Modifier.weight(1f), + title = "音轨号", + content = track, + ) } } } song.fileInfo.pathStr?.takeIf { it.isNotBlank() }?.let { path -> - Row( - modifier = Modifier.fillMaxWidth(), + ColumnItem( + title = "文件位置", + content = path, verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "文件位置", - style = MaterialTheme.typography.subtitle2 - ) - Text( - text = path, - style = MaterialTheme.typography.caption + showBorder = false + ) + } + } + } +} + +@Composable +fun ColumnItem( + modifier: Modifier = Modifier, + title: String, + content: String, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + showBorder: Boolean = true +) { + val clipboard = LocalClipboardManager.current + val contentColor = MaterialTheme.colors.onBackground + + Row( + modifier = modifier + .fillMaxWidth() + .drawBehind { + if (showBorder) { + drawLine( + color = contentColor.copy(0.15f), + start = Offset(16.dp.toPx(), this.size.height), + end = Offset(this.size.width - 16.dp.toPx(), this.size.height), + cap = StrokeCap.Round ) } } - } + .combinedClickable( + onLongClick = { + clipboard.setText(buildAnnotatedString { append(content) }) + ToastUtils.showShort("复制成功") + }, + onClick = { ToastUtils.showShort("长按复制元素内容") } + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = verticalAlignment, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.subtitle2, + fontWeight = FontWeight.Bold, + lineHeight = MaterialTheme.typography.subtitle2.fontSize + ) + Text( + modifier = Modifier + .weight(1f) + .alpha(0.9f), + text = content, + textAlign = TextAlign.End, + style = MaterialTheme.typography.caption, + ) } } \ No newline at end of file diff --git a/common/src/main/java/com/lalilu/common/base/SourceType.kt b/common/src/main/java/com/lalilu/common/base/SourceType.kt index d90d83310..78430361a 100644 --- a/common/src/main/java/com/lalilu/common/base/SourceType.kt +++ b/common/src/main/java/com/lalilu/common/base/SourceType.kt @@ -1,10 +1,11 @@ package com.lalilu.common.base -sealed interface SourceType { - data object Unknown : SourceType - data object MediaStore : SourceType - data object Local : SourceType - data object Network : SourceType +sealed class SourceType(val name: String) { + data object Unknown : SourceType("Unknown") + data object MediaStore : SourceType("MediaStore") + data object Local : SourceType("Local") + data object Network : SourceType("Network") + data object WebDAV : SourceType("WebDAV") - data class Extension(val extensionName: String) : SourceType + data class Extension(val extensionName: String) : SourceType(extensionName) } \ No newline at end of file diff --git a/common/src/main/java/com/lalilu/common/base/Sticker.kt b/common/src/main/java/com/lalilu/common/base/Sticker.kt index b5bb46656..885a28c54 100644 --- a/common/src/main/java/com/lalilu/common/base/Sticker.kt +++ b/common/src/main/java/com/lalilu/common/base/Sticker.kt @@ -2,7 +2,7 @@ package com.lalilu.common.base sealed class Sticker(val name: String) { open class ExtSticker(ext: String) : Sticker(ext) - open class SourceSticker(source: String) : Sticker(source) + open class SourceSticker(sourceType: SourceType) : Sticker(sourceType.name) data object HasLyricSticker : Sticker("LRC") data object HiresSticker : Sticker("HIRES") } @@ -15,7 +15,7 @@ data object Mp3Sticker : Sticker.ExtSticker("MP3") data object Mp4Sticker : Sticker.ExtSticker("MP4") -data object LocalSticker : Sticker.SourceSticker("LOCAL") -data object WebDavSticker : Sticker.SourceSticker("WEBDAV") -data object CloudSticker : Sticker.SourceSticker("CLOUD") +data object LocalSticker : Sticker.SourceSticker(SourceType.Local) +data object WebDavSticker : Sticker.SourceSticker(SourceType.WebDAV) +data object CloudSticker : Sticker.SourceSticker(SourceType.Network) diff --git a/component/src/main/java/com/lalilu/component/card/HasLyricIcon.kt b/component/src/main/java/com/lalilu/component/card/HasLyricIcon.kt index 18bc27c7e..a22726b13 100644 --- a/component/src/main/java/com/lalilu/component/card/HasLyricIcon.kt +++ b/component/src/main/java/com/lalilu/component/card/HasLyricIcon.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -19,7 +20,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.lalilu.component.R -import com.lalilu.component.extension.dayNightTextColorFilter +import com.lalilu.component.extension.toColorFilter @Composable fun HasLyricIcon( @@ -36,7 +37,7 @@ fun HasLyricIcon( Image( painter = painterResource(id = R.drawable.ic_lrc_fill), contentDescription = "Lyric Icon", - colorFilter = dayNightTextColorFilter(0.9f), + colorFilter = MaterialTheme.colors.background.copy(0.9f).toColorFilter(), modifier = Modifier .size(20.dp) .aspectRatio(1f) @@ -50,7 +51,7 @@ fun HasLyricIcon( Image( painter = painterResource(id = R.drawable.ic_lrc_fill), contentDescription = "Lyric Icon", - colorFilter = dayNightTextColorFilter(0.9f), + colorFilter = MaterialTheme.colors.background.copy(0.9f).toColorFilter(), modifier = Modifier .size(20.dp) .aspectRatio(1f) diff --git a/component/src/main/java/com/lalilu/component/card/SongCard.kt b/component/src/main/java/com/lalilu/component/card/SongCard.kt index 92b30c978..1ba23ae9b 100644 --- a/component/src/main/java/com/lalilu/component/card/SongCard.kt +++ b/component/src/main/java/com/lalilu/component/card/SongCard.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background @@ -53,9 +52,9 @@ import coil3.request.placeholder import com.lalilu.common.base.Sticker import com.lalilu.component.R import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.dayNightTextColorFilter import com.lalilu.component.extension.durationMsToString import com.lalilu.component.extension.mimeTypeToIcon +import com.lalilu.component.extension.toColorFilter import com.lalilu.lmedia.entity.LSong @Composable @@ -84,24 +83,29 @@ fun SongCard( title = { item.metadata.title }, subTitle = { item.metadata.artist }, duration = { item.metadata.duration }, - sticker = { emptyList() }, + sticker = { + listOfNotNull( + Sticker.ExtSticker(item.fileInfo.mimeType), + if (hasLyric()) Sticker.HasLyricSticker else null, + Sticker.SourceSticker(item.sourceType) + ) + }, imageData = { item }, onClick = onClick, onLongClick = onLongClick, onDoubleClick = onDoubleClick, onEnterSelect = onEnterSelect, - hasLyric = hasLyric, isPlaying = isPlaying, + fixedHeight = fixedHeight, isSelected = isSelected, showPrefix = showPrefix, - fixedHeight = fixedHeight, prefixContent = prefixContent ) } @Composable -fun SongCard( +internal fun SongCard( modifier: Modifier = Modifier, dragModifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -114,7 +118,6 @@ fun SongCard( onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, onEnterSelect: () -> Unit = {}, - hasLyric: () -> Boolean = { false }, isPlaying: () -> Boolean = { false }, fixedHeight: () -> Boolean = { false }, isSelected: () -> Boolean = { false }, @@ -153,17 +156,31 @@ fun SongCard( fixedHeight = fixedHeight, prefixContent = prefixContent, stickerContent = { - HasLyricIcon( - hasLyric = hasLyric, - fixedHeight = fixedHeight - ) - val stickers = remember { sticker() } + + stickers.firstOrNull { it is Sticker.HasLyricSticker }?.let { + HasLyricIcon( + hasLyric = { true }, + fixedHeight = fixedHeight + ) + } + + stickers.firstOrNull { it is Sticker.HiresSticker }?.let { + Image( + painter = painterResource(id = R.drawable.ic_ape_line), + contentDescription = "Hires Icon", + colorFilter = Color(0xFFFFC107).copy(0.9f).toColorFilter(), + modifier = Modifier + .size(20.dp) + .aspectRatio(1f) + ) + } + stickers.firstOrNull { it is Sticker.ExtSticker }?.let { Image( painter = painterResource(id = mimeTypeToIcon(mimeType = it.name)), contentDescription = "MediaType Icon", - colorFilter = dayNightTextColorFilter(0.9f), + colorFilter = MaterialTheme.colors.onBackground.copy(0.9f).toColorFilter(), modifier = Modifier .size(20.dp) .aspectRatio(1f) @@ -300,7 +317,6 @@ private fun SongCardPreview() { title = { "歌いましょう鳴らしましょう" }, subTitle = { "MyGO!!!!!" }, duration = { 189999L }, - hasLyric = { true }, sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) @@ -314,7 +330,6 @@ private fun SongCardPreviewMulti() { title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - hasLyric = { true }, sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) @@ -322,7 +337,6 @@ private fun SongCardPreviewMulti() { title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - hasLyric = { true }, sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) @@ -330,7 +344,6 @@ private fun SongCardPreviewMulti() { title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - hasLyric = { true }, sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) @@ -338,7 +351,6 @@ private fun SongCardPreviewMulti() { title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - hasLyric = { true }, sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) diff --git a/lmedia b/lmedia index cddaef629..cde959aac 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit cddaef6298a2972ef0935954e4d5b04c4afe11a1 +Subproject commit cde959aac62525cf3ad83aa782963e6f5553dd38 From a4c7c7aaae23fdaff7996190354bd362776cf4f5 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 12 Jan 2025 17:42:37 +0800 Subject: [PATCH 165/213] =?UTF-8?q?[modify]=E5=AE=8C=E5=96=84TTML=E6=AD=8C?= =?UTF-8?q?=E8=AF=8Dxml=E8=A7=A3=E6=9E=90=E7=9A=84=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 9 +- .../compose/screen/playing/TTMLParser.kt | 165 ------------------ .../screen/playing/{ => lyric}/LyricLayout.kt | 2 +- .../playing/{ => lyric}/LyricSentence.kt | 3 +- .../compose/screen/playing/lyric/TTML.kt | 159 +++++++++++++++++ .../java/com/lalilu/common/HapticUtils.kt | 41 ----- gradle/libs.versions.toml | 3 + 7 files changed, 170 insertions(+), 212 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/TTMLParser.kt rename app/src/main/java/com/lalilu/lmusic/compose/screen/playing/{ => lyric}/LyricLayout.kt (99%) rename app/src/main/java/com/lalilu/lmusic/compose/screen/playing/{ => lyric}/LyricSentence.kt (98%) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/TTML.kt delete mode 100644 common/src/main/java/com/lalilu/common/HapticUtils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ad812c5d4..478f1472c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,9 @@ dependencies { implementation(libs.kotlinx.serialization.json) ksp(libs.room.compiler) + implementation(libs.xmlutil.core) + implementation(libs.xmlutil.serialization) + // https://github.com/Block-Network/StatusBarApiExample // 墨 · 状态栏歌词 API implementation("com.github.577fkj:StatusBarApiExample:v2.0") @@ -210,11 +213,11 @@ dependencies { implementation("com.github.commandiron:WheelPickerCompose:1.1.11") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") - debugImplementation("com.github.getActivity:Logcat:11.8") +// debugImplementation("com.github.getActivity:Logcat:11.8") // debugImplementation("io.github.knight-zxw:blockcanary:0.0.5") // debugImplementation("io.github.knight-zxw:blockcanary-ui:0.0.5") - debugImplementation("com.github.cy745:wytrace:d0df4c2d15") - debugImplementation("com.bytedance.android:shadowhook:1.0.10") +// debugImplementation("com.github.cy745:wytrace:d0df4c2d15") +// debugImplementation("com.bytedance.android:shadowhook:1.0.10") implementation(libs.bundles.flyjingfish.aop) ksp(libs.flyjingfish.aop.ksp) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/TTMLParser.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/TTMLParser.kt deleted file mode 100644 index 6bf62d1d3..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/TTMLParser.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.lalilu.lmusic.compose.screen.playing - -import android.content.Context -import com.lalilu.R -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.w3c.dom.Document -import org.w3c.dom.Node -import org.w3c.dom.NodeList -import java.util.regex.Pattern -import javax.xml.parsers.DocumentBuilderFactory -import kotlin.coroutines.CoroutineContext - -data class LyricContent( - val name: String, - val begin: Long, - val end: Long, - val duration: Long, - val agent: List, - val sentences: List -) - -data class Agent( - val xmlId: String, - val type: String, -) - -data class Translation( - val role: String, - val lang: String, - val text: String -) - -sealed class Sentence { - data class TTMLSentence( - val begin: Long, - val end: Long, - val agent: String, - val itunesKey: String, - val characters: List, - val translation: Translation? = null - ) : Sentence() - - data class NormalSentence( - val begin: Long, - val text: String, - val translation: String = "" - ) : Sentence() - - data class EmptySentence( - val begin: Long, - val end: Long - ) : Sentence() -} - -data class TTMLCharacter( - val begin: Long, - val end: Long, - val content: String -) - -object TTMLParser : CoroutineScope { - override val coroutineContext: CoroutineContext = Dispatchers.IO - - fun parse(context: Context) = launch { -// val str = context.resources.openRawResource(R.raw.lyric) -// val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() -// .parse(str) -// -// val result = retrieveLyricFromNode(doc) - } -} - -suspend fun retrieveLyricFromNode(doc: Document): LyricContent? = withContext(Dispatchers.IO) { - val head = doc.getElementsByTagName("head").item(0) - val body = doc.getElementsByTagName("body").item(0) - val metadata = head.firstChild - val div = body.firstChild - - val agent = metadata.childNodes.toList().mapNotNull { - val type = it.getAttrByName("type") ?: return@mapNotNull null - val id = it.getAttrByName("xml:id") ?: return@mapNotNull null - Agent(xmlId = id, type = type) - } - - val duration = body.getAttrByName("dur")?.let(::parseTimeSpan) ?: return@withContext null - val begin = div.getAttrByName("begin")?.let(::parseTimeSpan) ?: return@withContext null - val end = div.getAttrByName("end")?.let(::parseTimeSpan) ?: return@withContext null - - val sentences = div.childNodes.toList() - .map { async { retrieveSentenceFromNode(it) } } - .awaitAll() - .filterNotNull() - - LyricContent( - name = "", - begin = begin, - end = end, - duration = duration, - agent = agent, - sentences = sentences - ) -} - -suspend fun retrieveSentenceFromNode(node: Node): Sentence? = withContext(Dispatchers.IO) { - val begin = node.getAttrByName("begin")?.let(::parseTimeSpan) ?: return@withContext null - val end = node.getAttrByName("end")?.let(::parseTimeSpan) ?: return@withContext null - val agent = node.getAttrByName("ttm:agent") ?: return@withContext null - val itunesKey = node.getAttrByName("itunes:key") ?: return@withContext null - var translation: Translation? = null - - val characters = node.childNodes.toList().mapNotNull { node -> - retrieveCharacterFromNode(node).also { - it ?: return@also - translation = retrieveTranslationFromNode(node) - } - } - - Sentence.TTMLSentence(begin, end, agent, itunesKey, characters, translation) -} - -private fun retrieveCharacterFromNode(node: Node): TTMLCharacter? { - val begin = node.getAttrByName("begin")?.let(::parseTimeSpan) ?: return null - val end = node.getAttrByName("end")?.let(::parseTimeSpan) ?: return null - val text = node.textContent ?: return null - - return TTMLCharacter(begin, end, text) -} - -private fun retrieveTranslationFromNode(node: Node): Translation? { - val role = node.getAttrByName("ttm:role") ?: return null - val lang = node.getAttrByName("xml:lang") ?: return null - val text = node.textContent ?: return null - - return Translation(role, lang, text) -} - -private val timeRegexp = - Pattern.compile("^(?:([0-9]{2}):)?([0-9]{2})(?::([0-9]{2})(?:\\.([0-9]+))?)?$") - -private fun parseTimeSpan(string: String?): Long { - string ?: return 0 - - val matches = timeRegexp.matcher(string) - if (matches.matches()) { - val hour = matches.group(1)?.toIntOrNull() ?: 0 - val min = matches.group(2)?.toIntOrNull() ?: 0 - val sec = matches.group(3)?.toIntOrNull() ?: 0 - val millisecond = matches.group(4)?.toIntOrNull() ?: 0 - return ((hour * 3600f + min * 60f + sec + millisecond) * 1000f).toLong() - } - return 0 -} - -private fun Node.getAttrByName(name: String): String? { - return attributes.getNamedItem(name)?.textContent -} - -private fun NodeList.toList(): List { - return (0 until length).map { item(it) } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt similarity index 99% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt rename to app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt index c4b2d0303..33ed3f884 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.screen.playing +package com.lalilu.lmusic.compose.screen.playing.lyric import android.graphics.Typeface import androidx.activity.compose.BackHandler diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSentence.kt similarity index 98% rename from app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt rename to app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSentence.kt index ca25a6a27..beb3ca4ce 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSentence.kt @@ -1,4 +1,4 @@ -package com.lalilu.lmusic.compose.screen.playing +package com.lalilu.lmusic.compose.screen.playing.lyric import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring @@ -6,7 +6,6 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/TTML.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/TTML.kt new file mode 100644 index 000000000..6fdafceb6 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/TTML.kt @@ -0,0 +1,159 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlChildrenName +import nl.adaptivity.xmlutil.serialization.XmlSerialName +import nl.adaptivity.xmlutil.serialization.XmlValue + +/** + * TTML 示例 + * 详见:applemusic-like-lyrics + * + * ```xml + * + * + * + * + * + * ...... + * + * + * + *

+ *

+ * ほほえむ + * 君がいる + * 你微笑着 站在此处 + *

+ * ...... + *
+ * + * + * ``` + * + * ```kotlin + * // 需要按照如下格式创建XML对象实例 + * XML { + * autoPolymorphic = true + * fast_0_90_2() + * } + * ``` + */ +@Serializable +@XmlSerialName(value = "tt", namespace = "http://www.w3.org/ns/ttml") +class TTML( + val head: TTMLHead, + val body: TTMLBody +) + +@Serializable +@XmlSerialName(value = "head") +class TTMLHead( + @XmlChildrenName("metadata") + val metadata: List +) + +@Serializable +sealed class MetadataItem { + + @Serializable + @XmlSerialName( + value = "agent", + namespace = "http://www.w3.org/ns/ttml#metadata", + prefix = "ttm" + ) + data class TTMLMetadataAgent( + @XmlSerialName(value = "type") + val type: String, + @XmlSerialName( + value = "id", + namespace = "http://www.w3.org/XML/1998/namespace", + prefix = "xml" + ) + val id: String + ) : MetadataItem() + + @Serializable + @XmlSerialName( + value = "meta", + namespace = "http://www.example.com/ns/amll", + prefix = "amll" + ) + data class TTMLMetadataItem( + val key: String, + val value: String + ) : MetadataItem() +} + +@Serializable +@XmlSerialName(value = "body") +class TTMLBody( + @XmlSerialName(value = "dur") + val dur: String, + @XmlSerialName(value = "div") + val div: TTMLBodyDiv, +) + +@Serializable +@XmlSerialName(value = "div") +data class TTMLBodyDiv( + @XmlSerialName("begin") + val begin: String, + @XmlSerialName("end") + val end: String, + val p: List +) + +@Serializable +@XmlSerialName(value = "p") +data class TTMLBodyDivP( + @XmlSerialName("begin") + val begin: String, + + @XmlSerialName("end") + val end: String, + + @XmlSerialName( + value = "key", + prefix = "itunes", + namespace = "http://music.apple.com/lyric-ttml-internal" + ) + val key: String, + + @XmlSerialName( + value = "agent", + namespace = "http://www.w3.org/ns/ttml#metadata", + prefix = "ttm" + ) + val agent: String, + val spans: List = emptyList() +) + +@Serializable +@XmlSerialName(value = "span") +data class TTMLSpan( + @XmlSerialName("begin") + val begin: String? = null, + + @XmlSerialName("end") + val end: String? = null, + + @XmlSerialName( + value = "role", + prefix = "ttm", + namespace = "http://www.w3.org/ns/ttml#metadata", + ) + val role: String? = null, + + @XmlSerialName( + value = "lang", + prefix = "xml", + namespace = "http://www.w3.org/XML/1998/namespace", + ) + val lang: String? = null, + + @XmlValue + val content: String = "" +) \ No newline at end of file diff --git a/common/src/main/java/com/lalilu/common/HapticUtils.kt b/common/src/main/java/com/lalilu/common/HapticUtils.kt deleted file mode 100644 index 50c50a822..000000000 --- a/common/src/main/java/com/lalilu/common/HapticUtils.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.lalilu.common - -import android.os.Build -import android.view.HapticFeedbackConstants -import android.view.View - -object HapticUtils { - enum class Strength(var value: Int) { - HAPTIC_WEAK( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - HapticFeedbackConstants.KEYBOARD_RELEASE - } else { - HapticFeedbackConstants.KEYBOARD_TAP - } - ), - HAPTIC_STRONG( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - HapticFeedbackConstants.KEYBOARD_PRESS - } else { - HapticFeedbackConstants.LONG_PRESS - } - ) - } - - fun haptic(view: View, strength: Strength) { - view.performHapticFeedback(strength.value) - } - - fun haptic(view: View) { - haptic(view, Strength.HAPTIC_STRONG) - } - - fun weakHaptic(view: View) { - haptic(view, Strength.HAPTIC_WEAK) - } - - fun doubleHaptic(view: View) { - haptic(view) - view.postDelayed({ haptic(view) }, 100) - } -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1aa5d056c..f4221fa3f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ gson = "2.11.0" flyjingfish-aop = "1.9.7" paging_version = "3.3.5" krouter_version = "0.0.3" +xmlutil = "0.90.3" [libraries] # kotlin @@ -112,6 +113,8 @@ flyjingfish-aop-ksp = { module = "io.github.FlyJingFish.AndroidAop:android-aop-k krouter-core = { module = "io.github.cy745.KRouter:core", version.ref = "krouter_version" } human-readable = { module = "nl.jacobras:Human-Readable", version = "1.10.0" } remixicon-kmp = { module = "io.github.cy745:remixicon-kmp", version = "0.0.2" } +xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core", version.ref = "xmlutil" } +xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" } [plugins] application = { id = "com.android.application", version.ref = "agp_version" } From b24caaff06096224fdeac2cf9be234f7e430b49d Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 12 Jan 2025 17:43:24 +0800 Subject: [PATCH 166/213] =?UTF-8?q?[modify]=E8=A7=A3=E5=86=B3=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=88=97=E8=A1=A8=E9=95=BF=E6=8C=89=E5=85=83=E7=B4=A0?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E8=A7=A6=E5=8F=91=E5=A4=9A=E6=AC=A1=E6=8C=AF?= =?UTF-8?q?=E5=8A=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index f94967aa0..e2b89300d 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -26,9 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -102,7 +100,6 @@ fun PlaylistLayout( forceRefresh: () -> Boolean = { false }, items: () -> List = { emptyList() } ) { - val haptic = LocalHapticFeedback.current val view = LocalView.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -155,8 +152,6 @@ fun PlaylistLayout( item = item.data, onPlayItem = { PlayerAction.PlayById(item.data.mediaId).action() }, onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - AppRouter.route("/pages/songs/detail") .with("mediaId", item.data.mediaId) .jump() From eaa2c05c2f3f94d67e582b3744eac95d75a5b6d2 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 13 Jan 2025 01:01:03 +0800 Subject: [PATCH 167/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=8C=85=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/compose/screen/playing/PlayingLayout.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 99ce3548f..71ae190c2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -51,6 +51,11 @@ import com.lalilu.lmedia.lyric.LyricSourceEmbedded import com.lalilu.lmedia.lyric.LyricUtils import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricLayout +import com.lalilu.lmusic.compose.screen.playing.lyric.index +import com.lalilu.lmusic.compose.screen.playing.lyric.rememberFontFamilyFromPath +import com.lalilu.lmusic.compose.screen.playing.lyric.rememberTextAlignFromGravity +import com.lalilu.lmusic.compose.screen.playing.lyric.rememberTextSizeFromInt import com.lalilu.lmusic.compose.screen.playing.seekbar.ClickPart import com.lalilu.lmusic.compose.screen.playing.seekbar.SeekbarLayout import com.lalilu.lmusic.datastore.SettingsSp From eba31ce481e9361a4f3775525f35c2cfd4b46974 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 25 Jan 2025 23:58:12 +0800 Subject: [PATCH 168/213] =?UTF-8?q?[modify]=E6=9B=B4=E6=96=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/LMusicApp.kt | 47 ++++++++++--------- component/build.gradle.kts | 2 + gradle/libs.versions.toml | 10 ++-- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt index f60f29069..2a0893fbb 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -16,38 +16,41 @@ import com.lalilu.lplaylist.PlaylistModule import com.zhangke.krouter.KRouter import com.zhangke.krouter.generated.KRouterInjectMap import org.koin.android.ext.koin.androidContext -import org.koin.androix.startup.KoinStartup.onKoinStartup +import org.koin.androix.startup.KoinStartup +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.dsl.KoinConfiguration import org.koin.java.KoinJavaComponent import org.koin.ksp.generated.module import java.io.File @Suppress("OPT_IN_USAGE") -class LMusicApp : Application(), ViewModelStoreOwner { +class LMusicApp : Application(), ViewModelStoreOwner, KoinStartup { override val viewModelStore: ViewModelStore = ViewModelStore() - init { - KRouter.init(KRouterInjectMap::getMap) + @KoinExperimentalAPI + override fun onKoinStartup(): KoinConfiguration = KoinConfiguration { + androidContext(this@LMusicApp) + modules( + MainModule.module, + AppModule, + ApiModule, + ViewModelModule, + HistoryModule.module, + PlaylistModule.module, + ArtistModule.module, + AlbumModule.module, + FolderModule, + LMedia.module, + MPlayer.module, + ) - onKoinStartup { - androidContext(this@LMusicApp) - modules( - MainModule.module, - AppModule, - ApiModule, - ViewModelModule, - HistoryModule.module, - PlaylistModule.module, - ArtistModule.module, - AlbumModule.module, - FolderModule, - LMedia.module, - MPlayer.module, - ) + SingletonImageLoader + .setSafe(KoinJavaComponent.get(SingletonImageLoader.Factory::class.java)) + } - SingletonImageLoader - .setSafe(KoinJavaComponent.get(SingletonImageLoader.Factory::class.java)) - } + init { + KRouter.init(KRouterInjectMap::getMap) } override fun onCreate() { diff --git a/component/build.gradle.kts b/component/build.gradle.kts index f008abae5..bacda8fa6 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -63,6 +63,8 @@ dependencies { api("com.cheonjaeung.compose.grid:grid:2.0.0") api("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") api("com.github.GIGAMOLE:ComposeFadingEdges:1.0.4") + api("dev.chrisbanes.haze:haze:1.2.2") + api("dev.chrisbanes.haze:haze-materials:1.2.2") // https://mvnrepository.com/artifact/org.jetbrains.androidx.navigation/navigation-compose api("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f4221fa3f..f13e078ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,15 +6,15 @@ agp_version = "8.6.1" kotlin_version = "2.1.0" ksp_version = "2.1.0-1.0.29" -koin_version = "4.0.0" +koin_version = "4.0.1" koin_ksp_version = "1.4.0" -compose_bom_alpha_version = "2024.12.01" -compose_bom_version = "2024.12.01" +compose_bom_alpha_version = "2025.01.00" +compose_bom_version = "2025.01.00" accompanist_version = "0.32.0" voyager = "1.1.0-beta03" lottie-compose = "6.6.0" -coil3_version = "3.0.2" +coil3_version = "3.0.4" utilcodex_version = "1.31.1" # androidx @@ -23,7 +23,7 @@ core-ktx = "1.15.0" palette-ktx = "1.0.0" dynamicanimation-ktx = "1.0.0-alpha03" startup-runtime = "1.2.0" -activity-compose = "1.9.3" +activity-compose = "1.10.0" room_version = "2.6.1" media = "1.7.0" media3 = "1.5.1" From 8f774a833235210537e7c2d4845adaaa409c07bf Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 26 Jan 2025 01:38:37 +0800 Subject: [PATCH 169/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E9=A1=B5=E5=85=83=E7=B4=A0=E6=98=BE=E7=A4=BA=E6=95=88?= =?UTF-8?q?=E6=9E=9C=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=8A=98=E5=8F=A0=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../search/extensions/SearchArtistsResult.kt | 116 +++++++++++++----- .../search/extensions/SearchSongsResult.kt | 95 ++++++++++---- 2 files changed, 158 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt index b32aac103..5bd54a669 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt @@ -1,68 +1,120 @@ package com.lalilu.lmusic.compose.screen.search.extensions +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.lalilu.RemixIcon import com.lalilu.component.LazyGridContent import com.lalilu.component.navigation.AppRouter import com.lalilu.lartist.component.ArtistCard import com.lalilu.lmedia.entity.LArtist import com.lalilu.lplayer.MPlayer +import com.lalilu.remixicon.Arrows +import com.lalilu.remixicon.UserAndFaces +import com.lalilu.remixicon.arrows.arrowDownSLine +import com.lalilu.remixicon.arrows.arrowUpSLine +import com.lalilu.remixicon.userandfaces.userLine class SearchArtistsResult( - private val artistsResult: () -> List + private val artistsResult: () -> List, ) : LazyGridContent { @Composable override fun register(): LazyGridScope.() -> Unit { + val collapsed = remember { mutableStateOf(false) } + return fun LazyGridScope.() { - if (artistsResult().isNotEmpty()){ - item( + if (artistsResult().isNotEmpty()) { + stickyHeader( key = "${this@SearchArtistsResult::class.java.name}_Header", - span = { GridItemSpan(maxLineSpan) } + contentType = "sticky" ) { - Text( + Row( modifier = Modifier - .animateItem() .fillMaxWidth() + .clickable { collapsed.value = !collapsed.value } .padding(16.dp), - text = "艺术家搜索结果", - fontSize = 16.sp, - lineHeight = 16.sp, - fontWeight = FontWeight.Bold - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.UserAndFaces.userLine, + contentDescription = null, + tint = MaterialTheme.colors.onBackground + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + text = "艺术家搜索结果 (${artistsResult().size})", + fontSize = 16.sp, + lineHeight = 16.sp, + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.Bold + ) + + AnimatedContent( + targetState = collapsed.value, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { collapsedValue -> + Icon( + imageVector = if (collapsedValue) RemixIcon.Arrows.arrowDownSLine + else RemixIcon.Arrows.arrowUpSLine, + contentDescription = null, + tint = MaterialTheme.colors.onBackground + ) + } + } } } - itemsIndexed( - items = artistsResult(), - key = { _, item -> item.id }, - contentType = { _, item -> item::class.java }, - span = { _, _ -> GridItemSpan(maxLineSpan) } - ) { index, item -> - ArtistCard( - modifier = Modifier - .fillMaxWidth() - .animateItem(), - title = item.name, - subTitle = "#$index", - songCount = item.songs.size.toLong(), - imageSource = { item.songs.firstOrNull() }, - isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, - onClick = { - AppRouter.route("/pages/artist/detail") - .with("artistName", item.id) - .push() - } - ) + if (!collapsed.value) { + itemsIndexed( + items = artistsResult(), + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + span = { _, _ -> GridItemSpan(maxLineSpan) } + ) { index, item -> + ArtistCard( + modifier = Modifier + .fillMaxWidth() + .animateItem(), + title = item.name, + subTitle = "#$index", + songCount = item.songs.size.toLong(), + imageSource = { item.songs.firstOrNull() }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, + onClick = { + AppRouter.route("/pages/artist/detail") + .with("artistName", item.id) + .push() + } + ) + } } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt index b78d2c234..d6e981a04 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt @@ -1,56 +1,109 @@ package com.lalilu.lmusic.compose.screen.search.extensions +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.lalilu.RemixIcon import com.lalilu.component.LazyGridContent import com.lalilu.component.card.SongCard import com.lalilu.lmedia.entity.LSong +import com.lalilu.remixicon.Arrows +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.arrows.arrowDownSLine +import com.lalilu.remixicon.arrows.arrowUpSLine +import com.lalilu.remixicon.media.music2Line class SearchSongsResult( - private val songsResult: () -> List + private val songsResult: () -> List, ) : LazyGridContent { @Composable override fun register(): LazyGridScope.() -> Unit { + val collapsed = remember { mutableStateOf(false) } + return fun LazyGridScope.() { if (songsResult().isNotEmpty()) { - item( + stickyHeader( key = "${this@SearchSongsResult::class.java.name}_Header", - span = { GridItemSpan(maxLineSpan) } + contentType = "sticky" ) { - Text( + Row( modifier = Modifier .fillMaxWidth() + .clickable { collapsed.value = !collapsed.value } .padding(16.dp), - text = "歌曲搜索结果", - fontSize = 16.sp, - lineHeight = 16.sp, - fontWeight = FontWeight.Bold - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = RemixIcon.Media.music2Line, + contentDescription = null, + tint = MaterialTheme.colors.onBackground + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + text = "歌曲搜索结果 (${songsResult().size})", + fontSize = 16.sp, + lineHeight = 16.sp, + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.Bold + ) + + AnimatedContent( + targetState = collapsed.value, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { collapsedValue -> + Icon( + imageVector = if (collapsedValue) RemixIcon.Arrows.arrowDownSLine + else RemixIcon.Arrows.arrowUpSLine, + contentDescription = null, + tint = MaterialTheme.colors.onBackground + ) + } + } } } - items( - items = songsResult(), - key = { it.id }, - contentType = { it::class.java }, - span = { GridItemSpan(maxLineSpan) } - ) { - SongCard( - modifier = Modifier - .fillMaxWidth() - .animateItem(), - song = { it } - ) + if (!collapsed.value) { + items( + items = songsResult(), + key = { it.id }, + contentType = { it::class.java }, + span = { GridItemSpan(maxLineSpan) } + ) { + SongCard( + modifier = Modifier + .fillMaxWidth() + .animateItem(), + song = { it } + ) + } } } } From d35a7d23538afccf6d45db2239141cf942ed46f1 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 1 Feb 2025 00:58:58 +0800 Subject: [PATCH 170/213] =?UTF-8?q?[modify]=E5=88=9D=E6=AD=A5=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E9=80=90=E5=AD=97=E6=AD=8C=E8=AF=8D=E7=9A=84=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/new_screen/SettingsScreen.kt | 9 +- .../compose/screen/playing/BlurBackground.kt | 2 - .../compose/screen/playing/PlayingLayout.kt | 11 +- .../screen/playing/lyric/LyricContent.kt | 34 +++ .../screen/playing/lyric/LyricLayout.kt | 142 ++++-------- .../screen/playing/lyric/LyricSentence.kt | 209 ------------------ .../playing/lyric/impl/LyricContentNormal.kt | 203 +++++++++++++++++ .../playing/lyric/impl/LyricContentWords.kt | 164 ++++++++++++++ .../screen/playing/lyric/utils/LyricUtils.kt | 65 ++++++ .../playing/lyric/utils/TextLayoutUtils.kt | 87 ++++++++ lmedia | 2 +- .../lplayer/service/MNotificationProvider.kt | 8 +- 12 files changed, 610 insertions(+), 326 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContent.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSentence.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt index 3ed1f5e3a..e723184da 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt @@ -11,13 +11,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource @@ -34,10 +34,10 @@ import com.lalilu.R import com.lalilu.RemixIcon import com.lalilu.common.CustomRomUtils import com.lalilu.component.IconTextButton -import com.lalilu.component.LLazyColumn import com.lalilu.component.base.NavigatorHeader import com.lalilu.component.base.screen.ScreenInfo import com.lalilu.component.base.screen.ScreenInfoFactory +import com.lalilu.component.base.smartBarPadding import com.lalilu.component.extension.rememberFixedStatusBarHeightDp import com.lalilu.component.settings.SettingCategory import com.lalilu.component.settings.SettingFilePicker @@ -85,7 +85,6 @@ private fun SettingsScreen( ) { val scope = rememberCoroutineScope() val context = LocalContext.current - val clipboardManager = LocalClipboardManager.current val darkModeOption = settingsSp.darkModeOption val ignoreAudioFocus = settingsSp.ignoreAudioFocus val enableUnknownFilter = settingsSp.enableUnknownFilter @@ -107,7 +106,7 @@ private fun SettingsScreen( ) { } - LLazyColumn( + LazyColumn( contentPadding = PaddingValues(top = rememberFixedStatusBarHeightDp()) ) { item { @@ -323,5 +322,7 @@ private fun SettingsScreen( } } } + + smartBarPadding() } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt index cb853322a..55c8da22b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.palette.graphics.Palette -import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.allowHardware @@ -28,7 +27,6 @@ import coil3.toBitmap import com.lalilu.common.getAutomaticColor import com.lalilu.lmusic.utils.StackBlurUtils -@OptIn(ExperimentalCoilApi::class) @Composable fun BlurBackground( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 71ae190c2..9aa6dd3a6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -53,9 +53,7 @@ import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar import com.lalilu.lmusic.compose.screen.playing.lyric.LyricLayout import com.lalilu.lmusic.compose.screen.playing.lyric.index -import com.lalilu.lmusic.compose.screen.playing.lyric.rememberFontFamilyFromPath -import com.lalilu.lmusic.compose.screen.playing.lyric.rememberTextAlignFromGravity -import com.lalilu.lmusic.compose.screen.playing.lyric.rememberTextSizeFromInt +import com.lalilu.lmusic.compose.screen.playing.lyric.utils.rememberFontFamilyFromPath import com.lalilu.lmusic.compose.screen.playing.seekbar.ClickPart import com.lalilu.lmusic.compose.screen.playing.seekbar.SeekbarLayout import com.lalilu.lmusic.datastore.SettingsSp @@ -271,12 +269,8 @@ fun PlayingLayout( lyricEntry = lyrics, listState = listState, currentTime = { seekbarTime.longValue }, - maxWidth = { constraints.maxWidth }, - textSize = rememberTextSizeFromInt { settingsSp.lyricTextSize.value }, - textAlign = rememberTextAlignFromGravity { settingsSp.lyricGravity.value }, + screenConstraints = constraints, fontFamily = rememberFontFamilyFromPath { settingsSp.lyricTypefacePath.value }, - isBlurredEnable = { !isLyricScrollEnable.value && settingsSp.isEnableBlurEffect.value }, - isTranslationShow = { settingsSp.isDrawTranslation.value }, isUserClickEnable = { draggable.state.value == DragAnchor.Max }, isUserScrollEnable = { isLyricScrollEnable.value }, onPositionReset = { @@ -292,7 +286,6 @@ fun PlayingLayout( }, onItemLongClick = { if (draggable.state.value == DragAnchor.Max) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) isLyricScrollEnable.value = !isLyricScrollEnable.value } }, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContent.kt new file mode 100644 index 000000000..13576bbb3 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContent.kt @@ -0,0 +1,34 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Constraints +import com.lalilu.lmedia.lyric.LyricItem + + +interface LyricContent { + val key: String + val item: LyricItem + + /** + * 歌词元素绘制 + * + * @param offsetToCurrent 此元素在列表中距离当前播放元素的偏移量 + * @param screenConstraints 屏幕的边界约束(预测量用) + */ + @Composable + fun Draw( + modifier: Modifier, + isCurrent: () -> Boolean, + offsetToCurrent: () -> Int, + currentTime: () -> Long, + screenConstraints: Constraints, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + textMeasurer: TextMeasurer, + fontFamily: () -> FontFamily? = { null }, + ) +} + diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt index 33ed3f884..eb495a5d3 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt @@ -1,6 +1,5 @@ package com.lalilu.lmusic.compose.screen.playing.lyric -import android.graphics.Typeface import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -33,83 +32,33 @@ import androidx.compose.ui.graphics.Color 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 +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.lmedia.lyric.LyricItem -import com.lalilu.lmedia.lyric.LyricUtils +import com.lalilu.lmedia.lyric.findPlayingIndex +import com.lalilu.lmedia.lyric.toNormal +import com.lalilu.lmusic.compose.screen.playing.lyric.impl.LyricContentNormal +import com.lalilu.lmusic.compose.screen.playing.lyric.impl.LyricContentWords import com.lalilu.lmusic.utils.extension.edgeTransparent import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.isActive -import java.io.File import java.util.WeakHashMap import kotlin.math.abs -/** - * 读取字体文件,并将其转换成Compose可用的FontFamily - * - * @param path 字体所在路径 - * @return 字体文件对应的FontFamily - */ -@Composable -fun rememberFontFamilyFromPath(path: () -> String?): State { - val fontFamily = remember { mutableStateOf(null) } - - LaunchedEffect(path()) { - val fontFile = path()?.takeIf { it.isNotBlank() } - ?.let { File(it) } - ?.takeIf { it.exists() && it.canRead() } - ?: return@LaunchedEffect - - fontFamily.value = runCatching { FontFamily(Typeface.createFromFile(fontFile)) } - .getOrNull() - } - - return fontFamily -} - -/** - * 将存储的Gravity的Int值转换成Compose可用的TextAlign - */ -@Composable -fun rememberTextAlignFromGravity(gravity: () -> Int?): TextAlign { - return remember(gravity()) { - when (gravity()) { - 0 -> TextAlign.Start - 1 -> TextAlign.Center - 2 -> TextAlign.End - else -> TextAlign.Start - } - } -} - -/** - * 将存储的Int值转换成Compose可用的TextUnit - */ -@Composable -fun rememberTextSizeFromInt(textSize: () -> Int?): TextUnit { - return remember(textSize()) { textSize()?.takeIf { it > 0 }?.sp ?: 26.sp } -} - -private val EMPTY_SENTENCE_TIPS = LyricItem.SingleLyric( - time = 0, - content = "暂无歌词", -) - private val indexKeeper = WeakHashMap() var LyricItem.index: Int get() = indexKeeper[this] ?: -1 set(value) = run { indexKeeper[this] = value } -val LyricItem.key +val LyricItem.tempKey get() = "${index}:$time" @OptIn(FlowPreview::class) @@ -118,13 +67,9 @@ fun LyricLayout( modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), currentTime: () -> Long = { 0L }, - maxWidth: () -> Int = { 1080 }, - textSize: TextUnit = 26.sp, - textAlign: TextAlign = TextAlign.Start, - isBlurredEnable: () -> Boolean = { false }, + screenConstraints: Constraints, isUserClickEnable: () -> Boolean = { false }, isUserScrollEnable: () -> Boolean = { false }, - isTranslationShow: () -> Boolean = { false }, onPositionReset: () -> Unit = {}, onItemClick: (LyricItem) -> Unit = {}, onItemLongClick: (LyricItem) -> Unit = {}, @@ -145,7 +90,28 @@ fun LyricLayout( val time = currentTime() val lyricEntryList = lyricEntry.value - LyricUtils.findPlayingIndex(time, lyricEntryList) + lyricEntryList.findPlayingIndex(time) + } + } + + val lyrics: State> = remember { + derivedStateOf { + lyricEntry.value.mapNotNull { + when (it) { + is LyricItem.WordsLyric -> LyricContentWords( + key = it.tempKey, + lyric = it + ) + + else -> { + val item = it.toNormal() ?: return@mapNotNull null + LyricContentNormal( + key = item.tempKey, + lyric = item + ) + } + } + } } } @@ -159,7 +125,7 @@ fun LyricLayout( BackHandler(enabled = isUserScrolling.value) { isUserScrolling.value = false - currentItem.value?.key?.let(scroller::animateTo) + currentItem.value?.tempKey?.let(scroller::animateTo) onPositionReset() } @@ -167,7 +133,7 @@ fun LyricLayout( snapshotFlow { currentItem.value } .collectLatest { it ?: return@collectLatest - scroller.animateTo(it.key) + scroller.animateTo(it.tempKey) } } @@ -179,7 +145,7 @@ fun LyricLayout( if (!isActive || isDragging || !isScrolling) return@collectLatest isUserScrolling.value = false - currentItem.value?.key?.let(scroller::animateTo) + currentItem.value?.tempKey?.let(scroller::animateTo) onPositionReset() } } @@ -194,46 +160,26 @@ fun LyricLayout( contentPadding = remember { PaddingValues(top = 300.dp, bottom = 500.dp) } ) { startRecord(recorder) { - if (lyricEntry.value.isEmpty()) { + if (lyrics.value.isEmpty()) { itemWithRecord(key = "EMPTY_TIPS") { - LyricSentence( - lyric = EMPTY_SENTENCE_TIPS, - maxWidth = maxWidth, - textMeasurer = textMeasurer, - fontFamily = fontFamily, - textAlign = textAlign, - textSize = textSize, - currentTime = currentTime, - isBlurredEnable = isBlurredEnable, - isTranslationShow = isTranslationShow, - isCurrent = { true }, - onLongClick = { - if (isUserClickEnable()) onItemLongClick( - EMPTY_SENTENCE_TIPS - ) - } - ) + Text("暂无歌词") } } else { itemsIndexedWithRecord( - items = lyricEntry.value, + items = lyrics.value, key = { _, item -> item.key }, contentType = { _, _ -> LyricItem::class } ) { index, item -> - LyricSentence( - lyric = item, - maxWidth = maxWidth, + item.Draw( + modifier = Modifier, textMeasurer = textMeasurer, - fontFamily = fontFamily, - textAlign = textAlign, - textSize = textSize, + fontFamily = { fontFamily.value }, currentTime = currentTime, - positionToCurrent = { abs(index - currentItemIndex.value) }, - isBlurredEnable = isBlurredEnable, - isTranslationShow = isTranslationShow, - isCurrent = { item.key == currentItem.value?.key }, - onLongClick = { if (isUserClickEnable()) onItemLongClick(item) }, - onClick = { if (isUserClickEnable()) onItemClick(item) } + screenConstraints = screenConstraints, + offsetToCurrent = { abs(index - currentItemIndex.value) }, + isCurrent = { item.key == currentItem.value?.tempKey }, + onLongClick = { if (isUserClickEnable()) onItemLongClick(item.item) }, + onClick = { if (isUserClickEnable()) onItemClick(item.item) } ) } } @@ -261,7 +207,7 @@ fun LyricLayout( colors = colors, onClick = { isUserScrolling.value = false - currentItem.value?.key?.let(scroller::animateTo) + currentItem.value?.tempKey?.let(scroller::animateTo) onPositionReset() } ) { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSentence.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSentence.kt deleted file mode 100644 index beb3ca4ce..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSentence.kt +++ /dev/null @@ -1,209 +0,0 @@ -package com.lalilu.lmusic.compose.screen.playing.lyric - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.graphics.drawscope.scale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.lalilu.lmedia.lyric.LyricItem - - -@Composable -fun LyricSentence( - modifier: Modifier = Modifier, - lyric: LyricItem, - textMeasurer: TextMeasurer, - maxWidth: () -> Int = { 1080 }, - currentTime: () -> Long = { 0L }, - positionToCurrent: () -> Int = { 0 }, - fontFamily: State, - textSize: TextUnit = 26.sp, - textAlign: TextAlign = TextAlign.Start, - translationGap: Dp = 10.dp, - translationScale: Float = 0.8f, - isBlurredEnable: () -> Boolean = { false }, - isTranslationShow: () -> Boolean = { false }, - isCurrent: () -> Boolean, - onLongClick: () -> Unit = {}, - onClick: () -> Unit = {}, -) { - val density = LocalDensity.current - val paddingVertical = remember { 15.dp } - val paddingHorizontal = remember { 40.dp } - val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } - val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } - val gapHeight = remember(translationGap) { with(density) { translationGap.toPx() } } - - val actualConstraints = remember { - val width = maxWidth() - paddingHorizontalPx * 2 - Constraints( - maxWidth = width, - minWidth = width, - maxHeight = Int.MAX_VALUE - ) - } - val (textResult, translateResult) = remember(textAlign, textSize, fontFamily, lyric) { - when (lyric) { - is LyricItem.SingleLyric -> { - textMeasurer.measure( - text = lyric.content, - constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize, - textAlign = textAlign, - fontFamily = fontFamily.value - ?: TextStyle.Default.fontFamily - ) - ) to null - } - - is LyricItem.TranslatedLyric -> { - textMeasurer.measure( - text = lyric.content, - constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize, - textAlign = textAlign, - fontFamily = fontFamily.value - ?: TextStyle.Default.fontFamily - ) - ) to textMeasurer.measure( - text = lyric.translated, - constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize * translationScale, - textAlign = textAlign, - fontFamily = fontFamily.value - ?: TextStyle.Default.fontFamily - ) - ) - } - } - } - - val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } - val translateHeight = remember(translateResult) { - translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f - } - val height = remember(isTranslationShow(), textHeight, translateHeight) { - textHeight + if (isTranslationShow() && translateHeight > 0) translateHeight + gapHeight else 0f - } - val heightDp = remember(height) { density.run { height.toDp() + paddingVertical * 2 } } - val animateHeight = animateDpAsState( - targetValue = heightDp, - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ), - label = "" - ) - - val animateAlpha = animateFloatAsState( - targetValue = if (isTranslationShow()) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow - ), - label = "" - ) - - val color = animateColorAsState( - targetValue = if (isCurrent()) Color.White else Color(0x80FFFFFF), - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow - ), - label = "" - ) - - val scale = animateFloatAsState( - targetValue = if (isCurrent()) 100f else 90f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow - ), - label = "" - ) - - val textShadow = remember { - Shadow( - color = Color.Black.copy(alpha = 0.2f), - offset = Offset(x = 0f, y = 1f), - blurRadius = 1f - ) - } - val translationTopLeft = remember(textHeight) { - Offset.Zero.copy(y = textHeight + gapHeight) - } - val pivotOffset = remember(height, textAlign) { - val width = maxWidth() - val x = when (textAlign) { - TextAlign.End -> width.toFloat() - TextAlign.Center -> width / 2f - else -> 0f - } - Offset.Zero.copy(y = height / 2f, x = x) - } - val blurRadius = remember { - derivedStateOf { - if (!isBlurredEnable()) return@derivedStateOf 0.dp - positionToCurrent().coerceAtMost(5).dp - } - } - val animateBlurRadius = animateDpAsState(targetValue = blurRadius.value, label = "") - - Canvas( - modifier = modifier - .blur(animateBlurRadius.value, BlurredEdgeTreatment.Unbounded) // TODO 对性能影响较大,待进一步优化 - .fillMaxWidth() - .height(animateHeight.value) - .combinedClickable(onLongClick = onLongClick, onClick = onClick) - .padding(vertical = paddingVertical, horizontal = paddingHorizontal) - ) { - scale( - scale = scale.value / 100f, - pivot = pivotOffset - ) { - drawText( - color = color.value, - shadow = textShadow, - textLayoutResult = textResult - ) - - if (translateResult == null) return@scale - drawText( - color = color.value, - topLeft = translationTopLeft, - textLayoutResult = translateResult, - alpha = animateAlpha.value - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt new file mode 100644 index 000000000..e5a140695 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt @@ -0,0 +1,203 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric.impl + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.lmedia.lyric.LyricItem +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricContent + + +data class LyricContentNormal( + override val key: String, + val lyric: LyricItem.NormalLyric, +) : LyricContent { + override val item: LyricItem = lyric + val translationGap: Dp = 10.dp + val textAlign: TextAlign = TextAlign.Start + val textSize: TextUnit = 26.sp + val translationScale: Float = 0.8f + val isTranslationShow: () -> Boolean = { true } + val isBlurredEnable: () -> Boolean = { false } + + @Composable + override fun Draw( + modifier: Modifier, + isCurrent: () -> Boolean, + offsetToCurrent: () -> Int, + currentTime: () -> Long, + screenConstraints: Constraints, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + textMeasurer: TextMeasurer, + fontFamily: () -> FontFamily?, + ) { + val density = LocalDensity.current + val paddingVertical = remember { 15.dp } + val paddingHorizontal = remember { 40.dp } + val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } + val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } + val gapHeight = remember(translationGap) { with(density) { translationGap.toPx() } } + + val actualConstraints = remember { + val width = screenConstraints.maxWidth - paddingHorizontalPx * 2 + Constraints( + maxWidth = width, + minWidth = width, + maxHeight = Int.MAX_VALUE + ) + } + val (textResult, translateResult) = remember(textAlign, textSize, fontFamily, lyric) { + textMeasurer.measure( + text = lyric.content, + constraints = actualConstraints, + style = TextStyle.Default.copy( + fontSize = textSize, + textAlign = textAlign, + fontFamily = fontFamily() ?: TextStyle.Default.fontFamily + ) + ) to lyric.translation + ?.takeIf(String::isNotBlank) + ?.let { + textMeasurer.measure( + text = it, + constraints = actualConstraints, + style = TextStyle.Default.copy( + fontSize = textSize * translationScale, + textAlign = textAlign, + fontFamily = fontFamily() ?: TextStyle.Default.fontFamily + ) + ) + } + } + + val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } + val translateHeight = remember(translateResult) { + translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f + } + val height = remember(isTranslationShow(), textHeight, translateHeight) { + textHeight + if (isTranslationShow() && translateHeight > 0) translateHeight + gapHeight else 0f + } + val heightDp = remember(height) { density.run { height.toDp() + paddingVertical * 2 } } + val animateHeight = animateDpAsState( + targetValue = heightDp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + + val animateAlpha = animateFloatAsState( + targetValue = if (isTranslationShow()) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ), + label = "" + ) + + val color = animateColorAsState( + targetValue = if (isCurrent()) Color.White else Color(0x80FFFFFF), + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + + val scale = animateFloatAsState( + targetValue = if (isCurrent()) 100f else 90f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + + val textShadow = remember { + Shadow( + color = Color.Black.copy(alpha = 0.2f), + offset = Offset(x = 0f, y = 1f), + blurRadius = 1f + ) + } + val translationTopLeft = remember(textHeight) { + Offset.Zero.copy(y = textHeight + gapHeight) + } + val pivotOffset = remember(height, textAlign) { + val width = screenConstraints.maxWidth + val x = when (textAlign) { + TextAlign.End -> width.toFloat() + TextAlign.Center -> width / 2f + else -> 0f + } + Offset.Zero.copy(y = height / 2f, x = x) + } + val blurRadius = remember { + derivedStateOf { + if (!isBlurredEnable()) return@derivedStateOf 0.dp + offsetToCurrent().coerceAtMost(5).dp + } + } + val animateBlurRadius = animateDpAsState(targetValue = blurRadius.value, label = "") + + Canvas( + modifier = modifier + .blur( + animateBlurRadius.value, + BlurredEdgeTreatment.Unbounded + ) // TODO 对性能影响较大,待进一步优化 + .fillMaxWidth() + .height(animateHeight.value) + .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) + .padding(vertical = paddingVertical, horizontal = paddingHorizontal) + ) { + scale( + scale = scale.value / 100f, + pivot = pivotOffset + ) { + drawText( + color = color.value, + shadow = textShadow, + textLayoutResult = textResult + ) + + if (translateResult == null) return@scale + drawText( + color = color.value, + topLeft = translationTopLeft, + textLayoutResult = translateResult, + alpha = animateAlpha.value + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt new file mode 100644 index 000000000..0618ec648 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt @@ -0,0 +1,164 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric.impl + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.lmedia.lyric.LyricItem +import com.lalilu.lmedia.lyric.findPlayingIndexForWords +import com.lalilu.lmedia.lyric.getSentenceContent +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricContent +import com.lalilu.lmusic.compose.screen.playing.lyric.utils.getPathForProgress +import com.lalilu.lmusic.compose.screen.playing.lyric.utils.normalized + +class LyricContentWords( + override val key: String, + val lyric: LyricItem.WordsLyric, +) : LyricContent { + override val item: LyricItem = lyric + + @Composable + override fun Draw( + modifier: Modifier, + isCurrent: () -> Boolean, + offsetToCurrent: () -> Int, + currentTime: () -> Long, + screenConstraints: Constraints, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + textMeasurer: TextMeasurer, + fontFamily: () -> FontFamily? + ) { + val density = LocalDensity.current + val paddingVertical = remember { 15.dp } + val paddingHorizontal = remember { 40.dp } + val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } + val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } + + val actualConstraints = remember { + val width = screenConstraints.maxWidth - paddingHorizontalPx * 2 + Constraints( + maxWidth = width, + minWidth = width, + maxHeight = Int.MAX_VALUE + ) + } + + val textResult = remember { + textMeasurer.measure( + text = lyric.getSentenceContent(), + constraints = actualConstraints, + style = TextStyle.Default.copy( + fontSize = 26.sp, + textAlign = TextAlign.Start, + fontFamily = fontFamily() ?: TextStyle.Default.fontFamily + ) + ) + } + val scale = animateFloatAsState( + targetValue = if (isCurrent()) 100f else 90f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } + val heightDp = + remember(textHeight) { density.run { textHeight.toDp() + paddingVertical * 2 } } + val animateHeight = animateDpAsState( + targetValue = heightDp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + val pivotOffset = remember(textHeight) { + Offset.Zero.copy(y = textHeight / 2f, x = 0f) + } + val textShadow = remember { + Shadow( + color = Color.Black.copy(alpha = 0.2f), + offset = Offset(x = 0f, y = 1f), + blurRadius = 1f + ) + } + + Canvas( + modifier = modifier + .fillMaxWidth() + .height(animateHeight.value) + .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) + .padding(vertical = paddingVertical, horizontal = paddingHorizontal) + ) { + scale( + scale = scale.value / 100f, + pivot = pivotOffset + ) { + drawText( + color = Color(0x80FFFFFF), + shadow = textShadow, + textLayoutResult = textResult + ) + +// val progress = normalized( +// start = lyric.startTime, +// end = lyric.endTime, +// current = currentTime() +// ) + + if (isCurrent()) { + val index = lyric.words.findPlayingIndexForWords(currentTime()) + val word = lyric.words.getOrNull(index) ?: return@scale + val progress = normalized( + start = word.startTime, + end = word.endTime, + current = currentTime() + ) + val offset = lyric.words.take(index) + .sumOf { it.content.length } + + val path = textResult.getPathForProgress( + progress = progress, + offset = offset, + length = word.content.length + ) + + clipPath( + path = path + ) { + drawText( + color = Color(0xFFFFFFFF), + shadow = textShadow, + textLayoutResult = textResult + ) + } + } + } + } + } +} + diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt new file mode 100644 index 000000000..e58f4309e --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt @@ -0,0 +1,65 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric.utils + +import android.graphics.Typeface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import java.io.File + +/** + * 读取字体文件,并将其转换成Compose可用的FontFamily + * + * @param path 字体所在路径 + * @return 字体文件对应的FontFamily + */ +@Composable +fun rememberFontFamilyFromPath(path: () -> String?): State { + val fontFamily = remember { mutableStateOf(null) } + + LaunchedEffect(path()) { + val fontFile = path()?.takeIf { it.isNotBlank() } + ?.let { File(it) } + ?.takeIf { it.exists() && it.canRead() } + ?: return@LaunchedEffect + + fontFamily.value = runCatching { FontFamily(Typeface.createFromFile(fontFile)) } + .getOrNull() + } + + return fontFamily +} + +/** + * 将存储的Gravity的Int值转换成Compose可用的TextAlign + */ +@Composable +fun rememberTextAlignFromGravity(gravity: () -> Int?): TextAlign { + return remember(gravity()) { + when (gravity()) { + 0 -> TextAlign.Start + 1 -> TextAlign.Center + 2 -> TextAlign.End + else -> TextAlign.Start + } + } +} + +/** + * 将存储的Int值转换成Compose可用的TextUnit + */ +@Composable +fun rememberTextSizeFromInt(textSize: () -> Int?): TextUnit { + return remember(textSize()) { textSize()?.takeIf { it > 0 }?.sp ?: 26.sp } +} + +fun normalized(start: Long, end: Long, current: Long): Float { + if (start >= end) return 0f + val result = (current - start).toFloat() / (end - start).toFloat() + return result.coerceIn(0f, 1f) +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt new file mode 100644 index 000000000..9d8f0bc45 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt @@ -0,0 +1,87 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric.utils + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.abs + +/** + * 获取指定行的宽度 + */ +fun TextLayoutResult.getLineWidth(lineIndex: Int): Float { + return abs(getLineRight(lineIndex) - getLineLeft(lineIndex)) +} + +/** + * 获取指定行的矩形 + */ +fun TextLayoutResult.getLineRect(lineIndex: Int): Rect { + return Rect( + left = getLineLeft(lineIndex), + right = getLineRight(lineIndex), + top = getLineTop(lineIndex), + bottom = getLineBottom(lineIndex) + ) +} + +/** + * 获取指定行与其之前的所有行的宽度之和 + */ +fun TextLayoutResult.sumWidthForLine(lineIndex: Int): Int { + if (lineIndex < 0) return 0 + return (0..lineIndex).sumOf { getLineWidth(it).toInt() } +} + +/** + * 获取指定字符偏移值对应的宽度 + */ +fun TextLayoutResult.getWidthForOffset(offset: Int): Int { + val lineIndex = getLineForOffset(offset) + val position = getHorizontalPosition(offset, true).toInt() + return sumWidthForLine(lineIndex - 1) + position +} + +/** + * 获取指定进度对应的路径 + * + * @param progress 进度 + * @param offset 起始偏移值 + * @param length 长度 + */ +fun TextLayoutResult.getPathForProgress( + progress: Float, + offset: Int = 0, + length: Int? = null +): Path { + val offsetWidth = getWidthForOffset(offset) + val maxWidth = if (length == null) { + sumWidthForLine(lineCount - 1) + } else { + getWidthForOffset(offset + length) + } + + val targetWidth = offsetWidth + (maxWidth - offsetWidth) * progress + var addedWidth = 0f + + val path = Path() + for (lineIndex in 0 until lineCount) { + val lineWidth = getLineWidth(lineIndex) + + // 若加上该行宽度会超出目标宽度,则说明该行已经超出目标宽度,此时需要截取该行 + if (addedWidth + lineWidth >= targetWidth) { + val widthToAdd = targetWidth - addedWidth + + val rect = getLineRect(lineIndex) + .let { it.copy(right = it.left + widthToAdd) } + path.addRect(rect) + addedWidth += widthToAdd + break + } else { + val rect = getLineRect(lineIndex) + path.addRect(rect) + addedWidth += lineWidth + } + } + + return path +} \ No newline at end of file diff --git a/lmedia b/lmedia index cde959aac..8bfe86d70 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit cde959aac62525cf3ad83aa782963e6f5553dd38 +Subproject commit 8bfe86d703cde4b020d34b59d38e2fb1a637f7a8 diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt index b23eb0805..9d4efa928 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt @@ -31,6 +31,8 @@ import com.lalilu.common.post import com.lalilu.lmedia.lyric.LyricItem import com.lalilu.lmedia.lyric.LyricSourceEmbedded import com.lalilu.lmedia.lyric.LyricUtils +import com.lalilu.lmedia.lyric.findPlayingIndex +import com.lalilu.lmedia.lyric.getSentenceContent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -189,7 +191,7 @@ class MNotificationProvider( val list = lyrics?.second ?: break val time = withContext(Dispatchers.Main) { mediaSession.player.currentPosition } - val index = LyricUtils.findPlayingIndex(time, list) + val index = list.findPlayingIndex(time) if (lastIndex == index) { delay(50) continue @@ -201,8 +203,8 @@ class MNotificationProvider( if (current != null) { post { val text = when (current) { - is LyricItem.SingleLyric -> current.content - is LyricItem.TranslatedLyric -> current.content + is LyricItem.NormalLyric -> current.content + is LyricItem.WordsLyric -> current.getSentenceContent() else -> "" } From 6686eb917313d1ecb6d0f7c3cbbc09d7afdbdbda Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 2 Feb 2025 00:39:51 +0800 Subject: [PATCH 171/213] =?UTF-8?q?[modify]=E5=AE=9E=E7=8E=B0=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E7=9A=84=E8=AF=8D=E4=B8=8E=E8=AF=8D=E6=B8=90=E5=8F=98?= =?UTF-8?q?=E8=BF=87=E6=B8=A1=E6=95=88=E6=9E=9C=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=80=90=E5=AD=97=E6=AD=8C=E8=AF=8D=E7=BB=98?= =?UTF-8?q?=E5=88=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/screen/playing/PlayingLayout.kt | 2 - .../screen/playing/lyric/LyricContent.kt | 34 -- .../screen/playing/lyric/LyricLayout.kt | 81 ++--- .../playing/lyric/impl/LyricContentNormal.kt | 295 +++++++++-------- .../playing/lyric/impl/LyricContentWords.kt | 302 +++++++++++------- .../screen/playing/lyric/utils/LyricUtils.kt | 6 + .../playing/lyric/utils/TextLayoutUtils.kt | 40 ++- lmedia | 2 +- 8 files changed, 411 insertions(+), 351 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContent.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 9aa6dd3a6..c182ddcdf 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -52,7 +52,6 @@ import com.lalilu.lmedia.lyric.LyricUtils import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar import com.lalilu.lmusic.compose.screen.playing.lyric.LyricLayout -import com.lalilu.lmusic.compose.screen.playing.lyric.index import com.lalilu.lmusic.compose.screen.playing.lyric.utils.rememberFontFamilyFromPath import com.lalilu.lmusic.compose.screen.playing.seekbar.ClickPart import com.lalilu.lmusic.compose.screen.playing.seekbar.SeekbarLayout @@ -245,7 +244,6 @@ fun PlayingLayout( MPlayer.currentMediaItem ?.let { lyricSource.loadLyric(it) } ?.let { LyricUtils.parseLrc(it.first, it.second) } - ?.mapIndexed { index, lyricItem -> lyricItem.also { it.index = index } } .let { if (isActive) lyrics.value = it ?: emptyList() } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContent.kt deleted file mode 100644 index 13576bbb3..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContent.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.lalilu.lmusic.compose.screen.playing.lyric - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.Constraints -import com.lalilu.lmedia.lyric.LyricItem - - -interface LyricContent { - val key: String - val item: LyricItem - - /** - * 歌词元素绘制 - * - * @param offsetToCurrent 此元素在列表中距离当前播放元素的偏移量 - * @param screenConstraints 屏幕的边界约束(预测量用) - */ - @Composable - fun Draw( - modifier: Modifier, - isCurrent: () -> Boolean, - offsetToCurrent: () -> Int, - currentTime: () -> Long, - screenConstraints: Constraints, - onClick: (() -> Unit)? = null, - onLongClick: (() -> Unit)? = null, - textMeasurer: TextMeasurer, - fontFamily: () -> FontFamily? = { null }, - ) -} - diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt index eb495a5d3..bfe569565 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt @@ -41,7 +41,6 @@ import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.lmedia.lyric.LyricItem import com.lalilu.lmedia.lyric.findPlayingIndex -import com.lalilu.lmedia.lyric.toNormal import com.lalilu.lmusic.compose.screen.playing.lyric.impl.LyricContentNormal import com.lalilu.lmusic.compose.screen.playing.lyric.impl.LyricContentWords import com.lalilu.lmusic.utils.extension.edgeTransparent @@ -49,18 +48,9 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.isActive -import java.util.WeakHashMap import kotlin.math.abs -private val indexKeeper = WeakHashMap() -var LyricItem.index: Int - get() = indexKeeper[this] ?: -1 - set(value) = run { indexKeeper[this] = value } - -val LyricItem.tempKey - get() = "${index}:$time" - @OptIn(FlowPreview::class) @Composable fun LyricLayout( @@ -94,27 +84,6 @@ fun LyricLayout( } } - val lyrics: State> = remember { - derivedStateOf { - lyricEntry.value.mapNotNull { - when (it) { - is LyricItem.WordsLyric -> LyricContentWords( - key = it.tempKey, - lyric = it - ) - - else -> { - val item = it.toNormal() ?: return@mapNotNull null - LyricContentNormal( - key = item.tempKey, - lyric = item - ) - } - } - } - } - } - val currentItem: State = remember { derivedStateOf { currentItemIndex.value @@ -125,7 +94,7 @@ fun LyricLayout( BackHandler(enabled = isUserScrolling.value) { isUserScrolling.value = false - currentItem.value?.tempKey?.let(scroller::animateTo) + currentItem.value?.key?.let(scroller::animateTo) onPositionReset() } @@ -133,7 +102,7 @@ fun LyricLayout( snapshotFlow { currentItem.value } .collectLatest { it ?: return@collectLatest - scroller.animateTo(it.tempKey) + scroller.animateTo(it.key) } } @@ -145,7 +114,7 @@ fun LyricLayout( if (!isActive || isDragging || !isScrolling) return@collectLatest isUserScrolling.value = false - currentItem.value?.tempKey?.let(scroller::animateTo) + currentItem.value?.key?.let(scroller::animateTo) onPositionReset() } } @@ -160,27 +129,43 @@ fun LyricLayout( contentPadding = remember { PaddingValues(top = 300.dp, bottom = 500.dp) } ) { startRecord(recorder) { - if (lyrics.value.isEmpty()) { + if (lyricEntry.value.isEmpty()) { itemWithRecord(key = "EMPTY_TIPS") { Text("暂无歌词") } } else { itemsIndexedWithRecord( - items = lyrics.value, + items = lyricEntry.value, key = { _, item -> item.key }, contentType = { _, _ -> LyricItem::class } ) { index, item -> - item.Draw( - modifier = Modifier, - textMeasurer = textMeasurer, - fontFamily = { fontFamily.value }, - currentTime = currentTime, - screenConstraints = screenConstraints, - offsetToCurrent = { abs(index - currentItemIndex.value) }, - isCurrent = { item.key == currentItem.value?.tempKey }, - onLongClick = { if (isUserClickEnable()) onItemLongClick(item.item) }, - onClick = { if (isUserClickEnable()) onItemClick(item.item) } - ) + when (item) { + is LyricItem.NormalLyric -> LyricContentNormal( + lyric = item, + modifier = Modifier, + textMeasurer = textMeasurer, + fontFamily = { fontFamily.value }, + currentTime = currentTime, + screenConstraints = screenConstraints, + offsetToCurrent = { abs(index - currentItemIndex.value) }, + isCurrent = { item.key == currentItem.value?.key }, + onLongClick = { if (isUserClickEnable()) onItemLongClick(item) }, + onClick = { if (isUserClickEnable()) onItemClick(item) } + ) + + is LyricItem.WordsLyric -> LyricContentWords( + lyric = item, + modifier = Modifier, + textMeasurer = textMeasurer, + fontFamily = { fontFamily.value }, + currentTime = currentTime, + screenConstraints = screenConstraints, + offsetToCurrent = { abs(index - currentItemIndex.value) }, + isCurrent = { item.key == currentItem.value?.key }, + onLongClick = { if (isUserClickEnable()) onItemLongClick(item) }, + onClick = { if (isUserClickEnable()) onItemClick(item) } + ) + } } } } @@ -207,7 +192,7 @@ fun LyricLayout( colors = colors, onClick = { isUserScrolling.value = false - currentItem.value?.tempKey?.let(scroller::animateTo) + currentItem.value?.key?.let(scroller::animateTo) onPositionReset() } ) { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt index e5a140695..67386fcce 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt @@ -32,172 +32,167 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.lmedia.lyric.LyricItem -import com.lalilu.lmusic.compose.screen.playing.lyric.LyricContent -data class LyricContentNormal( - override val key: String, - val lyric: LyricItem.NormalLyric, -) : LyricContent { - override val item: LyricItem = lyric - val translationGap: Dp = 10.dp - val textAlign: TextAlign = TextAlign.Start - val textSize: TextUnit = 26.sp - val translationScale: Float = 0.8f - val isTranslationShow: () -> Boolean = { true } - val isBlurredEnable: () -> Boolean = { false } +val translationGap: Dp = 10.dp +val textAlign: TextAlign = TextAlign.Start +val textSize: TextUnit = 26.sp +val translationScale: Float = 0.8f +val isTranslationShow: () -> Boolean = { true } +val isBlurredEnable: () -> Boolean = { false } - @Composable - override fun Draw( - modifier: Modifier, - isCurrent: () -> Boolean, - offsetToCurrent: () -> Int, - currentTime: () -> Long, - screenConstraints: Constraints, - onClick: (() -> Unit)?, - onLongClick: (() -> Unit)?, - textMeasurer: TextMeasurer, - fontFamily: () -> FontFamily?, - ) { - val density = LocalDensity.current - val paddingVertical = remember { 15.dp } - val paddingHorizontal = remember { 40.dp } - val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } - val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } - val gapHeight = remember(translationGap) { with(density) { translationGap.toPx() } } - val actualConstraints = remember { - val width = screenConstraints.maxWidth - paddingHorizontalPx * 2 - Constraints( - maxWidth = width, - minWidth = width, - maxHeight = Int.MAX_VALUE +@Composable +fun LyricContentNormal( + lyric: LyricItem.NormalLyric, + modifier: Modifier = Modifier, + isCurrent: () -> Boolean, + offsetToCurrent: () -> Int, + currentTime: () -> Long, + screenConstraints: Constraints, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + textMeasurer: TextMeasurer, + fontFamily: () -> FontFamily?, +) { + val density = LocalDensity.current + val paddingVertical = remember { 15.dp } + val paddingHorizontal = remember { 40.dp } + val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } + val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } + val gapHeight = remember(translationGap) { with(density) { translationGap.toPx() } } + + val actualConstraints = remember { + val width = screenConstraints.maxWidth - paddingHorizontalPx * 2 + Constraints( + maxWidth = width, + minWidth = width, + maxHeight = Int.MAX_VALUE + ) + } + val (textResult, translateResult) = remember(textAlign, textSize, fontFamily, lyric) { + textMeasurer.measure( + text = lyric.content, + constraints = actualConstraints, + style = TextStyle.Default.copy( + fontSize = textSize, + textAlign = textAlign, + fontFamily = fontFamily() ?: TextStyle.Default.fontFamily ) - } - val (textResult, translateResult) = remember(textAlign, textSize, fontFamily, lyric) { - textMeasurer.measure( - text = lyric.content, - constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize, - textAlign = textAlign, - fontFamily = fontFamily() ?: TextStyle.Default.fontFamily - ) - ) to lyric.translation - ?.takeIf(String::isNotBlank) - ?.let { - textMeasurer.measure( - text = it, - constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize * translationScale, - textAlign = textAlign, - fontFamily = fontFamily() ?: TextStyle.Default.fontFamily - ) + ) to lyric.translation + ?.takeIf(String::isNotBlank) + ?.let { + textMeasurer.measure( + text = it, + constraints = actualConstraints, + style = TextStyle.Default.copy( + fontSize = textSize * translationScale, + textAlign = textAlign, + fontFamily = fontFamily() ?: TextStyle.Default.fontFamily ) - } - } + ) + } + } - val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } - val translateHeight = remember(translateResult) { - translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f - } - val height = remember(isTranslationShow(), textHeight, translateHeight) { - textHeight + if (isTranslationShow() && translateHeight > 0) translateHeight + gapHeight else 0f - } - val heightDp = remember(height) { density.run { height.toDp() + paddingVertical * 2 } } - val animateHeight = animateDpAsState( - targetValue = heightDp, - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ), - label = "" - ) + val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } + val translateHeight = remember(translateResult) { + translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f + } + val height = remember(isTranslationShow(), textHeight, translateHeight) { + textHeight + if (isTranslationShow() && translateHeight > 0) translateHeight + gapHeight else 0f + } + val heightDp = remember(height) { density.run { height.toDp() + paddingVertical * 2 } } + val animateHeight = animateDpAsState( + targetValue = heightDp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) - val animateAlpha = animateFloatAsState( - targetValue = if (isTranslationShow()) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow - ), - label = "" - ) + val animateAlpha = animateFloatAsState( + targetValue = if (isTranslationShow()) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ), + label = "" + ) - val color = animateColorAsState( - targetValue = if (isCurrent()) Color.White else Color(0x80FFFFFF), - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow - ), - label = "" - ) + val color = animateColorAsState( + targetValue = if (isCurrent()) Color.White else Color(0x80FFFFFF), + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) - val scale = animateFloatAsState( - targetValue = if (isCurrent()) 100f else 90f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow - ), - label = "" - ) + val scale = animateFloatAsState( + targetValue = if (isCurrent()) 100f else 90f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) - val textShadow = remember { - Shadow( - color = Color.Black.copy(alpha = 0.2f), - offset = Offset(x = 0f, y = 1f), - blurRadius = 1f - ) - } - val translationTopLeft = remember(textHeight) { - Offset.Zero.copy(y = textHeight + gapHeight) - } - val pivotOffset = remember(height, textAlign) { - val width = screenConstraints.maxWidth - val x = when (textAlign) { - TextAlign.End -> width.toFloat() - TextAlign.Center -> width / 2f - else -> 0f - } - Offset.Zero.copy(y = height / 2f, x = x) + val textShadow = remember { + Shadow( + color = Color.Black.copy(alpha = 0.2f), + offset = Offset(x = 0f, y = 1f), + blurRadius = 1f + ) + } + val translationTopLeft = remember(textHeight) { + Offset.Zero.copy(y = textHeight + gapHeight) + } + val pivotOffset = remember(height, textAlign) { + val width = screenConstraints.maxWidth + val x = when (textAlign) { + TextAlign.End -> width.toFloat() + TextAlign.Center -> width / 2f + else -> 0f } - val blurRadius = remember { - derivedStateOf { - if (!isBlurredEnable()) return@derivedStateOf 0.dp - offsetToCurrent().coerceAtMost(5).dp - } + Offset.Zero.copy(y = height / 2f, x = x) + } + val blurRadius = remember { + derivedStateOf { + if (!isBlurredEnable()) return@derivedStateOf 0.dp + offsetToCurrent().coerceAtMost(5).dp } - val animateBlurRadius = animateDpAsState(targetValue = blurRadius.value, label = "") + } + val animateBlurRadius = animateDpAsState(targetValue = blurRadius.value, label = "") - Canvas( - modifier = modifier - .blur( - animateBlurRadius.value, - BlurredEdgeTreatment.Unbounded - ) // TODO 对性能影响较大,待进一步优化 - .fillMaxWidth() - .height(animateHeight.value) - .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) - .padding(vertical = paddingVertical, horizontal = paddingHorizontal) + Canvas( + modifier = modifier + .blur( + animateBlurRadius.value, + BlurredEdgeTreatment.Unbounded + ) // TODO 对性能影响较大,待进一步优化 + .fillMaxWidth() + .height(animateHeight.value) + .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) + .padding(vertical = paddingVertical, horizontal = paddingHorizontal) + ) { + scale( + scale = scale.value / 100f, + pivot = pivotOffset ) { - scale( - scale = scale.value / 100f, - pivot = pivotOffset - ) { - drawText( - color = color.value, - shadow = textShadow, - textLayoutResult = textResult - ) + drawText( + color = color.value, + shadow = textShadow, + textLayoutResult = textResult + ) - if (translateResult == null) return@scale - drawText( - color = color.value, - topLeft = translationTopLeft, - textLayoutResult = translateResult, - alpha = animateAlpha.value - ) - } + if (translateResult == null) return@scale + drawText( + color = color.value, + topLeft = translationTopLeft, + textLayoutResult = translateResult, + alpha = animateAlpha.value + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt index 0618ec648..19cffde20 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt @@ -13,15 +13,26 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp @@ -29,132 +40,194 @@ import androidx.compose.ui.unit.sp import com.lalilu.lmedia.lyric.LyricItem import com.lalilu.lmedia.lyric.findPlayingIndexForWords import com.lalilu.lmedia.lyric.getSentenceContent -import com.lalilu.lmusic.compose.screen.playing.lyric.LyricContent import com.lalilu.lmusic.compose.screen.playing.lyric.utils.getPathForProgress import com.lalilu.lmusic.compose.screen.playing.lyric.utils.normalized -class LyricContentWords( - override val key: String, - val lyric: LyricItem.WordsLyric, -) : LyricContent { - override val item: LyricItem = lyric - - @Composable - override fun Draw( - modifier: Modifier, - isCurrent: () -> Boolean, - offsetToCurrent: () -> Int, - currentTime: () -> Long, - screenConstraints: Constraints, - onClick: (() -> Unit)?, - onLongClick: (() -> Unit)?, - textMeasurer: TextMeasurer, - fontFamily: () -> FontFamily? - ) { - val density = LocalDensity.current - val paddingVertical = remember { 15.dp } - val paddingHorizontal = remember { 40.dp } - val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } - val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } - - val actualConstraints = remember { - val width = screenConstraints.maxWidth - paddingHorizontalPx * 2 - Constraints( - maxWidth = width, - minWidth = width, - maxHeight = Int.MAX_VALUE - ) - } - val textResult = remember { - textMeasurer.measure( - text = lyric.getSentenceContent(), - constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = 26.sp, - textAlign = TextAlign.Start, - fontFamily = fontFamily() ?: TextStyle.Default.fontFamily +private val DEFAULT_TEXT_SHADOW = Shadow( + color = Color.Black.copy(alpha = 0.2f), + offset = Offset(x = 0f, y = 1f), + blurRadius = 1f +) + +private val DEFAULT_GRADIENT_GAP = 48.dp + +@Composable +fun LyricContentWords( + modifier: Modifier, + lyric: LyricItem.WordsLyric, + isCurrent: () -> Boolean, + offsetToCurrent: () -> Int, + currentTime: () -> Long, + screenConstraints: Constraints, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + textMeasurer: TextMeasurer, + fontFamily: () -> FontFamily? +) { + val density = LocalDensity.current + val paddingVertical = remember { 15.dp } + val paddingHorizontal = remember { 40.dp } + val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } + val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } + + val fullSentence = remember { lyric.getSentenceContent() } + val actualConstraints = remember { + val width = screenConstraints.maxWidth - paddingHorizontalPx * 2 + Constraints( + maxWidth = width, + minWidth = width, + maxHeight = Int.MAX_VALUE + ) + } + + val textStyle = remember { + TextStyle.Default.copy( + fontSize = 34.sp, + textAlign = TextAlign.Start, + fontFamily = fontFamily() ?: FontFamily( + Font( + familyName = DeviceFontFamilyName("FontFamily.Monospace"), + variationSettings = FontVariation.Settings( + FontVariation.weight(900) + ) ) ) - } - val scale = animateFloatAsState( - targetValue = if (isCurrent()) 100f else 90f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow - ), - label = "" ) - val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } - val heightDp = - remember(textHeight) { density.run { textHeight.toDp() + paddingVertical * 2 } } - val animateHeight = animateDpAsState( - targetValue = heightDp, - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ), - label = "" + } + + val textResult = remember { + textMeasurer.measure( + text = fullSentence, + constraints = actualConstraints, + style = textStyle ) - val pivotOffset = remember(textHeight) { - Offset.Zero.copy(y = textHeight / 2f, x = 0f) - } - val textShadow = remember { - Shadow( - color = Color.Black.copy(alpha = 0.2f), - offset = Offset(x = 0f, y = 1f), - blurRadius = 1f - ) + } + val scale = animateFloatAsState( + targetValue = when { + isCurrent() -> 100f + currentTime() in lyric.startTime..lyric.endTime -> 95f + else -> 90f + }, + visibilityThreshold = 0.001f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + val alpha = animateFloatAsState( + targetValue = when { + isCurrent() -> 1f + currentTime() in lyric.startTime..lyric.endTime -> 0.75f + else -> 0.5f + }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + visibilityThreshold = 0.001f, + label = "" + ) + + val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } + val pivotOffset = remember(textHeight) { Offset.Zero.copy(y = textHeight / 2f, x = 0f) } + val heightDp = remember(textHeight) { + density.run { textHeight.toDp() + paddingVertical * 2 } + } + val animateHeight = animateDpAsState( + targetValue = heightDp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + + Canvas( + modifier = modifier + .fillMaxWidth() + .height(animateHeight.value) + .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) + .padding(vertical = paddingVertical, horizontal = paddingHorizontal) + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + ) { + val now = currentTime() + val index = lyric.words.findPlayingIndexForWords(now) + val word = lyric.words.getOrNull(index) + + // 获取某一词的播放进度 + var progress = normalized( + start = word?.startTime ?: 0, + end = word?.endTime ?: 0, + current = now + ) + + // 若当前句的歌词已经播放完毕,则进度固定为1 + if (lyric.words.maxOf { it.endTime } < currentTime()) { + progress = 1f } - Canvas( - modifier = modifier - .fillMaxWidth() - .height(animateHeight.value) - .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) - .padding(vertical = paddingVertical, horizontal = paddingHorizontal) + val offset = lyric.words.take(index) + .sumOf { it.content.length } + + val (path, rect, position) = textResult.getPathForProgress( + progress = progress, + offset = offset, + length = word?.content?.length + ) + + scale( + scale = scale.value / 100f, + pivot = pivotOffset ) { - scale( - scale = scale.value / 100f, - pivot = pivotOffset - ) { - drawText( - color = Color(0x80FFFFFF), - shadow = textShadow, - textLayoutResult = textResult - ) + drawText( + color = Color(0x50FFFFFF), + shadow = DEFAULT_TEXT_SHADOW, + textLayoutResult = textResult, + ) -// val progress = normalized( -// start = lyric.startTime, -// end = lyric.endTime, -// current = currentTime() -// ) - - if (isCurrent()) { - val index = lyric.words.findPlayingIndexForWords(currentTime()) - val word = lyric.words.getOrNull(index) ?: return@scale - val progress = normalized( - start = word.startTime, - end = word.endTime, - current = currentTime() + if (progress > 0f) { + val lineProgress = if (progress >= 0.99f) 1f else { + normalized( + start = rect.left, + end = rect.right, + current = position ) - val offset = lyric.words.take(index) - .sumOf { it.content.length } + } - val path = textResult.getPathForProgress( - progress = progress, - offset = offset, - length = word.content.length - ) + val offsetForProgress = DEFAULT_GRADIENT_GAP.toPx() * (1f - lineProgress) + val leftBound = position - offsetForProgress + val rightBound = (position + DEFAULT_GRADIENT_GAP.toPx() - offsetForProgress) + val rectForGradient = rect.copy(left = leftBound, right = rightBound) + + // 向右扩展一段距离,为渐变预留足够的空间 + path.addRect(rectForGradient.copy(right = rectForGradient.right.coerceAtMost(rect.right))) - clipPath( - path = path - ) { + clipPath(path) { + withLayer { drawText( - color = Color(0xFFFFFFFF), - shadow = textShadow, - textLayoutResult = textResult + color = Color.White, + textLayoutResult = textResult, + ) + + val gradient = Brush.horizontalGradient( + colors = listOf( + Color.Black, + Color.Black.copy(0.4f), + Color.Transparent + ), + startX = leftBound, + endX = rightBound ) + + clipPath(path = rect.toPath()) { + drawPath( + path = rectForGradient.toPath(), + brush = gradient, + blendMode = BlendMode.DstIn + ) + } } } } @@ -162,3 +235,14 @@ class LyricContentWords( } } +fun Rect.toPath(): Path { + return Path().apply { addRect(this@toPath) } +} + +fun DrawScope.withLayer(block: DrawScope.() -> Unit) { + with(drawContext.canvas.nativeCanvas) { + val layer = saveLayer(null, null) + block() + restoreToCount(layer) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt index e58f4309e..59ea4f5e6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt @@ -62,4 +62,10 @@ fun normalized(start: Long, end: Long, current: Long): Float { if (start >= end) return 0f val result = (current - start).toFloat() / (end - start).toFloat() return result.coerceIn(0f, 1f) +} + +fun normalized(start: Float, end: Float, current: Float): Float { + if (start >= end) return 0f + val result = (current - start) / (end - start) + return result.coerceIn(0f, 1f) } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt index 9d8f0bc45..062cd9b8a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt @@ -1,5 +1,7 @@ package com.lalilu.lmusic.compose.screen.playing.lyric.utils +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Path import androidx.compose.ui.text.TextLayoutResult @@ -41,6 +43,13 @@ fun TextLayoutResult.getWidthForOffset(offset: Int): Int { return sumWidthForLine(lineIndex - 1) + position } +@Immutable +data class WordsLayoutResult( + @Stable val path: Path, + @Stable val rect: Rect, + @Stable val position: Float +) + /** * 获取指定进度对应的路径 * @@ -52,7 +61,7 @@ fun TextLayoutResult.getPathForProgress( progress: Float, offset: Int = 0, length: Int? = null -): Path { +): WordsLayoutResult { val offsetWidth = getWidthForOffset(offset) val maxWidth = if (length == null) { sumWidthForLine(lineCount - 1) @@ -64,6 +73,8 @@ fun TextLayoutResult.getPathForProgress( var addedWidth = 0f val path = Path() + var rect: Rect? = null + var position = 0f for (lineIndex in 0 until lineCount) { val lineWidth = getLineWidth(lineIndex) @@ -71,17 +82,32 @@ fun TextLayoutResult.getPathForProgress( if (addedWidth + lineWidth >= targetWidth) { val widthToAdd = targetWidth - addedWidth - val rect = getLineRect(lineIndex) - .let { it.copy(right = it.left + widthToAdd) } - path.addRect(rect) + val lineRect = getLineRect(lineIndex) + val lineRectWithProgress = lineRect.let { it.copy(right = it.left + widthToAdd) } + path.addRect(lineRectWithProgress) + + // 获取当前行(词)的左右边界 + rect = lineRect.copy( + left = lineRect.left + offsetWidth - addedWidth, + right = lineRect.left + maxWidth - addedWidth + ) + + // 获取当前行(词)的播放位置 + position = lineRectWithProgress.right addedWidth += widthToAdd break } else { - val rect = getLineRect(lineIndex) - path.addRect(rect) + val lineRect = getLineRect(lineIndex) + path.addRect(lineRect) + position = lineRect.right addedWidth += lineWidth + rect = lineRect } } - return path + return WordsLayoutResult( + path = path, + rect = rect ?: Rect.Zero, + position = position + ) } \ No newline at end of file diff --git a/lmedia b/lmedia index 8bfe86d70..8ff82a4a4 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 8bfe86d703cde4b020d34b59d38e2fb1a637f7a8 +Subproject commit 8ff82a4a411ca5b731353c0e7d9c11c23c1eee1d From 923f5bfed228801b8aa7ce3d4400c43a054ff2a6 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 2 Feb 2025 16:59:09 +0800 Subject: [PATCH 172/213] =?UTF-8?q?[modify]=E5=8E=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E8=BF=87=E6=97=B6=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/lalilu/lmusic/ui/ShaderView.kt | 498 ------------------ .../java/com/lalilu/lmusic/ui/TestCode.kt | 21 - .../lalilu/lmusic/ui/shadertoy/BasePass.kt | 97 ---- .../lalilu/lmusic/ui/shadertoy/ScaleType.kt | 132 ----- .../com/lalilu/lmusic/ui/shadertoy/Shader.kt | 82 --- .../lalilu/lmusic/ui/shadertoy/ShaderToy.kt | 44 -- .../lalilu/lmusic/ui/shadertoy/ShaderUtils.kt | 284 ---------- 7 files changed, 1158 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/ui/ShaderView.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/ui/TestCode.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/ui/shadertoy/BasePass.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ScaleType.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/ui/shadertoy/Shader.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ShaderToy.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ShaderUtils.kt diff --git a/app/src/main/java/com/lalilu/lmusic/ui/ShaderView.kt b/app/src/main/java/com/lalilu/lmusic/ui/ShaderView.kt deleted file mode 100644 index 0bdd732be..000000000 --- a/app/src/main/java/com/lalilu/lmusic/ui/ShaderView.kt +++ /dev/null @@ -1,498 +0,0 @@ -package com.lalilu.lmusic.ui - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Color -import android.opengl.GLES30 -import android.opengl.GLSurfaceView -import android.opengl.Matrix -import android.util.AttributeSet -import android.view.SurfaceHolder -import android.view.animation.DecelerateInterpolator -import androidx.annotation.ColorInt -import androidx.compose.ui.util.lerp -import androidx.core.content.ContextCompat -import androidx.lifecycle.MutableLiveData -import androidx.palette.graphics.Palette -import com.lalilu.lmusic.ui.shadertoy.BasePass -import com.lalilu.lmusic.ui.shadertoy.Shader -import com.lalilu.lmusic.ui.shadertoy.ShaderToyChannel -import com.lalilu.lmusic.ui.shadertoy.ShaderToyContext -import com.lalilu.lmusic.ui.shadertoy.ShaderToyPass -import com.lalilu.lmusic.ui.shadertoy.ShaderUtils -import com.lalilu.lmusic.ui.shadertoy.StaticScaleType -import javax.microedition.khronos.egl.EGLConfig -import javax.microedition.khronos.opengles.GL10 -import kotlin.math.ceil -import kotlin.math.min - -fun interface FrameRateChangeListener { - fun onFrameRateChange(frameRate: Float) -} - -class ShaderView(context: Context?, attrs: AttributeSet?) : GLSurfaceView(context, attrs) { - private val shaderContext: ShaderToyContext by lazy { ShaderToyContext(queueEventFunc = this::queueEvent) } - private val renderer: ShaderToyRenderer by lazy { ShaderToyRenderer(image, shaderContext) } - val palette = MutableLiveData(null) - - private val bitmapChannel = BitmapChannel() - private val bufferA = TransformBuffer( - content = ShowImage, - channel0 = bitmapChannel - ) - private val blurBuffer = BlurBuffer( - channel0 = bufferA - ) - - private val colorChannel = ColorChannel() - private val mixBuffer = MixBuffer( - channel0 = colorChannel, - channel1 = blurBuffer - ) - - private val image = Image( - common = "", - content = ShowImage, - channel0 = mixBuffer - ) - - init { - setEGLContextClientVersion(3) - setRenderer(renderer) - renderMode = RENDERMODE_WHEN_DIRTY - tryUpdateFrameRate() - } - - fun updateBitmap(bitmap: Bitmap? = null) { - bitmap ?: return - palette.postValue(Palette.from(bitmap).generate()) - queueEvent { - bitmapChannel.update(bitmap) - requestRender() - } - } - - fun updateColor(color: Int) { - queueEvent { - colorChannel.updateColor(color) - requestRender() - } - } - - fun updateAlpha(alpha: Float) { - queueEvent { - mixBuffer.updateAlpha(alpha) - requestRender() - } - } - - fun updateProgress(progress: Float) { - queueEvent { - bufferA.updateProgress(progress) - blurBuffer.updateRadius(50f * progress) - requestRender() - } - } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, w: Int, h: Int) { - super.surfaceChanged(holder, format, w, h) - tryUpdateFrameRate() - } - - private fun tryUpdateFrameRate() { - val refreshRate = ContextCompat.getDisplayOrDefault(context).refreshRate - renderer.onFrameRateChange(refreshRate) - } -} - - -class ShaderToyRenderer( - private val image: Image, - private val shaderContext: ShaderToyContext -) : GLSurfaceView.Renderer, FrameRateChangeListener { - - override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { - shaderContext.mStartTime = System.currentTimeMillis() - image.init() - } - - override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) { - shaderContext.mResolution = floatArrayOf(width.toFloat(), height.toFloat(), 1f) - image.update(shaderContext, width, height) - GLES30.glViewport(0, 0, width, height) - } - - override fun onFrameRateChange(frameRate: Float) { - shaderContext.iFrameRate = frameRate - } - - override fun onDrawFrame(gl: GL10?) { - shaderContext.resetCounter() - shaderContext.iFrame = ++shaderContext.iFrame - shaderContext.iTime = (System.currentTimeMillis() - shaderContext.mStartTime) - .toFloat() / 1000f - - image.draw(shaderContext) - } -} - -class TransformBuffer( - content: String, - channel0: ShaderToyPass -) : Buffer(content = content, channel0 = channel0) { - private var progress: Float = 0f - - fun updateProgress(progress: Float) { - this.progress = progress - } - - val aInterpolator = DecelerateInterpolator() - - override fun draw(context: ShaderToyContext) { - val resolution = channel0?.output()?.getTextureResolution() - requireNotNull(resolution) { "channal0 must have an output!" } - - val textureWidth = resolution.getOrNull(0) ?: 0 - val textureHeight = resolution.getOrNull(1) ?: 0 - - Matrix.setIdentityM(shader.modelMatrix, 0) - - val value2 = aInterpolator.getInterpolation(progress) - val screenHeight = lerp(shader.screenWidth, shader.screenHeight, 1f - value2) - val textureAspectRatio = textureWidth.toFloat() / textureHeight.toFloat() - val screenAspectRatio = shader.screenWidth.toFloat() / screenHeight - - // 先移动 - val moveY = (shader.screenHeight - shader.screenWidth).toFloat() / shader.screenHeight - val translateY = lerp(moveY, 0f, progress) - Matrix.translateM(shader.modelMatrix, 0, 0f, translateY, 0f) - - // 后缩放 - val scale = screenAspectRatio / textureAspectRatio - Matrix.scaleM(shader.modelMatrix, 0, scale, scale, 0f) - - StaticScaleType.Crop.updateMatrix( - mvpMatrix = shader.mvpMatrix, - modelMatrix = shader.modelMatrix, - viewMatrix = shader.viewMatrix, - projectionMatrix = shader.projectionMatrix, - screenWidth = shader.screenWidth, - screenHeight = shader.screenHeight, - textureWidth = textureWidth.toInt(), - textureHeight = textureHeight.toInt() - ) - - // x,y轴翻转 - Matrix.scaleM(shader.mvpMatrix, 0, 1f, -1f, 1f) - - super.draw(context) - } - - private fun lerp(start: Int, stop: Int, fraction: Float): Int { - return start + ((stop - start) * fraction.toDouble()).toInt() - } -} - -class MixBuffer( - channel0: ShaderToyPass, - channel1: ShaderToyPass -) : Buffer( - content = MixShader, - channel0 = channel0, - channel1 = channel1 -) { - companion object { - val MixShader = """ - uniform float alpha; - - void mainImage( out vec4 fragColor, in vec2 fragCoord ) - { - vec2 uv = fragCoord.xy / iResolution.xy; - - // vec2 uv0 = fragCoord.xy / iChannelResolution[0].xy; - // vec2 uv1 = fragCoord.xy / iChannelResolution[1].xy; - // - vec4 color0 = texture(iChannel0, uv); - vec4 color1 = texture(iChannel1, uv); - - fragColor = mix(color0, color1, alpha); - } - """.trimIndent() - } - - private var mAlphaLocation: Int = 0 - private var alpha: Float = 1f - - fun updateAlpha(alpha: Float) { - this.alpha = alpha - } - - override fun init(commonContent: String) { - super.init(commonContent) -// mAlphaLocation = GLES30.glGetUniformLocation(shader.programId, "alpha") - } - - override fun onDraw(context: ShaderToyContext) { - GLES30.glUniform1f(mAlphaLocation, alpha) - } -} - -class BlurBuffer( - private val channel0: ShaderToyPass, -) : ShaderToyPass, ShaderToyChannel { - companion object { - val BlurShader = """ - uniform vec2 uOffset; - - void mainImage( out vec4 fragColor, in vec2 fragCoord ) - { - vec2 vUV = fragCoord.xy / iResolution.xy; - - fragColor = texture(iChannel0, vUV, 0.0); - fragColor += texture(iChannel0, vUV + vec2( uOffset.x, uOffset.y), 0.0); - fragColor += texture(iChannel0, vUV + vec2( uOffset.x, -uOffset.y), 0.0); - fragColor += texture(iChannel0, vUV + vec2(-uOffset.x, uOffset.y), 0.0); - fragColor += texture(iChannel0, vUV + vec2(-uOffset.x, -uOffset.y), 0.0); - - fragColor = vec4(fragColor.rgb * 0.2, 1.0); - } - """.trimIndent() - } - - private var mRadius: Float = 0f - fun updateRadius(radius: Float) { - this.mRadius = radius - } - - private val sampleScale: Int = 4 - private val textureResolution: FloatArray = floatArrayOf(0f, 0f, 0f) - private var blurProgram: Int = 0 - private var pongTexture: Int = 0 - private var pingTexture: Int = 0 - private var pongFbo: Int = 0 - private var pingFbo: Int = 0 - private var lastDrawFbo: Int = 0 - - private var mOffsetLoc: Int = 0 - private var screenWidth: Int = 0 - private var screenHeight: Int = 0 - private var fboWidth: Int = 0 - private var fboHeight: Int = 0 - private val mvpMatrix = FloatArray(16) - - override fun init(commonContent: String) { - channel0.init(commonContent) - - if (blurProgram == 0) { - val fragmentCode = ShaderUtils.FragmentCodeTemplate - .replace("//<**ShaderToyContent**>", BlurShader) - .replace("//<**ShaderToyCommon**>", commonContent) - blurProgram = ShaderUtils.createProgram(ShaderUtils.VertexCode, fragmentCode) - mOffsetLoc = GLES30.glGetUniformLocation(blurProgram, "uOffset") - Matrix.setIdentityM(mvpMatrix, 0) - } - } - - override fun update(context: ShaderToyContext, screenWidth: Int, screenHeight: Int) { - channel0.update(context, screenWidth, screenHeight) - - this.screenWidth = screenWidth - this.screenHeight = screenHeight - this.fboWidth = screenWidth / sampleScale - this.fboHeight = screenHeight / sampleScale - this.textureResolution[0] = fboWidth.toFloat() - this.textureResolution[1] = fboHeight.toFloat() - - if (pongTexture == 0) { - pingTexture = ShaderUtils.createTexture(fboWidth, fboHeight) - pingFbo = ShaderUtils.createFBO(pingTexture) - pongTexture = ShaderUtils.createTexture(fboWidth, fboHeight) - pongFbo = ShaderUtils.createFBO(pongTexture) - } - } - - override fun onDraw(context: ShaderToyContext) { - val resolution = channel0.output()?.getTextureResolution() - requireNotNull(resolution) { "channal0 must have an output!" } - - val textureWidth = resolution.getOrNull(0) ?: 0f - val textureHeight = resolution.getOrNull(1) ?: 0f - - val radius = mRadius / 6.0f - val passes = min(8, ceil(radius).toInt()) - - // 若当前所需的次数为0,则不进行绘制,交由channel0进行绘制 - if (passes == 0 || passes == 1) { - lastDrawFbo = -1 - return - } - - val radiusByPasses = radius / passes - val stepX = radiusByPasses / textureWidth - val stepY = radiusByPasses / textureHeight - - GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, pingFbo) - - // 设置背景颜色 - GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) - GLES30.glClearColor(0f, 0f, 0f, 1f) - - GLES30.glUseProgram(blurProgram) - GLES30.glUniformMatrix4fv(ShaderUtils.mUMatrixLocation, 1, false, mvpMatrix, 0) - ShaderUtils.bindUniformVariable(context) - - bindTextureFromOutput(context) // 将channel0的纹理绑定至着色器对应变量 - GLES30.glUniform2f(mOffsetLoc, stepX, stepY) - - GLES30.glViewport(0, 0, fboWidth, fboHeight) - ShaderUtils.drawVertex() - - var temp: Int - var readFbo = pingFbo - var drawFbo = pongFbo - - GLES30.glViewport(0, 0, fboWidth, fboHeight) - for (i in 1 until passes) { - GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, drawFbo) - - // 此前没有启用其他纹理操作单元,则bindTexture将绑定至iChannel0 - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, getTextureIdByFboId(readFbo)) - GLES30.glUniform2f(mOffsetLoc, stepX * i, stepY * i) - ShaderUtils.drawVertex() - - // fbo交换 - temp = drawFbo - drawFbo = readFbo - readFbo = temp - } - - lastDrawFbo = readFbo - GLES30.glViewport(0, 0, screenWidth, screenHeight) - } - - private fun getTextureIdByFboId(fboId: Int): Int { - return if (fboId == pingFbo) pingTexture else pongTexture - } - - private fun bindTextureFromOutput(context: ShaderToyContext) { - channel0.output()?.let { - context.tryBindTexture { count -> - GLES30.glActiveTexture(GLES30.GL_TEXTURE0 + count) // 启用某个纹理操作单元,若后续没有启用其他操作单元,则bindTexture都会绑定至该操作单元 - GLES30.glUniform1i(ShaderUtils.iChannel0Location, count) // 将该纹理操作单元映射到着色器中 - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, it.getTexture()) - GLES30.glUniform3fv( - ShaderUtils.iChannelResolutionLocation, 1, - it.getTextureResolution(), 0 - ) - } - } - } - - override fun draw(context: ShaderToyContext) { - channel0.draw(context) - onDraw(context) - } - - override fun getTexture(): Int = getTextureIdByFboId(lastDrawFbo) - override fun getTextureResolution(): FloatArray = textureResolution - override fun output(): ShaderToyChannel { - return if (lastDrawFbo == -1) channel0.output() ?: this else this - } -} - -open class Buffer( - val content: String, - channel0: ShaderToyPass? = null, - channel1: ShaderToyPass? = null, - channel2: ShaderToyPass? = null, - channel3: ShaderToyPass? = null -) : BasePass( - shader = Shader(content = content), - channel0 = channel0, - channel1 = channel1, - channel2 = channel2, - channel3 = channel3, -), ShaderToyChannel { - override fun getTexture(): Int = shader.textureId - override fun getTextureResolution(): FloatArray = shader.textureResolution - override fun output(): ShaderToyChannel = this -} - -class Image( - val content: String, - val common: String = "", - channel0: ShaderToyPass? = null, - channel1: ShaderToyPass? = null, - channel2: ShaderToyPass? = null, - channel3: ShaderToyPass? = null -) : BasePass( - shader = Shader(content, false), - channel0 = channel0, - channel1 = channel1, - channel2 = channel2, - channel3 = channel3, -) { - override fun init(commonContent: String) { - super.init(common) - } -} - -class BitmapChannel( - private val bitmap: Bitmap? = null -) : ShaderToyChannel, ShaderToyPass { - private var textureId = 0 - private var textureResolution = floatArrayOf(0f, 0f, 0f) - - fun update(bitmap: Bitmap) { - if (textureId == 0) { - textureId = ShaderUtils.createTexture(bitmap) - } else { - ShaderUtils.updateTexture(textureId, bitmap) - } - textureResolution[0] = bitmap.width.toFloat() - textureResolution[1] = bitmap.height.toFloat() - } - - override fun init(commonContent: String) { - if (bitmap != null) update(bitmap) - } - - // 若无预设bitmap,则用屏幕宽高创建Texture,需确保texture必须在绘制和更新数据前已完成创建 - override fun update(context: ShaderToyContext, screenWidth: Int, screenHeight: Int) { - if (textureId == 0) { - textureId = ShaderUtils.createTexture(screenWidth, screenHeight) - textureResolution[0] = screenWidth.toFloat() - textureResolution[1] = screenHeight.toFloat() - } - } - - override fun getTexture(): Int = textureId - override fun getTextureResolution(): FloatArray = textureResolution - override fun output(): ShaderToyChannel = this -} - -class ColorChannel( - @ColorInt color: Int = Color.BLACK -) : ShaderToyChannel, ShaderToyPass { - private var textureId = 0 - private var textureResolution = floatArrayOf(1f, 1f, 0f) - private val colorBitmap by lazy { - Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) - .also { it.setPixel(0, 0, color) } - } - - override fun init(commonContent: String) { - textureId = ShaderUtils.createTexture(colorBitmap) - } - - fun updateColor(color: Int) { - if (textureId != 0) { - colorBitmap.setPixel(0, 0, color) - ShaderUtils.updateTexture(textureId, colorBitmap) - } - } - - override fun getTexture(): Int = textureId - override fun getTextureResolution(): FloatArray = textureResolution - override fun output(): ShaderToyChannel = this -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/ui/TestCode.kt b/app/src/main/java/com/lalilu/lmusic/ui/TestCode.kt deleted file mode 100644 index 899298117..000000000 --- a/app/src/main/java/com/lalilu/lmusic/ui/TestCode.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.lalilu.lmusic.ui - - -val ShowImage = """ -vec4 textureNice( sampler2D sam, vec2 uv ) -{ - float textureResolution = float(textureSize(sam,0).x); - uv = uv*textureResolution + 0.5; - vec2 iuv = floor( uv ); - vec2 fuv = fract( uv ); - uv = iuv + fuv*fuv*(3.0-2.0*fuv); - uv = (uv - 0.5)/textureResolution; - return texture( sam, uv ); -} - -void mainImage( out vec4 fragColor, in vec2 fragCoord ) { - vec2 uv = fragCoord/iResolution.xy; - - fragColor = textureNice(iChannel0, uv); -} -""".trimIndent() diff --git a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/BasePass.kt b/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/BasePass.kt deleted file mode 100644 index 57803eba7..000000000 --- a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/BasePass.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.lalilu.lmusic.ui.shadertoy - -import android.opengl.GLES30 - - -open class BasePass( - protected val shader: Shader, - internal var channel0: ShaderToyPass? = null, - internal var channel1: ShaderToyPass? = null, - internal var channel2: ShaderToyPass? = null, - internal var channel3: ShaderToyPass? = null -) : ShaderToyPass { - private val iChannelResolution = FloatArray(12) - - override fun init(commonContent: String) { - channel0?.init(commonContent) - channel1?.init(commonContent) - channel2?.init(commonContent) - channel3?.init(commonContent) - shader.onCreate(commonContent) - } - - override fun update(context: ShaderToyContext, screenWidth: Int, screenHeight: Int) { - channel0?.update(context, screenWidth, screenHeight) - channel1?.update(context, screenWidth, screenHeight) - channel2?.update(context, screenWidth, screenHeight) - channel3?.update(context, screenWidth, screenHeight) - shader.onSizeChange(context, screenWidth, screenHeight) - } - - override fun draw(context: ShaderToyContext) { - channel0?.draw(context) - channel1?.draw(context) - channel2?.draw(context) - channel3?.draw(context) - - /** - * glActiveTexture函数需要传的是 GL_TEXTURE0 - * 而传递至GLSL的uniform变量时glUniform1i需要传的只是 0 - */ - shader.draw(context) { - channel0?.output()?.let { - context.tryBindTexture { count -> - GLES30.glActiveTexture(GLES30.GL_TEXTURE0 + count) // GL_TEXTUREx - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, it.getTexture()) // textureId - GLES30.glUniform1i(ShaderUtils.iChannel0Location, count) // x - it.getTextureResolution().let { - iChannelResolution[0] = it[0] - iChannelResolution[1] = it[1] - iChannelResolution[2] = it[2] - } - } - } - channel1?.output()?.let { - context.tryBindTexture { count -> - GLES30.glActiveTexture(GLES30.GL_TEXTURE0 + count) - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, it.getTexture()) - GLES30.glUniform1i(ShaderUtils.iChannel1Location, count) - it.getTextureResolution().let { - iChannelResolution[3] = it[0] - iChannelResolution[4] = it[1] - iChannelResolution[5] = it[2] - } - } - } - channel2?.output()?.let { - context.tryBindTexture { count -> - GLES30.glActiveTexture(GLES30.GL_TEXTURE0 + count) - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, it.getTexture()) - GLES30.glUniform1i(ShaderUtils.iChannel2Location, count) - it.getTextureResolution().let { - iChannelResolution[6] = it[0] - iChannelResolution[7] = it[1] - iChannelResolution[8] = it[2] - } - } - } - channel3?.output()?.let { - context.tryBindTexture { count -> - GLES30.glActiveTexture(GLES30.GL_TEXTURE0 + count) - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, it.getTexture()) - GLES30.glUniform1i(ShaderUtils.iChannel3Location, count) - it.getTextureResolution().let { - iChannelResolution[9] = it[0] - iChannelResolution[10] = it[1] - iChannelResolution[11] = it[2] - } - } - } - GLES30.glUniform3fv( - ShaderUtils.iChannelResolutionLocation, 4, - iChannelResolution, 0 - ) - onDraw(context) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ScaleType.kt b/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ScaleType.kt deleted file mode 100644 index a3617f18f..000000000 --- a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ScaleType.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.lalilu.lmusic.ui.shadertoy - -import android.opengl.Matrix - -interface ScaleType { - fun updateMatrix( - mvpMatrix: FloatArray, - modelMatrix: FloatArray, - viewMatrix: FloatArray, - projectionMatrix: FloatArray, - screenWidth: Int, - screenHeight: Int, - textureWidth: Int, - textureHeight: Int - ) -} - -sealed class StaticScaleType : ScaleType { - protected fun updateMvpMatrix( - mvpMatrix: FloatArray, - modelMatrix: FloatArray, - viewMatrix: FloatArray, - projectionMatrix: FloatArray, - ) { - Matrix.setLookAtM(viewMatrix, 0, 0f, 0f, 7.0f, 0f, 0f, 0f, 0f, 1.0f, 0.0f) - Matrix.multiplyMM(mvpMatrix, 0, viewMatrix, 0, modelMatrix, 0) - Matrix.multiplyMM(mvpMatrix, 0, projectionMatrix, 0, mvpMatrix, 0) - } - - data object Crop : StaticScaleType() { - override fun updateMatrix( - mvpMatrix: FloatArray, - modelMatrix: FloatArray, - viewMatrix: FloatArray, - projectionMatrix: FloatArray, - screenWidth: Int, - screenHeight: Int, - textureWidth: Int, - textureHeight: Int - ) { - val textureAspectRatio = textureWidth.toFloat() / textureHeight.toFloat() - val screenAspectRatio = screenWidth.toFloat() / screenHeight.toFloat() - - if (screenWidth > screenHeight) { - if (textureAspectRatio > screenAspectRatio) { - Matrix.orthoM( - projectionMatrix, 0, - -screenAspectRatio * textureAspectRatio, - screenAspectRatio * textureAspectRatio, - -1f, 1f, 3f, 7f - ) - } else { - Matrix.orthoM( - projectionMatrix, 0, - -screenAspectRatio / textureAspectRatio, - screenAspectRatio / textureAspectRatio, - -1f, 1f, 3f, 7f - ) - } - } else { - if (textureAspectRatio > screenAspectRatio) { - Matrix.orthoM( - projectionMatrix, 0, - -screenAspectRatio / textureAspectRatio, - screenAspectRatio / textureAspectRatio, - -1f, 1f, 3f, 7f - ) - } else { - Matrix.orthoM( - projectionMatrix, 0, -1f, 1f, - -textureAspectRatio / screenAspectRatio, - textureAspectRatio / screenAspectRatio, - 3f, 7f - ) - } - } - updateMvpMatrix(mvpMatrix, modelMatrix, viewMatrix, projectionMatrix) - } - } - - data object Center : StaticScaleType() { - override fun updateMatrix( - mvpMatrix: FloatArray, - modelMatrix: FloatArray, - viewMatrix: FloatArray, - projectionMatrix: FloatArray, - screenWidth: Int, - screenHeight: Int, - textureWidth: Int, - textureHeight: Int - ) { - val textureAspectRatio = textureWidth.toFloat() / textureHeight.toFloat() - val screenAspectRatio = screenWidth.toFloat() / screenHeight.toFloat() - - if (screenWidth > screenHeight) { - if (textureAspectRatio > screenAspectRatio) { - Matrix.orthoM( - projectionMatrix, 0, - -screenAspectRatio * textureAspectRatio, - screenAspectRatio * textureAspectRatio, - -1f, 1f, 3f, 7f - ) - } else { - Matrix.orthoM( - projectionMatrix, 0, - -screenAspectRatio / textureAspectRatio, - screenAspectRatio / textureAspectRatio, - -1f, 1f, 3f, 7f - ) - } - } else { - if (textureAspectRatio > screenAspectRatio) { - Matrix.orthoM( - projectionMatrix, 0, -1f, 1f, - -1 / screenAspectRatio * textureAspectRatio, - 1 / screenAspectRatio * textureAspectRatio, - 3f, 7f - ) - } else { - Matrix.orthoM( - projectionMatrix, 0, -1f, 1f, - -textureAspectRatio / screenAspectRatio, - textureAspectRatio / screenAspectRatio, - 3f, 7f - ) - } - } - - updateMvpMatrix(mvpMatrix, modelMatrix, viewMatrix, projectionMatrix) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/Shader.kt b/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/Shader.kt deleted file mode 100644 index d6e7ce2dc..000000000 --- a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/Shader.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.lalilu.lmusic.ui.shadertoy - -import android.opengl.GLES30 -import android.opengl.Matrix - -/** - * 一个简单的Shader封装 - * - * @param content ShaderToy中的Shader代码 - * @param output 是否使用帧缓冲并输出一个有内容的Texture - */ -class Shader( - private val content: String, - private val output: Boolean = true -) { - var screenWidth: Int = 0 - private set - var screenHeight: Int = 0 - private set - - val mvpMatrix = FloatArray(16) - val modelMatrix = FloatArray(16) - val viewMatrix = FloatArray(16) - val projectionMatrix = FloatArray(16) - - val textureResolution: FloatArray = floatArrayOf(0f, 0f, 0f) - var textureId: Int = 0 - private set - var fboId: Int = 0 - private set - var programId: Int = 0 - private set - - fun onCreate(commonContent: String) { - if (programId == 0) { - val fragmentCode = ShaderUtils.FragmentCodeTemplate - .replace("//<**ShaderToyContent**>", content) - .replace("//<**ShaderToyCommon**>", commonContent) - programId = ShaderUtils.createProgram(ShaderUtils.VertexCode, fragmentCode) - } - } - - fun onSizeChange(context: ShaderToyContext, width: Int, height: Int) { - val changed = this.screenWidth != width || this.screenHeight != height - if (!changed) return - - if (output) { - textureId = ShaderUtils.createTexture(width, height) - fboId = ShaderUtils.createFBO(textureId) - } - - this.screenWidth = width - this.screenHeight = height - this.textureResolution[0] = width.toFloat() - this.textureResolution[1] = height.toFloat() - - Matrix.setIdentityM(mvpMatrix, 0) - Matrix.setIdentityM(modelMatrix, 0) - Matrix.setIdentityM(viewMatrix, 0) - Matrix.setIdentityM(projectionMatrix, 0) - } - - fun draw(context: ShaderToyContext, fboId: Int = this.fboId, action: () -> Unit) { - GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, fboId) - - // 设置背景颜色 - GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) - GLES30.glClearColor(0f, 0f, 0f, 1f) - - // 应用GL程序 - GLES30.glUseProgram(programId) - - GLES30.glUniformMatrix4fv(ShaderUtils.mUMatrixLocation, 1, false, mvpMatrix, 0) - ShaderUtils.bindUniformVariable(context) - ShaderUtils.drawVertex(action) - - // 重置 - // GLES30.glBindVertexArray(0) - // GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, 0) - // GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, 0) - } -} diff --git a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ShaderToy.kt b/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ShaderToy.kt deleted file mode 100644 index a236368f5..000000000 --- a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ShaderToy.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.lalilu.lmusic.ui.shadertoy - -class ShaderToyContext( - private val queueEventFunc: (Runnable) -> Unit = {} -) { - fun queueGLEvent(action: () -> Unit) = queueEventFunc.invoke(action) - - var mMouse = FloatArray(4) - var mResolution = FloatArray(3) - var mStartTime: Long = 0 - var iFrame: Int = 0 - var iFrameRate: Float = 60f - var iTime: Float = 0f - - @Volatile - var dirty: Boolean = false - - /** - * 简易的计数器,用于在一个draw周期中,区分开不同的纹理使用的单元 - */ - private var textureCounter = 0 - fun tryBindTexture(action: (count: Int) -> Unit) { - action(textureCounter) - textureCounter++ - } - - fun resetCounter() { - textureCounter = 0 - } -} - -interface ShaderToyChannel { - fun getTexture(): Int - fun getTextureResolution(): FloatArray -} - -interface ShaderToyPass { - fun init(commonContent: String = "") {} - fun update(context: ShaderToyContext, screenWidth: Int, screenHeight: Int) {} - fun draw(context: ShaderToyContext) {} - - fun onDraw(context: ShaderToyContext) {} - fun output(): ShaderToyChannel? = null -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ShaderUtils.kt b/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ShaderUtils.kt deleted file mode 100644 index 8eaf4cfc2..000000000 --- a/app/src/main/java/com/lalilu/lmusic/ui/shadertoy/ShaderUtils.kt +++ /dev/null @@ -1,284 +0,0 @@ -package com.lalilu.lmusic.ui.shadertoy - -import android.graphics.Bitmap -import android.opengl.GLES30 -import android.opengl.GLUtils -import android.util.Log -import com.blankj.utilcode.util.Utils -import com.lalilu.R -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.FloatBuffer - - -object ShaderUtils { - private const val LOGTAG = "ShaderUtils" - private const val iResolutionLocation = 1 - private const val iTimeLocation = 2 - private const val iFrameRateLocation = 3 - private const val iFrameLocation = 4 - private const val iMouseLocation = 5 - const val iChannel0Location = 6 - const val iChannel1Location = 7 - const val iChannel2Location = 8 - const val iChannel3Location = 9 - const val iChannelResolutionLocation = 10 - private const val mafPositionLocation = 14 - private const val mavPositionLocation = 15 - const val mUMatrixLocation = 16 - - //顶点坐标 - private val vertexData = floatArrayOf( - -1f, -1f, 0.0f, // bottom left - 1f, -1f, 0.0f, // bottom right - -1f, 1f, 0.0f, // top left - 1f, 1f, 0.0f, // top right - ) - - // 纹理坐标 - private val textureData = floatArrayOf( - 0f, 0f, 0.0f, // top left - 1f, 0f, 0.0f, // top right - 0f, 1f, 0.0f, // bottom left - 1f, 1f, 0.0f, // bottom right - ) - - private const val COORDS_PER_VERTEX = 3 //每一次取点的时候取几个点 - private const val vertexStride = COORDS_PER_VERTEX * 4 // 4 bytes per vertex //每一次取的总的点 大小 - private val vertexCount: Int = vertexData.size / COORDS_PER_VERTEX - - private val vertexBuffer: FloatBuffer by lazy { - ByteBuffer.allocateDirect(vertexData.size * 4) - .order(ByteOrder.nativeOrder()) - .asFloatBuffer() - .put(vertexData) - .also { it.position(0) } - } - - private val textureBuffer: FloatBuffer by lazy { - ByteBuffer.allocateDirect(textureData.size * 4) - .order(ByteOrder.nativeOrder()) - .asFloatBuffer() - .put(textureData) - .also { it.position(0) } - } - - val VertexCode: String by lazy { - Utils.getApp().resources - .openRawResource(R.raw.vertex_shader_template) - .readBytes() - .decodeToString() - } - - val FragmentCodeTemplate: String by lazy { - Utils.getApp().resources - .openRawResource(R.raw.fragment_shader_template) - .readBytes() - .decodeToString() - } - - fun bindUniformVariable(context: ShaderToyContext) { - GLES30.glUniform1f(iTimeLocation, context.iTime) - GLES30.glUniform1i(iFrameLocation, context.iFrame) - GLES30.glUniform1f(iFrameRateLocation, context.iFrameRate) - GLES30.glUniform4fv(iMouseLocation, 1, context.mMouse, 0) - GLES30.glUniform3fv(iResolutionLocation, 1, context.mResolution, 0) - } - - fun drawVertex(action: () -> Unit = {}) { - action() - - GLES30.glEnableVertexAttribArray(mavPositionLocation) - GLES30.glEnableVertexAttribArray(mafPositionLocation) - - //设置顶点位置值 - GLES30.glVertexAttribPointer( - mavPositionLocation, - COORDS_PER_VERTEX, - GLES30.GL_FLOAT, - false, - vertexStride, - vertexBuffer - ) - GLES30.glVertexAttribPointer( - mafPositionLocation, - COORDS_PER_VERTEX, - GLES30.GL_FLOAT, - false, - vertexStride, - textureBuffer - ) - - GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, vertexCount) - - GLES30.glDisableVertexAttribArray(mavPositionLocation) - GLES30.glDisableVertexAttribArray(mafPositionLocation) - } - - /** - * 创建一个着色器程序 - * - * @return 该着色器在内存中的ID - */ - fun createProgram(vertexCode: String, fragmentCode: String): Int { - // 创建GL程序 - // Create the GL program - val programId = GLES30.glCreateProgram() - - // 加载、编译vertex shader和fragment shader - // Load and compile vertex shader and fragment shader - val vertexShader = GLES30.glCreateShader(GLES30.GL_VERTEX_SHADER) - val fragmentShader = GLES30.glCreateShader(GLES30.GL_FRAGMENT_SHADER) - GLES30.glShaderSource(vertexShader, vertexCode) - GLES30.glShaderSource(fragmentShader, fragmentCode) - GLES30.glCompileShader(vertexShader) - GLES30.glCompileShader(fragmentShader) - checkGLError { "Shader create error" + fragmentCode } - - // 将shader程序附着到GL程序上 - // Attach the compiled shaders to the GL program - GLES30.glAttachShader(programId, vertexShader) - GLES30.glAttachShader(programId, fragmentShader) - - // 链接GL程序 - // Link the GL program - GLES30.glLinkProgram(programId) - - checkGLError() - return programId - } - - /** - * 创建一个空的纹理对象 - * - * @return 纹理对象的ID - */ - fun createTexture(width: Int, height: Int): Int { - val texture = IntArray(1) - GLES30.glGenTextures(1, texture, 0) - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texture[0]) - GLES30.glTexParameteri( - GLES30.GL_TEXTURE_2D, - GLES30.GL_TEXTURE_WRAP_S, - GLES30.GL_CLAMP_TO_EDGE - ) - GLES30.glTexParameteri( - GLES30.GL_TEXTURE_2D, - GLES30.GL_TEXTURE_WRAP_T, - GLES30.GL_CLAMP_TO_EDGE - ) - GLES30.glTexParameteri( - GLES30.GL_TEXTURE_2D, - GLES30.GL_TEXTURE_MIN_FILTER, - GLES30.GL_NEAREST - ) - GLES30.glTexParameteri( - GLES30.GL_TEXTURE_2D, - GLES30.GL_TEXTURE_MAG_FILTER, - GLES30.GL_NEAREST - ) - GLES30.glTexImage2D( - GLES30.GL_TEXTURE_2D, 0, - GLES30.GL_RGBA, width, height, 0, - GLES30.GL_RGBA, - GLES30.GL_UNSIGNED_BYTE, - null, - ) - - checkGLError() - return texture[0] - } - - - /** - * 使用bitmap创建一个纹理对象 - * - * @return 纹理对象的ID - */ - fun createTexture(bitmap: Bitmap): Int { - val texture = IntArray(1) - GLES30.glGenTextures(1, texture, 0) - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texture[0]) - GLES30.glTexParameteri( - GLES30.GL_TEXTURE_2D, - GLES30.GL_TEXTURE_WRAP_S, - GLES30.GL_CLAMP_TO_EDGE - ) - GLES30.glTexParameteri( - GLES30.GL_TEXTURE_2D, - GLES30.GL_TEXTURE_WRAP_T, - GLES30.GL_CLAMP_TO_EDGE - ) - GLES30.glTexParameteri( - GLES30.GL_TEXTURE_2D, - GLES30.GL_TEXTURE_MIN_FILTER, - GLES30.GL_NEAREST - ) - GLES30.glTexParameteri( - GLES30.GL_TEXTURE_2D, - GLES30.GL_TEXTURE_MAG_FILTER, - GLES30.GL_NEAREST - ) - GLUtils.texImage2D(GLES30.GL_TEXTURE_2D, 0, bitmap, 0) - - checkGLError() - return texture[0] - } - - /** - * 更新某一纹理的内容 - * - * @param textureId 目标纹理对象的id - * @param bitmap - * - * @return 纹理对象的ID - */ - fun updateTexture(textureId: Int, bitmap: Bitmap): Int { - GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureId) - GLUtils.texImage2D(GLES30.GL_TEXTURE_2D, 0, bitmap, 0) - return textureId - } - - /** - * 创建一个帧缓冲对象 - * - * @return 帧缓冲对象的ID - */ - fun createFBO(textureId: Int): Int { - // 创建帧缓冲 - val fbo = IntArray(1) - GLES30.glGenFramebuffers(1, fbo, 0) - // 绑定帧缓冲 - GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, fbo[0]) - // 把纹理作为帧缓冲的附件 - GLES30.glFramebufferTexture2D( - GLES30.GL_FRAMEBUFFER, - GLES30.GL_COLOR_ATTACHMENT0, - GLES30.GL_TEXTURE_2D, textureId, 0 - ) - - checkGLError { "FBO create error" } - checkFrameBufferError() - - return fbo[0] - } - - private fun checkGLError(msg: () -> String = { "" }) { - val error = GLES30.glGetError() - if (error != GLES30.GL_NO_ERROR) { - val hexErrorCode = Integer.toHexString(error) - val message = "[GLError: $hexErrorCode] ${msg()}" - Log.e(LOGTAG, message) - throw RuntimeException(message) - } - } - - private fun checkFrameBufferError() { - // 检查帧缓冲是否完整 - val fboStatus = GLES30.glCheckFramebufferStatus(GLES30.GL_FRAMEBUFFER) - if (fboStatus != GLES30.GL_FRAMEBUFFER_COMPLETE) { - Log.e(LOGTAG, "initFBO failed, status: $fboStatus") - throw RuntimeException("GLError") - } - } -} From 73a5169309d5157d70f0a024af0fa1bb21a37b39 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 3 Feb 2025 01:23:45 +0800 Subject: [PATCH 173/213] =?UTF-8?q?[modify]=E6=B7=BB=E5=8A=A0=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E6=98=BE=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/lyric/impl/LyricContentWords.kt | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt index 19cffde20..8faa9d570 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt @@ -70,6 +70,7 @@ fun LyricContentWords( val paddingHorizontal = remember { 40.dp } val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } + val gapHeight = remember(translationGap) { with(density) { translationGap.toPx() } } val fullSentence = remember { lyric.getSentenceContent() } val actualConstraints = remember { @@ -103,6 +104,16 @@ fun LyricContentWords( style = textStyle ) } + + val translateResult = remember { + val text = lyric.translation.firstOrNull()?.content ?: return@remember null + textMeasurer.measure( + text = text, + constraints = actualConstraints, + style = textStyle.copy(fontSize = textStyle.fontSize * 0.7f) + ) + } + val scale = animateFloatAsState( targetValue = when { isCurrent() -> 100f @@ -131,10 +142,14 @@ fun LyricContentWords( ) val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } + val translateHeight = remember(translateResult) { + translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f + } val pivotOffset = remember(textHeight) { Offset.Zero.copy(y = textHeight / 2f, x = 0f) } - val heightDp = remember(textHeight) { - density.run { textHeight.toDp() + paddingVertical * 2 } + val height = remember(isTranslationShow(), textHeight, translateHeight) { + textHeight + if (isTranslationShow() && translateHeight > 0) translateHeight + gapHeight else 0f } + val heightDp = remember(height) { density.run { height.toDp() + paddingVertical * 2 } } val animateHeight = animateDpAsState( targetValue = heightDp, animationSpec = spring( @@ -143,6 +158,9 @@ fun LyricContentWords( ), label = "" ) + val translationTopLeft = remember(textHeight) { + Offset.Zero.copy(y = textHeight + gapHeight) + } Canvas( modifier = modifier @@ -182,7 +200,7 @@ fun LyricContentWords( pivot = pivotOffset ) { drawText( - color = Color(0x50FFFFFF), + color = Color(0x80FFFFFF), shadow = DEFAULT_TEXT_SHADOW, textLayoutResult = textResult, ) @@ -231,6 +249,14 @@ fun LyricContentWords( } } } + + if (translateResult == null) return@scale + drawText( + color = Color(0x80FFFFFF), + topLeft = translationTopLeft, + shadow = DEFAULT_TEXT_SHADOW, + textLayoutResult = translateResult, + ) } } } From fe9308a2ae1f4037b8319bc8d322e3967ccb4ba2 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 3 Feb 2025 01:26:10 +0800 Subject: [PATCH 174/213] =?UTF-8?q?[modify]=E5=8E=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E4=BB=A3=E7=A0=81=EF=BC=8C=E8=A7=A3=E5=86=B3=E9=95=BF?= =?UTF-8?q?=E6=8C=89=E8=A7=A6=E5=8F=91=E5=A4=9A=E6=AC=A1=E6=8C=AF=E5=8A=A8?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E6=95=B4?= =?UTF-8?q?=E7=90=86=E5=AE=8C=E5=96=84=E6=92=AD=E6=94=BE=E5=99=A8=E5=92=8C?= =?UTF-8?q?=E5=AF=B9=E5=88=97=E6=93=8D=E4=BD=9C=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lmusic/AppModule.kt | 5 --- .../lmusic/compose/LayoutWrapperContent.kt | 2 +- .../compose/screen/detail/SongDetailScreen.kt | 4 +- .../compose/screen/detail/SongPlayAction.kt | 12 +----- .../compose/screen/playing/PlayingLayout.kt | 2 +- .../screen/playing/PlayingLayoutExpended.kt | 13 +++--- .../compose/screen/playing/PlaylistLayout.kt | 2 +- .../screen/songs/SongsScreenContent.kt | 12 +++--- .../lalilu/lmusic/extension/LatestPanel.kt | 12 +----- .../com/lalilu/lmusic/extension/SleepTimer.kt | 2 +- .../lmusic/viewmodel/PlayingViewModel.kt | 42 ------------------- .../com/lalilu/component/card/SongCard.kt | 5 --- .../component/viewmodel/PlayingViewModel.kt | 20 --------- .../lalbum/screen/AlbumDetailScreenContent.kt | 12 +++--- .../screen/ArtistDetailScreenContent.kt | 12 +++--- .../java/com/lalilu/lhistory/HistoryPanel.kt | 5 --- .../main/java/com/lalilu/lplayer/MPlayer.kt | 27 +++++++++++- .../lplayer/{extensions => action}/Action.kt | 2 +- .../com/lalilu/lplayer/action/MediaControl.kt | 32 ++++++++++++++ .../{extensions => action}/PlayerAction.kt | 8 +++- .../lalilu/lplayer/extensions/Extensions.kt | 30 ------------- .../lalilu/lplayer/extensions/QueueAction.kt | 13 ------ .../detail/PlaylistDetailScreenContent.kt | 19 ++++----- 23 files changed, 105 insertions(+), 188 deletions(-) delete mode 100644 app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt delete mode 100644 component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt rename lplayer/src/main/java/com/lalilu/lplayer/{extensions => action}/Action.kt (50%) create mode 100644 lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt rename lplayer/src/main/java/com/lalilu/lplayer/{extensions => action}/PlayerAction.kt (80%) delete mode 100644 lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index 408f0b2e2..30a70f19c 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -10,7 +10,6 @@ import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.transitionFactory import com.lalilu.R -import com.lalilu.component.viewmodel.IPlayingViewModel import com.lalilu.lmusic.Config.LRCSHARE_BASEURL import com.lalilu.lmusic.api.lrcshare.LrcShareApi import com.lalilu.lmusic.datastore.SettingsSp @@ -24,12 +23,10 @@ import com.lalilu.lmusic.utils.coil.keyer.LAlbumCoverKeyer import com.lalilu.lmusic.utils.coil.keyer.LSongCoverKeyer import com.lalilu.lmusic.utils.coil.keyer.MediaItemKeyer import com.lalilu.lmusic.utils.extension.toBitmap -import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lmusic.viewmodel.SearchLyricViewModel import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext -import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module @@ -81,8 +78,6 @@ val AppModule = module { } val ViewModelModule = module { - viewModelOf(::PlayingViewModel) - viewModel { get() } viewModelOf(::SearchLyricViewModel) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt index be5c36383..78ad6afc0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt @@ -167,7 +167,7 @@ private fun LayoutForMobile( BottomSheetLayout( modifier = modifier.fillMaxSize(), scrimColor = Color.Black.copy(alpha = 0.5f), - skipHalfExpanded = false, + skipHalfExpanded = true, sheetBackgroundColor = MaterialTheme.colors.background, animationSpec = tween( durationMillis = 200, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt index a0d2c445b..32312004f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt @@ -17,7 +17,7 @@ import com.lalilu.component.base.screen.ScreenType import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LSong -import com.lalilu.lplayer.extensions.QueueAction +import com.lalilu.lplayer.action.PlayerAction import com.zhangke.krouter.annotation.Destination import com.zhangke.krouter.annotation.Param import org.koin.core.parameter.parametersOf @@ -50,7 +50,7 @@ data class SongDetailScreen( onAction = { val song = LMedia.get(id = mediaId) ?: return@Static - QueueAction.AddToNext(song.id).action() + PlayerAction.AddToNext(song.id).action() DynamicTipsItem.Static( title = song.metadata.title, subTitle = "下一首播放", diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt index 44e1feb13..b7cfb8016 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt @@ -24,9 +24,8 @@ import androidx.compose.ui.unit.sp import com.lalilu.R import com.lalilu.RemixIcon import com.lalilu.component.base.screen.ScreenAction -import com.lalilu.component.extension.singleViewModel -import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.action.MediaControl import com.lalilu.remixicon.Media import com.lalilu.remixicon.media.pauseLine import com.lalilu.remixicon.media.playLine @@ -34,18 +33,11 @@ import com.lalilu.remixicon.media.playLine @OptIn(ExperimentalMaterialApi::class) fun provideSongPlayAction(mediaId: String): ScreenAction.Dynamic { return ScreenAction.Dynamic { actionContext -> - val playingVM: PlayingViewModel = singleViewModel() val color = Color(0xFF008394) Surface( color = color.copy(0.2f), - onClick = { - playingVM.play( - mediaId = mediaId, - addToNext = true, - playOrPause = true - ) - } + onClick = { MediaControl.addAndPlay(mediaId) } ) { Row( modifier = Modifier.padding(horizontal = 20.dp), diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index c182ddcdf..0b5e7559b 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -57,8 +57,8 @@ import com.lalilu.lmusic.compose.screen.playing.seekbar.ClickPart import com.lalilu.lmusic.compose.screen.playing.seekbar.SeekbarLayout import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.action.PlayerAction import com.lalilu.lplayer.extensions.PlayMode -import com.lalilu.lplayer.extensions.PlayerAction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt index a394ca5e0..56696fb14 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt @@ -37,18 +37,17 @@ import coil3.request.crossfade import coil3.request.transformations import com.lalilu.R import com.lalilu.RemixIcon -import com.lalilu.component.extension.singleViewModel +import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.utils.coil.BlurTransformation -import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplayer.MPlayer -import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.action.PlayerAction import com.lalilu.remixicon.Media import com.lalilu.remixicon.media.playLine +import org.koin.compose.koinInject @Composable fun PlayingLayoutExpended( modifier: Modifier = Modifier, - playingVM: PlayingViewModel = singleViewModel(), ) { val currentPlaying = MPlayer.currentMediaItem val context = LocalContext.current @@ -114,7 +113,7 @@ fun PlayingLayoutExpended( } SongDetailPanel(playable = currentPlaying) - ControlPanel(playingVM) + ControlPanel() } } } @@ -157,10 +156,10 @@ fun SongDetailPanel( @Composable fun ControlPanel( - playingVM: PlayingViewModel = singleViewModel(), + settingsSp: SettingsSp = koinInject() ) { val isPlaying = remember { derivedStateOf { MPlayer.isPlaying } } - var playMode by playingVM.settingsSp.playMode + var playMode by settingsSp.playMode Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index e2b89300d..bd4bfce82 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -36,7 +36,7 @@ import coil3.compose.AsyncImage import com.lalilu.component.navigation.AppRouter import com.lalilu.lmusic.compose.screen.playing.util.DiffUtil import com.lalilu.lmusic.compose.screen.playing.util.ListUpdateCallback -import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.action.PlayerAction import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index e0d80b6ac..41c9f07eb 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -19,9 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -46,7 +44,7 @@ import com.lalilu.lmedia.entity.Metadata import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lmusic.LMusicTheme import com.lalilu.lmusic.viewmodel.SongsEvent -import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.action.MediaControl import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collectLatest @@ -63,7 +61,6 @@ internal fun SongsScreenContent( onClickGroup: (GroupIdentity) -> Unit = {} ) { val density = LocalDensity.current - val hapticFeedback = LocalHapticFeedback.current val listState: LazyListState = rememberLazyListState() val statusBar = WindowInsets.statusBars val scroller = rememberLazyListAnimateScroller( @@ -176,12 +173,13 @@ internal fun SongsScreenContent( if (isSelecting()) { onSelect(it) } else { - PlayerAction.PlayById(it.id).action() + MediaControl.playWithList( + mediaIds = list.map(LSong::id), + mediaId = it.id + ) } }, onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - if (isSelecting()) { onSelect(it) } else { diff --git a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt index bc57421dc..632fcd35e 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt @@ -12,13 +12,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lalilu.component.LazyGridContent import com.lalilu.component.navigation.AppRouter -import com.lalilu.lmedia.entity.LSong import com.lalilu.lmusic.compose.component.card.RecommendCard import com.lalilu.lmusic.compose.component.card.RecommendRow import com.lalilu.lmusic.compose.component.card.RecommendTitle import com.lalilu.lmusic.viewmodel.LibraryViewModel -import com.lalilu.lmusic.viewmodel.PlayingViewModel import com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.action.MediaControl import org.koin.compose.koinInject @@ -28,7 +27,6 @@ object LatestPanel : LazyGridContent { @Composable override fun register(): LazyGridScope.() -> Unit { val libraryVM: LibraryViewModel = koinInject() - val playingVM: PlayingViewModel = koinInject() val items by libraryVM.recentlyAdded return fun LazyGridScope.() { @@ -73,13 +71,7 @@ object LatestPanel : LazyGridContent { .jump() }, isPlaying = { MPlayer.isItemPlaying(it.id) }, - onClickButton = { - playingVM.play( - mediaId = it.id, - playOrPause = true, - addToNext = true - ) - } + onClickButton = { MediaControl.addAndPlay(mediaId = it.id) } ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt index 614127c5d..e29c10458 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/SleepTimer.kt @@ -54,7 +54,7 @@ import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.extension.enableFor import com.lalilu.component.settings.SettingSwitcher import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.action.PlayerAction import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import org.koin.compose.koinInject diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt deleted file mode 100644 index bbf94d03c..000000000 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lalilu.lmusic.viewmodel - -import androidx.lifecycle.viewModelScope -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.lmusic.datastore.SettingsSp -import com.lalilu.lplayer.MPlayer -import com.lalilu.lplayer.extensions.PlayerAction -import com.lalilu.lplayer.extensions.QueueAction -import kotlinx.coroutines.launch - -class PlayingViewModel( - val settingsSp: SettingsSp -) : IPlayingViewModel() { - /** - * 综合播放操作 - * - * @param mediaId 目标歌曲的ID - * @param mediaIds 歌曲ID列表 - * @param playOrPause 当前正在播放则暂停,暂停则开始播放 - * @param addToNext 是否在播放前将该歌曲移动到下一首播放的位置 - */ - override fun play( - mediaId: String, - mediaIds: List?, - playOrPause: Boolean, - addToNext: Boolean, - ) { - viewModelScope.launch { - if (!mediaIds.isNullOrEmpty()) { - QueueAction.UpdateList(mediaIds).action() - } - if (addToNext) { - QueueAction.AddToNext(mediaId).action() - } - if (mediaId == MPlayer.currentMediaItem?.mediaId && playOrPause) { - PlayerAction.PlayOrPause.action() - } else { - PlayerAction.PlayById(mediaId).action() - } - } - } -} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/card/SongCard.kt b/component/src/main/java/com/lalilu/component/card/SongCard.kt index 1ba23ae9b..2f99c0cc6 100644 --- a/component/src/main/java/com/lalilu/component/card/SongCard.kt +++ b/component/src/main/java/com/lalilu/component/card/SongCard.kt @@ -34,10 +34,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -277,8 +275,6 @@ fun SongCardImage( onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, ) { - val haptic = LocalHapticFeedback.current - Surface( modifier = modifier, elevation = 2.dp, @@ -292,7 +288,6 @@ fun SongCardImage( interactionSource = interaction, indication = null, onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) onLongClick() }, onClick = onClick diff --git a/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt b/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt deleted file mode 100644 index f001ecc88..000000000 --- a/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.lalilu.component.viewmodel - -import androidx.lifecycle.ViewModel - -abstract class IPlayingViewModel : ViewModel() { - /** - * 综合播放操作 - * - * @param mediaId 目标歌曲的ID - * @param mediaIds 歌曲ID列表 - * @param playOrPause 当前正在播放则暂停,暂停则开始播放 - * @param addToNext 是否在播放前将该歌曲移动到下一首播放的位置 - */ - abstract fun play( - mediaId: String, - mediaIds: List? = null, - playOrPause: Boolean = false, - addToNext: Boolean = false, - ) -} \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt index f8651e32e..b94f7916c 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt @@ -22,10 +22,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -46,7 +44,7 @@ import com.lalilu.lalbum.viewModel.AlbumDetailEvent import com.lalilu.lmedia.entity.LAlbum import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity -import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.action.MediaControl import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.emptyFlow @@ -67,7 +65,6 @@ fun AlbumDetailScreenContent( val statusBar = WindowInsets.statusBars val density = LocalDensity.current val stickyHeaderContentType = remember { "group" } - val hapticFeedback = LocalHapticFeedback.current val scroller = rememberLazyListAnimateScroller( listState = listState, keys = keys @@ -189,12 +186,13 @@ fun AlbumDetailScreenContent( if (isSelecting()) { onSelect(it) } else { - PlayerAction.PlayById(it.id).action() + MediaControl.playWithList( + mediaIds = list.map(LSong::id), + mediaId = it.id + ) } }, onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - if (isSelecting()) { onSelect(it) } else { diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt index c340f68cf..ad5193081 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt @@ -18,9 +18,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -43,7 +41,7 @@ import com.lalilu.lmedia.entity.LArtist import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.MPlayer -import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.action.MediaControl import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.emptyFlow @@ -64,7 +62,6 @@ internal fun ArtistDetailScreenContent( val statusBar = WindowInsets.statusBars val density = LocalDensity.current val stickyHeaderContentType = remember { "group" } - val hapticFeedback = LocalHapticFeedback.current val scroller = rememberLazyListAnimateScroller( listState = listState, keys = keys @@ -181,12 +178,13 @@ internal fun ArtistDetailScreenContent( if (isSelecting()) { onSelect(it) } else { - PlayerAction.PlayById(it.id).action() + MediaControl.playWithList( + mediaIds = list.map(LSong::id), + mediaId = it.id + ) } }, onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - if (isSelecting()) { onSelect(it) } else { diff --git a/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt index dcfaac150..3bc45561e 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt @@ -8,8 +8,6 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.lalilu.component.LazyGridContent import com.lalilu.component.base.LocalWindowSize @@ -30,7 +28,6 @@ class HistoryPanel : LazyGridContent { override fun register(): LazyGridScope.() -> Unit { val historyVM = koinInject() val widthSizeClass = LocalWindowSize.current.widthSizeClass - val haptic = LocalHapticFeedback.current val items by historyVM.historyState return fun LazyGridScope.() { @@ -56,8 +53,6 @@ class HistoryPanel : LazyGridContent { }, onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - AppRouter.route("/pages/songs/detail") .with("mediaId", item.id) .jump() diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index 9f9a05464..7ded75b79 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -16,8 +16,10 @@ import androidx.media3.session.MediaBrowser import androidx.media3.session.SessionToken import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.Utils +import com.lalilu.lmedia.LMedia +import com.lalilu.lplayer.action.Action +import com.lalilu.lplayer.action.PlayerAction import com.lalilu.lplayer.extensions.PlayMode -import com.lalilu.lplayer.extensions.PlayerAction import com.lalilu.lplayer.extensions.playMode import com.lalilu.lplayer.service.CustomCommand import com.lalilu.lplayer.service.MService @@ -89,7 +91,7 @@ object MPlayer : CoroutineScope { } } - fun doAction(action: PlayerAction) = launch(Dispatchers.Main) { + fun doAction(action: Action) = launch(Dispatchers.Main) { val browser = browserFuture.await() when (action) { @@ -155,6 +157,27 @@ object MPlayer : CoroutineScope { is PlayerAction.SetPlayMode -> { browser.playMode = action.playMode } + + is PlayerAction.AddToNext -> { + val item = browser.getItem(action.mediaId).await().value ?: return@launch + val index = browser.currentTimeline.indexOf(action.mediaId) + + if (index != -1) { + val offset = if (index > browser.currentMediaItemIndex) 1 else 0 + browser.moveMediaItem(index, browser.currentMediaItemIndex + offset) + } else { + browser.addMediaItem(browser.currentMediaItemIndex + 1, item) + } + } + + is PlayerAction.UpdateList -> { + val index = action.mediaId?.let { action.mediaIds.indexOf(it) } + ?.takeIf { it >= 0 } + ?: 0 + + val items = LMedia.mapItems(action.mediaIds) + browser.setMediaItems(items, index, 0) + } } } diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt b/lplayer/src/main/java/com/lalilu/lplayer/action/Action.kt similarity index 50% rename from lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt rename to lplayer/src/main/java/com/lalilu/lplayer/action/Action.kt index e2ff3b245..93b38ec05 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/action/Action.kt @@ -1,4 +1,4 @@ -package com.lalilu.lplayer.extensions +package com.lalilu.lplayer.action interface Action { diff --git a/lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt b/lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt new file mode 100644 index 000000000..b80e3c8c0 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt @@ -0,0 +1,32 @@ +package com.lalilu.lplayer.action + +import com.lalilu.lplayer.MPlayer + +/** + * 常用的媒体操作的方法封装 + */ +object MediaControl { + + /** + * 将当前元素添加进播放列表的下一位置,并开始播放 + * 若当前播放歌曲就是目标歌曲,则暂停或播放 + */ + fun addAndPlay(mediaId: String) { + // 将当前元素添加进播放列表的下一位置,若已存在则不移动 + PlayerAction.AddToNext(mediaId).action() + + if (MPlayer.currentMediaItem?.mediaId == mediaId) { + PlayerAction.PlayOrPause.action() + } else { + PlayerAction.PlayById(mediaId = mediaId) + .action() + } + } + + /** + * 替换播放列表,并播放目标歌曲 + */ + fun playWithList(mediaIds: List, mediaId: String) { + PlayerAction.UpdateList(mediaIds, mediaId).action() + } +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt similarity index 80% rename from lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt rename to lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt index 7718a2577..298e041e0 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt @@ -1,6 +1,7 @@ -package com.lalilu.lplayer.extensions +package com.lalilu.lplayer.action import com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.extensions.PlayMode sealed class PlayerAction : Action { override fun action() { @@ -15,6 +16,11 @@ sealed class PlayerAction : Action { data class SeekTo(val positionMs: Long) : PlayerAction() data class PauseWhenCompletion(val cancel: Boolean = false) : PlayerAction() data class SetPlayMode(val playMode: PlayMode) : PlayerAction() + data class AddToNext(val mediaId: String) : PlayerAction() + data class UpdateList( + val mediaIds: List, + val mediaId: String? = null + ) : PlayerAction() sealed class CustomAction(val name: String) : PlayerAction() data object PlayOrPause : CustomAction(PlayOrPause::class.java.name) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt index 0b0f21e2b..be67cbc0f 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt @@ -6,33 +6,3 @@ import android.support.v4.media.session.PlaybackStateCompat fun MediaSessionCompat.isPlaying(): Boolean { return PlaybackStateCompat.STATE_PLAYING == controller.playbackState?.state } - -private fun lerp(start: Float, stop: Float, fraction: Float): Float = - (1f - fraction) * start + fraction * stop - -fun List.getNextOf(item: T, cycle: Boolean = false): T? { - val nextIndex = indexOf(item) + 1 - return getOrNull(if (cycle) nextIndex % size else nextIndex) -} - -fun List.getPreviousOf(item: T, cycle: Boolean = false): T? { - var previousIndex = indexOf(item) - 1 - if (previousIndex < 0 && cycle) { - previousIndex = size - 1 - } - return getOrNull(previousIndex) -} - -fun List.move(from: Int, to: Int): List = toMutableList().apply { - val targetIndex = if (from < to) to else to + 1 - val temp = removeAt(from) - add(targetIndex, temp) -} - -fun List.add(index: Int = -1, item: T): List = toMutableList().apply { - if (index == -1) add(item) else add(index, item) -} - -fun List.removeAt(index: Int): List = toMutableList().apply { - removeAt(index) -} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt deleted file mode 100644 index 1e226b8af..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.lalilu.lplayer.extensions - -sealed class QueueAction : Action { - override fun action() { - } - - data object Clear : QueueAction() - data object Shuffle : QueueAction() - data class AddToNext(val id: String) : QueueAction() - data class Remove(val id: String) : QueueAction() - data class UpdatePlaying(val id: String?) : QueueAction() - data class UpdateList(val ids: List) : QueueAction() -} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt index 893071a3b..16067b6c7 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt @@ -22,9 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.gigamole.composefadingedges.FadingEdgesGravity @@ -43,7 +41,7 @@ import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity -import com.lalilu.lplayer.extensions.PlayerAction +import com.lalilu.lplayer.action.MediaControl import com.lalilu.lplaylist.entity.LPlaylist import com.lalilu.lplaylist.viewmodel.PlaylistDetailEvent import com.lalilu.remixicon.Design @@ -70,7 +68,6 @@ internal fun PlaylistDetailScreenContent( ) { val density = LocalDensity.current val statusBar = WindowInsets.statusBars - val hapticFeedback = LocalHapticFeedback.current val listState: LazyListState = rememberLazyListState() val stickyHeaderContentType = remember { "group" } val scroller = rememberLazyListAnimateScroller( @@ -207,12 +204,13 @@ internal fun PlaylistDetailScreenContent( if (isSelecting()) { onSelect(item) } else { - PlayerAction.PlayById(item.id).action() + MediaControl.playWithList( + mediaIds = playlistState.map(LSong::id), + mediaId = item.id + ) } }, onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - if (isSelecting()) { onSelect(item) } else { @@ -255,12 +253,13 @@ internal fun PlaylistDetailScreenContent( if (isSelecting()) { onSelect(item) } else { - PlayerAction.PlayById(item.id).action() + MediaControl.playWithList( + mediaIds = playlistState.map(LSong::id), + mediaId = item.id + ) } }, onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - if (isSelecting()) { onSelect(item) } else { From cb2d715533aca14af4269d28f5ce6fd84d887afa Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 15 Feb 2025 16:36:36 +0800 Subject: [PATCH 175/213] =?UTF-8?q?[modify]=E5=90=8C=E6=AD=A5=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E6=A0=B7=E5=BC=8F=EF=BC=8C=E4=BC=98=E5=8C=96=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E5=B8=83=E5=B1=80=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=95=B4=E7=90=86=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/playing/lyric/LyricContext.kt | 15 ++ .../screen/playing/lyric/LyricLayout.kt | 33 ++-- .../screen/playing/lyric/LyricSettings.kt | 78 +++++++++ .../compose/screen/playing/lyric/TTML.kt | 159 ----------------- .../playing/lyric/impl/LyricContentNormal.kt | 146 +++++++--------- .../playing/lyric/impl/LyricContentWords.kt | 164 +++++++++--------- 6 files changed, 259 insertions(+), 336 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContext.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/TTML.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContext.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContext.kt new file mode 100644 index 000000000..1827b81c2 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricContext.kt @@ -0,0 +1,15 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric + +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.unit.Constraints + +/** + * 歌词组件的上下文环境 + */ +data class LyricContext( + val currentTime: () -> Long, + val currentIndex: () -> Int, + val isUserScrolling: () -> Boolean, + val screenConstraints: Constraints, + val textMeasurer: TextMeasurer, +) \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt index bfe569565..f2437a268 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt @@ -48,7 +48,6 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.isActive -import kotlin.math.abs @OptIn(FlowPreview::class) @@ -67,7 +66,8 @@ fun LyricLayout( fontFamily: State = remember { mutableStateOf(null) } ) { val textMeasurer = rememberTextMeasurer() - val isUserScrolling = remember(isUserScrollEnable()) { mutableStateOf(isUserScrollEnable()) } + val isUserScrolling = remember { mutableStateOf(isUserScrollEnable()) } + .also { it.value = isUserScrollEnable() } val recorder = remember { ItemRecorder() } val scroller = rememberLazyListAnimateScroller( listState = listState, @@ -119,6 +119,17 @@ fun LyricLayout( } } + val settings = remember { mutableStateOf(LyricSettings()) } + val context = remember { + LyricContext( + currentTime = currentTime, + currentIndex = { currentItemIndex.value }, + isUserScrolling = { isUserScrolling.value }, + screenConstraints = screenConstraints, + textMeasurer = textMeasurer, + ) + } + Box(modifier = Modifier.fillMaxSize()) { LazyColumn( state = listState, @@ -142,26 +153,20 @@ fun LyricLayout( when (item) { is LyricItem.NormalLyric -> LyricContentNormal( lyric = item, + index = index, modifier = Modifier, - textMeasurer = textMeasurer, - fontFamily = { fontFamily.value }, - currentTime = currentTime, - screenConstraints = screenConstraints, - offsetToCurrent = { abs(index - currentItemIndex.value) }, - isCurrent = { item.key == currentItem.value?.key }, + settings = settings.value, + context = context, onLongClick = { if (isUserClickEnable()) onItemLongClick(item) }, onClick = { if (isUserClickEnable()) onItemClick(item) } ) is LyricItem.WordsLyric -> LyricContentWords( lyric = item, + index = index, modifier = Modifier, - textMeasurer = textMeasurer, - fontFamily = { fontFamily.value }, - currentTime = currentTime, - screenConstraints = screenConstraints, - offsetToCurrent = { abs(index - currentItemIndex.value) }, - isCurrent = { item.key == currentItem.value?.key }, + settings = settings.value, + context = context, onLongClick = { if (isUserClickEnable()) onItemLongClick(item) }, onClick = { if (isUserClickEnable()) onItemClick(item) } ) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt new file mode 100644 index 000000000..37f3fb389 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt @@ -0,0 +1,78 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +internal val DEFAULT_TEXT_SHADOW = Shadow( + color = Color.Black.copy(alpha = 0.2f), + offset = Offset(x = 0f, y = 1f), + blurRadius = 1f +) + +data class LyricSettings( + // 布局样式配置 + val textAlign: TextAlign = TextAlign.Start, + val containerPadding: PaddingValues = PaddingValues(horizontal = 40.dp, vertical = 15.dp), + val gapSize: Dp = 10.dp, + val scaleRange: ClosedRange = 0.85f..1f, + + // 字体样式配置 + val mainFontSize: TextUnit = 26.sp, + val mainLineHeight: TextUnit = 28.sp, + val mainFontWeight: Int = FontWeight.Black.weight, + val mainFontFamily: FontFamily? = null, + val translationFontSize: TextUnit = 22.sp, + val translationLineHeight: TextUnit = 26.sp, + val translationFontWeight: Int = FontWeight.Bold.weight, + val translationFontFamily: FontFamily? = null, + + // 特殊效果开关 + val blurEffectEnable: Boolean = true, + val translationVisible: Boolean = true, + val variableFontWeightEnable: Boolean = false +) { + val mainTextStyle: TextStyle by lazy { + TextStyle.Default.copy( + fontSize = mainFontSize, + textAlign = textAlign, + lineHeight = mainLineHeight, + fontWeight = FontWeight(mainFontWeight), + fontFamily = mainFontFamily ?: FontFamily( + Font( + familyName = DeviceFontFamilyName("FontFamily.Monospace"), + variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) + ) + ) + ) + } + + val translationTextStyle: TextStyle by lazy { + TextStyle.Default.copy( + fontSize = translationFontSize, + textAlign = textAlign, + lineHeight = translationLineHeight, + fontWeight = FontWeight(translationFontWeight), + fontFamily = translationFontFamily ?: FontFamily( + Font( + familyName = DeviceFontFamilyName("FontFamily.Monospace"), + variationSettings = FontVariation.Settings( + FontVariation.weight(translationFontWeight) + ) + ) + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/TTML.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/TTML.kt deleted file mode 100644 index 6fdafceb6..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/TTML.kt +++ /dev/null @@ -1,159 +0,0 @@ -package com.lalilu.lmusic.compose.screen.playing.lyric - -import kotlinx.serialization.Serializable -import nl.adaptivity.xmlutil.serialization.XmlChildrenName -import nl.adaptivity.xmlutil.serialization.XmlSerialName -import nl.adaptivity.xmlutil.serialization.XmlValue - -/** - * TTML 示例 - * 详见:applemusic-like-lyrics - * - * ```xml - * - * - * - * - * - * ...... - * - * - * - *
- *

- * ほほえむ - * 君がいる - * 你微笑着 站在此处 - *

- * ...... - *
- * - *
- * ``` - * - * ```kotlin - * // 需要按照如下格式创建XML对象实例 - * XML { - * autoPolymorphic = true - * fast_0_90_2() - * } - * ``` - */ -@Serializable -@XmlSerialName(value = "tt", namespace = "http://www.w3.org/ns/ttml") -class TTML( - val head: TTMLHead, - val body: TTMLBody -) - -@Serializable -@XmlSerialName(value = "head") -class TTMLHead( - @XmlChildrenName("metadata") - val metadata: List -) - -@Serializable -sealed class MetadataItem { - - @Serializable - @XmlSerialName( - value = "agent", - namespace = "http://www.w3.org/ns/ttml#metadata", - prefix = "ttm" - ) - data class TTMLMetadataAgent( - @XmlSerialName(value = "type") - val type: String, - @XmlSerialName( - value = "id", - namespace = "http://www.w3.org/XML/1998/namespace", - prefix = "xml" - ) - val id: String - ) : MetadataItem() - - @Serializable - @XmlSerialName( - value = "meta", - namespace = "http://www.example.com/ns/amll", - prefix = "amll" - ) - data class TTMLMetadataItem( - val key: String, - val value: String - ) : MetadataItem() -} - -@Serializable -@XmlSerialName(value = "body") -class TTMLBody( - @XmlSerialName(value = "dur") - val dur: String, - @XmlSerialName(value = "div") - val div: TTMLBodyDiv, -) - -@Serializable -@XmlSerialName(value = "div") -data class TTMLBodyDiv( - @XmlSerialName("begin") - val begin: String, - @XmlSerialName("end") - val end: String, - val p: List -) - -@Serializable -@XmlSerialName(value = "p") -data class TTMLBodyDivP( - @XmlSerialName("begin") - val begin: String, - - @XmlSerialName("end") - val end: String, - - @XmlSerialName( - value = "key", - prefix = "itunes", - namespace = "http://music.apple.com/lyric-ttml-internal" - ) - val key: String, - - @XmlSerialName( - value = "agent", - namespace = "http://www.w3.org/ns/ttml#metadata", - prefix = "ttm" - ) - val agent: String, - val spans: List = emptyList() -) - -@Serializable -@XmlSerialName(value = "span") -data class TTMLSpan( - @XmlSerialName("begin") - val begin: String? = null, - - @XmlSerialName("end") - val end: String? = null, - - @XmlSerialName( - value = "role", - prefix = "ttm", - namespace = "http://www.w3.org/ns/ttml#metadata", - ) - val role: String? = null, - - @XmlSerialName( - value = "lang", - prefix = "xml", - namespace = "http://www.w3.org/XML/1998/namespace", - ) - val lang: String? = null, - - @XmlValue - val content: String = "" -) \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt index 67386fcce..e9d75a7b2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt @@ -18,92 +18,94 @@ import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.lalilu.lmedia.lyric.LyricItem - - -val translationGap: Dp = 10.dp -val textAlign: TextAlign = TextAlign.Start -val textSize: TextUnit = 26.sp -val translationScale: Float = 0.8f -val isTranslationShow: () -> Boolean = { true } -val isBlurredEnable: () -> Boolean = { false } +import com.lalilu.lmusic.compose.screen.playing.lyric.DEFAULT_TEXT_SHADOW +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricContext +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricSettings +import kotlin.math.abs @Composable fun LyricContentNormal( + index: Int, lyric: LyricItem.NormalLyric, modifier: Modifier = Modifier, - isCurrent: () -> Boolean, - offsetToCurrent: () -> Int, - currentTime: () -> Long, - screenConstraints: Constraints, + settings: LyricSettings, + context: LyricContext, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, - textMeasurer: TextMeasurer, - fontFamily: () -> FontFamily?, ) { val density = LocalDensity.current - val paddingVertical = remember { 15.dp } - val paddingHorizontal = remember { 40.dp } - val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } - val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } - val gapHeight = remember(translationGap) { with(density) { translationGap.toPx() } } + val direction = LocalLayoutDirection.current + val isCurrent = context.currentIndex() == index - val actualConstraints = remember { - val width = screenConstraints.maxWidth - paddingHorizontalPx * 2 + val actualConstraints = remember(context, settings) { + val paddingHorizontal = settings.containerPadding.calculateLeftPadding(direction) + + settings.containerPadding.calculateRightPadding(direction) + val paddingHorizontalPx = with(density) { paddingHorizontal.roundToPx() } + val width = context.screenConstraints.maxWidth - paddingHorizontalPx Constraints( maxWidth = width, minWidth = width, maxHeight = Int.MAX_VALUE ) } - val (textResult, translateResult) = remember(textAlign, textSize, fontFamily, lyric) { - textMeasurer.measure( + val (textResult, translateResult) = remember(settings, context, lyric) { + context.textMeasurer.measure( text = lyric.content, constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize, - textAlign = textAlign, - fontFamily = fontFamily() ?: TextStyle.Default.fontFamily - ) + style = settings.mainTextStyle ) to lyric.translation ?.takeIf(String::isNotBlank) ?.let { - textMeasurer.measure( + context.textMeasurer.measure( text = it, constraints = actualConstraints, - style = TextStyle.Default.copy( - fontSize = textSize * translationScale, - textAlign = textAlign, - fontFamily = fontFamily() ?: TextStyle.Default.fontFamily - ) + style = settings.translationTextStyle ) } } - val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } - val translateHeight = remember(translateResult) { - translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f - } - val height = remember(isTranslationShow(), textHeight, translateHeight) { - textHeight + if (isTranslationShow() && translateHeight > 0) translateHeight + gapHeight else 0f + val (heightDp, translationTopLeft, pivotOffset) = remember( + textResult, translateResult, settings + ) { + val gapHeight = with(density) { settings.gapSize.toPx() } + val textHeight = textResult.getLineBottom(textResult.lineCount - 1) + val translateHeight = translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f + + val height = + if (settings.translationVisible && translateHeight > 0) textHeight + translateHeight + gapHeight + else textHeight + val paddingVertical = settings.containerPadding.calculateTopPadding() + + settings.containerPadding.calculateBottomPadding() + + val width = context.screenConstraints.maxWidth + val x = when (settings.textAlign) { + TextAlign.End -> width.toFloat() + TextAlign.Center -> width / 2f + else -> 0f + } + val pivotOffset = Offset.Zero.copy(y = height / 2f, x = x) + + listOf( + density.run { height.toDp() + paddingVertical }, + Offset.Zero.copy(y = textHeight + gapHeight), + pivotOffset + ) } - val heightDp = remember(height) { density.run { height.toDp() + paddingVertical * 2 } } + val animateHeight = animateDpAsState( - targetValue = heightDp, + targetValue = heightDp as? Dp ?: 0.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow @@ -112,7 +114,7 @@ fun LyricContentNormal( ) val animateAlpha = animateFloatAsState( - targetValue = if (isTranslationShow()) 1f else 0f, + targetValue = if (settings.translationVisible) 1f else 0f, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow @@ -121,7 +123,7 @@ fun LyricContentNormal( ) val color = animateColorAsState( - targetValue = if (isCurrent()) Color.White else Color(0x80FFFFFF), + targetValue = if (isCurrent) Color.White else Color(0x80FFFFFF), animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow @@ -130,7 +132,9 @@ fun LyricContentNormal( ) val scale = animateFloatAsState( - targetValue = if (isCurrent()) 100f else 90f, + targetValue = if (isCurrent) settings.scaleRange.endInclusive + else settings.scaleRange.start, + visibilityThreshold = 0.001f, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow @@ -138,58 +142,38 @@ fun LyricContentNormal( label = "" ) - val textShadow = remember { - Shadow( - color = Color.Black.copy(alpha = 0.2f), - offset = Offset(x = 0f, y = 1f), - blurRadius = 1f - ) - } - val translationTopLeft = remember(textHeight) { - Offset.Zero.copy(y = textHeight + gapHeight) - } - val pivotOffset = remember(height, textAlign) { - val width = screenConstraints.maxWidth - val x = when (textAlign) { - TextAlign.End -> width.toFloat() - TextAlign.Center -> width / 2f - else -> 0f - } - Offset.Zero.copy(y = height / 2f, x = x) - } val blurRadius = remember { derivedStateOf { - if (!isBlurredEnable()) return@derivedStateOf 0.dp - offsetToCurrent().coerceAtMost(5).dp + if (context.isUserScrolling()) return@derivedStateOf 0.dp + if (!settings.blurEffectEnable) return@derivedStateOf 0.dp + abs(index - context.currentIndex()).coerceAtMost(5).dp } } val animateBlurRadius = animateDpAsState(targetValue = blurRadius.value, label = "") Canvas( modifier = modifier - .blur( - animateBlurRadius.value, - BlurredEdgeTreatment.Unbounded - ) // TODO 对性能影响较大,待进一步优化 .fillMaxWidth() .height(animateHeight.value) + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .blur(animateBlurRadius.value, BlurredEdgeTreatment.Unbounded) // TODO 对性能影响较大,待进一步优化 .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) - .padding(vertical = paddingVertical, horizontal = paddingHorizontal) + .padding(settings.containerPadding) ) { scale( - scale = scale.value / 100f, - pivot = pivotOffset + scale = scale.value, + pivot = pivotOffset as? Offset ?: Offset.Zero ) { drawText( color = color.value, - shadow = textShadow, + shadow = DEFAULT_TEXT_SHADOW, textLayoutResult = textResult ) if (translateResult == null) return@scale drawText( color = color.value, - topLeft = translationTopLeft, + topLeft = translationTopLeft as? Offset ?: Offset.Zero, textLayoutResult = translateResult, alpha = animateAlpha.value ) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt index 8faa9d570..7426b3c3f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt @@ -10,8 +10,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.BlendMode @@ -19,62 +22,51 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.font.DeviceFontFamilyName -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.lalilu.lmedia.lyric.LyricItem import com.lalilu.lmedia.lyric.findPlayingIndexForWords import com.lalilu.lmedia.lyric.getSentenceContent +import com.lalilu.lmusic.compose.screen.playing.lyric.DEFAULT_TEXT_SHADOW +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricContext +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricSettings import com.lalilu.lmusic.compose.screen.playing.lyric.utils.getPathForProgress import com.lalilu.lmusic.compose.screen.playing.lyric.utils.normalized +import kotlin.math.abs -private val DEFAULT_TEXT_SHADOW = Shadow( - color = Color.Black.copy(alpha = 0.2f), - offset = Offset(x = 0f, y = 1f), - blurRadius = 1f -) - private val DEFAULT_GRADIENT_GAP = 48.dp @Composable fun LyricContentWords( - modifier: Modifier, + index: Int, lyric: LyricItem.WordsLyric, - isCurrent: () -> Boolean, - offsetToCurrent: () -> Int, - currentTime: () -> Long, - screenConstraints: Constraints, + modifier: Modifier = Modifier, + settings: LyricSettings, + context: LyricContext, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, - textMeasurer: TextMeasurer, - fontFamily: () -> FontFamily? ) { val density = LocalDensity.current - val paddingVertical = remember { 15.dp } - val paddingHorizontal = remember { 40.dp } - val paddingVerticalPx = remember { with(density) { paddingVertical.roundToPx() } } - val paddingHorizontalPx = remember { with(density) { paddingHorizontal.roundToPx() } } - val gapHeight = remember(translationGap) { with(density) { translationGap.toPx() } } + val direction = LocalLayoutDirection.current + val isCurrent = context.currentIndex() == index val fullSentence = remember { lyric.getSentenceContent() } - val actualConstraints = remember { - val width = screenConstraints.maxWidth - paddingHorizontalPx * 2 + val actualConstraints = remember(context, settings) { + val paddingHorizontal = settings.containerPadding.calculateLeftPadding(direction) + + settings.containerPadding.calculateRightPadding(direction) + val paddingHorizontalPx = with(density) { paddingHorizontal.roundToPx() } + val width = context.screenConstraints.maxWidth - paddingHorizontalPx Constraints( maxWidth = width, minWidth = width, @@ -82,43 +74,26 @@ fun LyricContentWords( ) } - val textStyle = remember { - TextStyle.Default.copy( - fontSize = 34.sp, - textAlign = TextAlign.Start, - fontFamily = fontFamily() ?: FontFamily( - Font( - familyName = DeviceFontFamilyName("FontFamily.Monospace"), - variationSettings = FontVariation.Settings( - FontVariation.weight(900) - ) - ) - ) - ) - } - - val textResult = remember { - textMeasurer.measure( + val (textResult, translateResult) = remember(context, settings, lyric) { + context.textMeasurer.measure( text = fullSentence, constraints = actualConstraints, - style = textStyle - ) - } - - val translateResult = remember { - val text = lyric.translation.firstOrNull()?.content ?: return@remember null - textMeasurer.measure( - text = text, - constraints = actualConstraints, - style = textStyle.copy(fontSize = textStyle.fontSize * 0.7f) - ) + style = settings.mainTextStyle + ) to run { + val text = lyric.translation.firstOrNull()?.content ?: return@run null + context.textMeasurer.measure( + text = text, + constraints = actualConstraints, + style = settings.translationTextStyle + ) + } } val scale = animateFloatAsState( targetValue = when { - isCurrent() -> 100f - currentTime() in lyric.startTime..lyric.endTime -> 95f - else -> 90f + isCurrent -> settings.scaleRange.endInclusive + context.currentTime() in lyric.startTime..lyric.endTime -> 0.95f + else -> settings.scaleRange.start }, visibilityThreshold = 0.001f, animationSpec = spring( @@ -129,8 +104,8 @@ fun LyricContentWords( ) val alpha = animateFloatAsState( targetValue = when { - isCurrent() -> 1f - currentTime() in lyric.startTime..lyric.endTime -> 0.75f + isCurrent -> 1f + context.currentTime() in lyric.startTime..lyric.endTime -> 0.75f else -> 0.5f }, animationSpec = spring( @@ -141,38 +116,63 @@ fun LyricContentWords( label = "" ) - val textHeight = remember(textResult) { textResult.getLineBottom(textResult.lineCount - 1) } - val translateHeight = remember(translateResult) { - translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f - } - val pivotOffset = remember(textHeight) { Offset.Zero.copy(y = textHeight / 2f, x = 0f) } - val height = remember(isTranslationShow(), textHeight, translateHeight) { - textHeight + if (isTranslationShow() && translateHeight > 0) translateHeight + gapHeight else 0f + val (heightDp, translationTopLeft, pivotOffset) = remember( + textResult, translateResult, settings + ) { + val gapHeight = with(density) { settings.gapSize.toPx() } + val textHeight = textResult.getLineBottom(textResult.lineCount - 1) + val translateHeight = translateResult?.let { it.getLineBottom(it.lineCount - 1) } ?: 0f + + val height = + if (settings.translationVisible && translateHeight > 0) textHeight + translateHeight + gapHeight + else textHeight + val paddingVertical = settings.containerPadding.calculateTopPadding() + + settings.containerPadding.calculateBottomPadding() + + val width = context.screenConstraints.maxWidth + val x = when (settings.textAlign) { + TextAlign.End -> width.toFloat() + TextAlign.Center -> width / 2f + else -> 0f + } + val pivotOffset = Offset.Zero.copy(y = height / 2f, x = x) + + listOf( + density.run { height.toDp() + paddingVertical }, + Offset.Zero.copy(y = textHeight + gapHeight), + pivotOffset + ) } - val heightDp = remember(height) { density.run { height.toDp() + paddingVertical * 2 } } val animateHeight = animateDpAsState( - targetValue = heightDp, + targetValue = heightDp as? Dp ?: 0.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow ), label = "" ) - val translationTopLeft = remember(textHeight) { - Offset.Zero.copy(y = textHeight + gapHeight) + + val blurRadius = remember { + derivedStateOf { + if (context.isUserScrolling()) return@derivedStateOf 0.dp + if (!settings.blurEffectEnable) return@derivedStateOf 0.dp + abs(index - context.currentIndex()).coerceAtMost(5).dp + } } + val animateBlurRadius = animateDpAsState(targetValue = blurRadius.value, label = "") Canvas( modifier = modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } .fillMaxWidth() .height(animateHeight.value) .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) - .padding(vertical = paddingVertical, horizontal = paddingHorizontal) - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .blur(animateBlurRadius.value, BlurredEdgeTreatment.Unbounded) // TODO 对性能影响较大,待进一步优化 + .padding(settings.containerPadding) ) { - val now = currentTime() - val index = lyric.words.findPlayingIndexForWords(now) - val word = lyric.words.getOrNull(index) + val now = context.currentTime() + val wordIndex = lyric.words.findPlayingIndexForWords(now) + val word = lyric.words.getOrNull(wordIndex) // 获取某一词的播放进度 var progress = normalized( @@ -182,11 +182,11 @@ fun LyricContentWords( ) // 若当前句的歌词已经播放完毕,则进度固定为1 - if (lyric.words.maxOf { it.endTime } < currentTime()) { + if (lyric.words.maxOf { it.endTime } < context.currentTime()) { progress = 1f } - val offset = lyric.words.take(index) + val offset = lyric.words.take(wordIndex) .sumOf { it.content.length } val (path, rect, position) = textResult.getPathForProgress( @@ -196,8 +196,8 @@ fun LyricContentWords( ) scale( - scale = scale.value / 100f, - pivot = pivotOffset + scale = scale.value, + pivot = pivotOffset as? Offset ?: Offset.Zero, ) { drawText( color = Color(0x80FFFFFF), @@ -253,7 +253,7 @@ fun LyricContentWords( if (translateResult == null) return@scale drawText( color = Color(0x80FFFFFF), - topLeft = translationTopLeft, + topLeft = translationTopLeft as? Offset ?: Offset.Zero, shadow = DEFAULT_TEXT_SHADOW, textLayoutResult = translateResult, ) From dfdfe510ae9a2a5161aeaf7aede55e614a518492 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 15 Feb 2025 23:59:37 +0800 Subject: [PATCH 176/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=AD=8C=E8=AF=8D=E6=A0=B7=E5=BC=8F=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E7=9A=84=E6=8E=A7=E5=88=B6=E9=80=BB=E8=BE=91=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E8=AF=A5=E6=A0=B7=E5=BC=8F=E9=85=8D=E7=BD=AE=E7=9A=84?= =?UTF-8?q?=E8=AF=BB=E5=86=99=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 2 + .../main/java/com/lalilu/lmusic/AppModule.kt | 18 +++++ .../java/com/lalilu/lmusic/compose/App.kt | 3 + .../component/playing/LyricViewToolbar.kt | 72 +++++++++++++++---- .../compose/new_screen/SettingsScreen.kt | 24 ++++--- .../compose/screen/playing/PlayingLayout.kt | 2 - .../screen/playing/lyric/LyricLayout.kt | 13 ++-- .../screen/playing/lyric/LyricSettings.kt | 30 ++++++-- .../playing/lyric/LyricSettingsState.kt | 28 ++++++++ .../screen/playing/lyric/SerializableFont.kt | 54 ++++++++++++++ .../playing/lyric/impl/LyricContentNormal.kt | 17 ++--- .../playing/lyric/impl/LyricContentWords.kt | 17 ++--- .../lyric/serializable/DpSerializer.kt | 23 ++++++ .../lyric/serializable/TextAlignSerializer.kt | 32 +++++++++ .../lyric/serializable/TextUnitSerializer.kt | 50 +++++++++++++ .../screen/playing/lyric/utils/LyricUtils.kt | 71 ------------------ .../playing/lyric/utils/TextLayoutUtils.kt | 12 ++++ component/build.gradle.kts | 10 ++- .../extension/LazyListAnimateScroller.kt | 5 +- .../component/extension/SplitMutableState.kt | 72 +++++++++++++++++++ .../settings/SettingProgressSeekBar.kt | 37 +++------- 21 files changed, 438 insertions(+), 154 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/SerializableFont.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/DpSerializer.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/TextAlignSerializer.kt create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/TextUnitSerializer.kt delete mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt create mode 100644 component/src/main/java/com/lalilu/component/extension/SplitMutableState.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 92e6a8ad7..169a64824 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ android:glEsVersion="0x00030000" android:required="true" /> + + diff --git a/app/src/main/java/com/lalilu/lmusic/AppModule.kt b/app/src/main/java/com/lalilu/lmusic/AppModule.kt index 30a70f19c..c50e1ee88 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -9,6 +9,8 @@ import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.transitionFactory +import com.funny.data_saver.core.DataSaverInterface +import com.funny.data_saver.core.DataSaverPreferences import com.lalilu.R import com.lalilu.lmusic.Config.LRCSHARE_BASEURL import com.lalilu.lmusic.api.lrcshare.LrcShareApi @@ -24,6 +26,7 @@ import com.lalilu.lmusic.utils.coil.keyer.LSongCoverKeyer import com.lalilu.lmusic.utils.coil.keyer.MediaItemKeyer import com.lalilu.lmusic.utils.extension.toBitmap import com.lalilu.lmusic.viewmodel.SearchLyricViewModel +import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext @@ -39,6 +42,21 @@ import retrofit2.converter.gson.GsonConverterFactory @ComponentScan("com.lalilu.lmusic") object MainModule +@Single +fun provideDataSaverInterface( + application: Application +): DataSaverInterface { + val sp = application.getSharedPreferences("settings", Application.MODE_PRIVATE) + return DataSaverPreferences(sp) +} + +@Single +fun provideJson(): Json { + return Json { + ignoreUnknownKeys = true + } +} + @Single(createdAtStart = true) fun provideImageLoaderFactory( context: Application, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/App.kt b/app/src/main/java/com/lalilu/lmusic/compose/App.kt index a61b8c366..275a7b143 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/App.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/App.kt @@ -11,8 +11,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.jetpack.ProvideNavigatorLifecycleKMPSupport +import com.funny.data_saver.core.LocalDataSaver import com.lalilu.component.base.LocalWindowSize import com.lalilu.lmusic.LMusicTheme +import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) object App { @@ -35,6 +37,7 @@ object App { MaterialTheme { CompositionLocalProvider( LocalWindowSize provides calculateWindowSizeClass(activity = activity), + LocalDataSaver provides koinInject(), content = content ) } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt index 45fb7127e..b4552c391 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt @@ -19,20 +19,44 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.funny.data_saver.core.DataSaverMutableState import com.lalilu.R import com.lalilu.component.extension.DialogItem import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.extension.split +import com.lalilu.component.extension.transform import com.lalilu.component.settings.SettingFilePicker import com.lalilu.component.settings.SettingProgressSeekBar import com.lalilu.component.settings.SettingStateSeekBar import com.lalilu.component.settings.SettingSwitcher +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricSettings +import com.lalilu.lmusic.compose.screen.playing.lyric.SerializableFont import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.extension.SleepTimerSmallEntry import org.koin.compose.koinInject +import org.koin.core.qualifier.named private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.Transparent) { val settingsSp: SettingsSp = koinInject() + val settings: DataSaverMutableState = koinInject(named("LyricSettings")) + val lyricTypefacePath = settings.split( + getValue = { it.mainFont }, + setValue = { value.copy(mainFont = it) }, + transform = transform( + from = { SerializableFont.LoadedFont(it) }, + to = { item -> + when (item) { + is SerializableFont.DeviceFont -> item.fontName + is SerializableFont.LoadedFont -> item.fontPath + null -> "" + } + } + ) + ) Surface( modifier = Modifier @@ -42,19 +66,38 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T ) { Column(modifier = Modifier) { SettingStateSeekBar( - state = settingsSp.lyricGravity, + state = { + when (settings.value.textAlign) { + TextAlign.Start -> 0 + TextAlign.Center -> 1 + TextAlign.End -> 2 + else -> -1 + } + }, + onStateUpdate = { + settings.value = settings.value.copy( + textAlign = when (it) { + 0 -> TextAlign.Start + 1 -> TextAlign.Center + 2 -> TextAlign.End + else -> TextAlign.Start + } + ) + }, selection = stringArrayResource(id = R.array.lyric_gravity_text).toList(), - titleRes = R.string.preference_lyric_settings_text_gravity + title = stringResource(R.string.preference_lyric_settings_text_gravity) ) SettingProgressSeekBar( - state = settingsSp.lyricTextSize, + value = { settings.value.mainFontSize.value }, + onValueUpdate = { settings.value = settings.value.copy(mainFontSize = it.sp) }, title = "歌词文字大小", valueRange = 14..36 ) SettingSwitcher( title = "歌词模糊效果", subTitle = "为歌词添加一点模糊效果", - state = settingsSp.isEnableBlurEffect, + state = { settings.value.blurEffectEnable }, + onStateUpdate = { settings.value = settings.value.copy(blurEffectEnable = it) } ) SettingSwitcher( title = "歌词页展开时隐藏其他组件", @@ -62,7 +105,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T state = settingsSp.autoHideSeekbar, ) SettingFilePicker( - state = settingsSp.lyricTypefacePath, + state = lyricTypefacePath, title = "自定义字体", subTitle = "请选择TTF格式的字体文件", mimeType = "font/ttf" @@ -72,10 +115,8 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T } @Composable -fun LyricViewToolbar( - settingsSp: SettingsSp = koinInject() -) { - var isDrawTranslation by settingsSp.isDrawTranslation +fun LyricViewToolbar() { + val settings: DataSaverMutableState = koinInject(named("LyricSettings")) Row( modifier = Modifier @@ -84,8 +125,9 @@ fun LyricViewToolbar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - val iconAlpha2 = animateFloatAsState( - targetValue = if (isDrawTranslation) 1f else 0.5f, label = "" + val iconAlpha = animateFloatAsState( + targetValue = if (settings.value.translationVisible) 1f else 0.5f, + label = "" ) SleepTimerSmallEntry() @@ -98,9 +140,13 @@ fun LyricViewToolbar( ) } - IconButton(onClick = { isDrawTranslation = !isDrawTranslation }) { + IconButton(onClick = { + settings.value = settings.value.copy( + translationVisible = !settings.value.translationVisible + ) + }) { Icon( - modifier = Modifier.graphicsLayer { alpha = iconAlpha2.value }, + modifier = Modifier.graphicsLayer { alpha = iconAlpha.value }, painter = painterResource(id = R.drawable.translate_2), contentDescription = "", tint = Color.White diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt index e723184da..99d73c143 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt @@ -55,6 +55,7 @@ import com.lalilu.remixicon.system.settings4Line import com.zhangke.krouter.annotation.Destination import kotlinx.coroutines.launch import org.koin.compose.koinInject +import kotlin.math.roundToInt @Destination("/pages/settings") object SettingsScreen : Screen, ScreenInfoFactory { @@ -126,7 +127,8 @@ private fun SettingsScreen( state = ignoreAudioFocus ) SettingProgressSeekBar( - state = volumeControl, + value = { volumeControl.value.toFloat() }, + onValueUpdate = { volumeControl.value = it.roundToInt() }, title = "独立音量控制", valueRange = 0..100 ) @@ -195,11 +197,11 @@ private fun SettingsScreen( selection = stringArrayResource(id = R.array.lyric_gravity_text).toList(), titleRes = R.string.preference_lyric_settings_text_gravity ) - SettingProgressSeekBar( - state = lyricTextSize, - title = "歌词文字大小", - valueRange = 14..36 - ) +// SettingProgressSeekBar( +// state = lyricTextSize, +// title = "歌词文字大小", +// valueRange = 14..36 +// ) } } @@ -208,11 +210,11 @@ private fun SettingsScreen( iconRes = R.drawable.ic_scan_line, titleRes = R.string.preference_media_source_settings ) { - SettingProgressSeekBar( - state = durationFilter, - title = "筛除小于时长的文件", - valueRange = 0..60 - ) +// SettingProgressSeekBar( +// state = durationFilter, +// title = "筛除小于时长的文件", +// valueRange = 0..60 +// ) SettingSwitcher( state = enableUnknownFilter, titleRes = R.string.preference_media_source_settings_unknown_filter, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 0b5e7559b..e1c85d370 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -52,7 +52,6 @@ import com.lalilu.lmedia.lyric.LyricUtils import com.lalilu.lmusic.compose.component.playing.LyricViewToolbar import com.lalilu.lmusic.compose.component.playing.PlayingToolbar import com.lalilu.lmusic.compose.screen.playing.lyric.LyricLayout -import com.lalilu.lmusic.compose.screen.playing.lyric.utils.rememberFontFamilyFromPath import com.lalilu.lmusic.compose.screen.playing.seekbar.ClickPart import com.lalilu.lmusic.compose.screen.playing.seekbar.SeekbarLayout import com.lalilu.lmusic.datastore.SettingsSp @@ -268,7 +267,6 @@ fun PlayingLayout( listState = listState, currentTime = { seekbarTime.longValue }, screenConstraints = constraints, - fontFamily = rememberFontFamilyFromPath { settingsSp.lyricTypefacePath.value }, isUserClickEnable = { draggable.state.value == DragAnchor.Max }, isUserScrollEnable = { isLyricScrollEnable.value }, onPositionReset = { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt index f2437a268..49c0a1912 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt @@ -29,13 +29,13 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.funny.data_saver.core.DataSaverMutableState import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord @@ -48,6 +48,8 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.isActive +import org.koin.compose.koinInject +import org.koin.core.qualifier.named @OptIn(FlowPreview::class) @@ -56,15 +58,15 @@ fun LyricLayout( modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), currentTime: () -> Long = { 0L }, + lyricEntry: State> = remember { mutableStateOf(emptyList()) }, screenConstraints: Constraints, isUserClickEnable: () -> Boolean = { false }, isUserScrollEnable: () -> Boolean = { false }, onPositionReset: () -> Unit = {}, onItemClick: (LyricItem) -> Unit = {}, onItemLongClick: (LyricItem) -> Unit = {}, - lyricEntry: State> = remember { mutableStateOf(emptyList()) }, - fontFamily: State = remember { mutableStateOf(null) } ) { + val settings: DataSaverMutableState = koinInject(named("LyricSettings")) val textMeasurer = rememberTextMeasurer() val isUserScrolling = remember { mutableStateOf(isUserScrollEnable()) } .also { it.value = isUserScrollEnable() } @@ -77,7 +79,7 @@ fun LyricLayout( val currentItemIndex = remember { derivedStateOf { - val time = currentTime() + val time = currentTime() + settings.value.timeOffset val lyricEntryList = lyricEntry.value lyricEntryList.findPlayingIndex(time) @@ -119,10 +121,9 @@ fun LyricLayout( } } - val settings = remember { mutableStateOf(LyricSettings()) } val context = remember { LyricContext( - currentTime = currentTime, + currentTime = { currentTime() + settings.value.timeOffset }, currentIndex = { currentItemIndex.value }, isUserScrolling = { isUserScrolling.value }, screenConstraints = screenConstraints, diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt index 37f3fb389..ecc76df9d 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt @@ -1,3 +1,9 @@ +@file:UseSerializers( + TextAlignSerializer::class, + TextUnitSerializer::class, + DpSerializer::class, +) + package com.lalilu.lmusic.compose.screen.playing.lyric import androidx.compose.foundation.layout.PaddingValues @@ -15,6 +21,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.lalilu.lmusic.compose.screen.playing.lyric.serializable.DpSerializer +import com.lalilu.lmusic.compose.screen.playing.lyric.serializable.TextAlignSerializer +import com.lalilu.lmusic.compose.screen.playing.lyric.serializable.TextUnitSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + internal val DEFAULT_TEXT_SHADOW = Shadow( color = Color.Black.copy(alpha = 0.2f), @@ -22,22 +34,24 @@ internal val DEFAULT_TEXT_SHADOW = Shadow( blurRadius = 1f ) +@Serializable data class LyricSettings( // 布局样式配置 val textAlign: TextAlign = TextAlign.Start, val containerPadding: PaddingValues = PaddingValues(horizontal = 40.dp, vertical = 15.dp), val gapSize: Dp = 10.dp, val scaleRange: ClosedRange = 0.85f..1f, + val timeOffset: Long = 50L, // 字体样式配置 val mainFontSize: TextUnit = 26.sp, val mainLineHeight: TextUnit = 28.sp, val mainFontWeight: Int = FontWeight.Black.weight, - val mainFontFamily: FontFamily? = null, + val mainFont: SerializableFont? = null, val translationFontSize: TextUnit = 22.sp, val translationLineHeight: TextUnit = 26.sp, val translationFontWeight: Int = FontWeight.Bold.weight, - val translationFontFamily: FontFamily? = null, + val translationFont: SerializableFont? = null, // 特殊效果开关 val blurEffectEnable: Boolean = true, @@ -50,8 +64,10 @@ data class LyricSettings( textAlign = textAlign, lineHeight = mainLineHeight, fontWeight = FontWeight(mainFontWeight), - fontFamily = mainFontFamily ?: FontFamily( - Font( + fontFamily = FontFamily( + mainFont?.toFont( + variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) + ) ?: Font( familyName = DeviceFontFamilyName("FontFamily.Monospace"), variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) ) @@ -65,8 +81,10 @@ data class LyricSettings( textAlign = textAlign, lineHeight = translationLineHeight, fontWeight = FontWeight(translationFontWeight), - fontFamily = translationFontFamily ?: FontFamily( - Font( + fontFamily = FontFamily( + translationFont?.toFont( + variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) + ) ?: Font( familyName = DeviceFontFamilyName("FontFamily.Monospace"), variationSettings = FontVariation.Settings( FontVariation.weight(translationFontWeight) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt new file mode 100644 index 000000000..64df2d723 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt @@ -0,0 +1,28 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric + +import com.funny.data_saver.core.DataSaverConverter +import com.funny.data_saver.core.DataSaverInterface +import com.funny.data_saver.core.DataSaverMutableState +import com.funny.data_saver.core.mutableDataSaverStateOf +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single + +@Named("LyricSettings") +@Single(createdAtStart = true) +fun provideLyricSettingsState( + dataSaverInterface: DataSaverInterface, + json: Json +): DataSaverMutableState { + DataSaverConverter.registerTypeConverters( + save = { json.encodeToString(it) }, + restore = { json.decodeFromString(it) } + ) + + return mutableDataSaverStateOf( + dataSaverInterface = dataSaverInterface, + key = "LyricSettings", + initialValue = LyricSettings() + ) +} diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/SerializableFont.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/SerializableFont.kt new file mode 100644 index 000000000..d0fd1cc74 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/SerializableFont.kt @@ -0,0 +1,54 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric + +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.font.FontWeight +import kotlinx.serialization.Serializable +import java.io.File + +@Serializable +sealed interface SerializableFont { + fun toFont( + weight: FontWeight = FontWeight.Normal, + style: FontStyle = FontStyle.Normal, + variationSettings: FontVariation.Settings = FontVariation.Settings() + ): Font? + + data class LoadedFont(val fontPath: String) : SerializableFont { + override fun toFont( + weight: FontWeight, + style: FontStyle, + variationSettings: FontVariation.Settings + ): Font? { + return if (fontPath.isBlank()) null + else runCatching { + Font( + file = File(fontPath), + weight = weight, + style = style, + variationSettings = variationSettings + ) + }.getOrNull() + } + } + + data class DeviceFont(val fontName: String) : SerializableFont { + override fun toFont( + weight: FontWeight, + style: FontStyle, + variationSettings: FontVariation.Settings + ): Font? { + return if (fontName.isBlank()) null + else runCatching { + Font( + familyName = DeviceFontFamilyName(fontName), + weight = weight, + style = style, + variationSettings = variationSettings + ) + }.getOrNull() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt index e9d75a7b2..0b5277746 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment @@ -142,14 +141,16 @@ fun LyricContentNormal( label = "" ) - val blurRadius = remember { - derivedStateOf { - if (context.isUserScrolling()) return@derivedStateOf 0.dp - if (!settings.blurEffectEnable) return@derivedStateOf 0.dp - abs(index - context.currentIndex()).coerceAtMost(5).dp - } + val blurRadius = remember( + context.isUserScrolling(), + context.currentIndex(), + settings.blurEffectEnable + ) { + if (context.isUserScrolling()) return@remember 0.dp + if (!settings.blurEffectEnable) return@remember 0.dp + abs(index - context.currentIndex()).coerceAtMost(5).dp } - val animateBlurRadius = animateDpAsState(targetValue = blurRadius.value, label = "") + val animateBlurRadius = animateDpAsState(targetValue = blurRadius, label = "") Canvas( modifier = modifier diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt index 7426b3c3f..74ae645ab 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment @@ -152,14 +151,16 @@ fun LyricContentWords( label = "" ) - val blurRadius = remember { - derivedStateOf { - if (context.isUserScrolling()) return@derivedStateOf 0.dp - if (!settings.blurEffectEnable) return@derivedStateOf 0.dp - abs(index - context.currentIndex()).coerceAtMost(5).dp - } + val blurRadius = remember( + context.isUserScrolling(), + context.currentIndex(), + settings.blurEffectEnable + ) { + if (context.isUserScrolling()) return@remember 0.dp + if (!settings.blurEffectEnable) return@remember 0.dp + abs(index - context.currentIndex()).coerceAtMost(5).dp } - val animateBlurRadius = animateDpAsState(targetValue = blurRadius.value, label = "") + val animateBlurRadius = animateDpAsState(targetValue = blurRadius, label = "") Canvas( modifier = modifier diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/DpSerializer.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/DpSerializer.kt new file mode 100644 index 000000000..9febcbbbf --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/DpSerializer.kt @@ -0,0 +1,23 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric.serializable + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +class DpSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Dp", PrimitiveKind.FLOAT) + + override fun deserialize(decoder: Decoder): Dp { + return decoder.decodeFloat().dp + } + + override fun serialize(encoder: Encoder, value: Dp) { + encoder.encodeFloat(value.value) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/TextAlignSerializer.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/TextAlignSerializer.kt new file mode 100644 index 000000000..0609a30d0 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/TextAlignSerializer.kt @@ -0,0 +1,32 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric.serializable + +import androidx.compose.ui.text.style.TextAlign +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +class TextAlignSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TextAlign", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): TextAlign { + val string = decoder.decodeString() + return when (string) { + "Left" -> TextAlign.Left + "Right" -> TextAlign.Right + "Center" -> TextAlign.Center + "Justify" -> TextAlign.Justify + "Start" -> TextAlign.Start + "End" -> TextAlign.End + "Unspecified" -> TextAlign.Unspecified + else -> TextAlign.Unspecified + } + } + + override fun serialize(encoder: Encoder, value: TextAlign) { + encoder.encodeString(value.toString()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/TextUnitSerializer.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/TextUnitSerializer.kt new file mode 100644 index 000000000..07c049900 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/TextUnitSerializer.kt @@ -0,0 +1,50 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric.serializable + +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure + +class TextUnitSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextUnit") { + element("value") + element("type") + } + + override fun deserialize(decoder: Decoder): TextUnit { + return decoder.decodeStructure(descriptor) { + var value = 0f + var type = "" + + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> value = decodeFloatElement(descriptor, 0) + 1 -> type = decodeStringElement(descriptor, 1) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + + when (type.lowercase()) { + "sp" -> value.sp + "em" -> value.em + else -> 0.sp + } + } + } + + override fun serialize(encoder: Encoder, value: TextUnit) { + encoder.encodeStructure(descriptor) { + encodeFloatElement(descriptor, 0, value.value) + encodeStringElement(descriptor, 1, value.type.toString()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt deleted file mode 100644 index 59ea4f5e6..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/LyricUtils.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.lalilu.lmusic.compose.screen.playing.lyric.utils - -import android.graphics.Typeface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.sp -import java.io.File - -/** - * 读取字体文件,并将其转换成Compose可用的FontFamily - * - * @param path 字体所在路径 - * @return 字体文件对应的FontFamily - */ -@Composable -fun rememberFontFamilyFromPath(path: () -> String?): State { - val fontFamily = remember { mutableStateOf(null) } - - LaunchedEffect(path()) { - val fontFile = path()?.takeIf { it.isNotBlank() } - ?.let { File(it) } - ?.takeIf { it.exists() && it.canRead() } - ?: return@LaunchedEffect - - fontFamily.value = runCatching { FontFamily(Typeface.createFromFile(fontFile)) } - .getOrNull() - } - - return fontFamily -} - -/** - * 将存储的Gravity的Int值转换成Compose可用的TextAlign - */ -@Composable -fun rememberTextAlignFromGravity(gravity: () -> Int?): TextAlign { - return remember(gravity()) { - when (gravity()) { - 0 -> TextAlign.Start - 1 -> TextAlign.Center - 2 -> TextAlign.End - else -> TextAlign.Start - } - } -} - -/** - * 将存储的Int值转换成Compose可用的TextUnit - */ -@Composable -fun rememberTextSizeFromInt(textSize: () -> Int?): TextUnit { - return remember(textSize()) { textSize()?.takeIf { it > 0 }?.sp ?: 26.sp } -} - -fun normalized(start: Long, end: Long, current: Long): Float { - if (start >= end) return 0f - val result = (current - start).toFloat() / (end - start).toFloat() - return result.coerceIn(0f, 1f) -} - -fun normalized(start: Float, end: Float, current: Float): Float { - if (start >= end) return 0f - val result = (current - start) / (end - start) - return result.coerceIn(0f, 1f) -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt index 062cd9b8a..547bc5695 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt @@ -110,4 +110,16 @@ fun TextLayoutResult.getPathForProgress( rect = rect ?: Rect.Zero, position = position ) +} + +fun normalized(start: Long, end: Long, current: Long): Float { + if (start >= end) return 0f + val result = (current - start).toFloat() / (end - start).toFloat() + return result.coerceIn(0f, 1f) +} + +fun normalized(start: Float, end: Float, current: Float): Float { + if (start >= end) return 0f + val result = (current - start) / (end - start) + return result.coerceIn(0f, 1f) } \ No newline at end of file diff --git a/component/build.gradle.kts b/component/build.gradle.kts index bacda8fa6..de5e1023d 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag + plugins { id("com.android.library") kotlin("android") @@ -31,7 +33,11 @@ android { } composeCompiler { - enableStrongSkippingMode = true + featureFlags.set( + listOf( + ComposeFeatureFlag.StrongSkipping + ) + ) } dependencies { @@ -66,6 +72,8 @@ dependencies { api("dev.chrisbanes.haze:haze:1.2.2") api("dev.chrisbanes.haze:haze-materials:1.2.2") + api("io.github.FunnySaltyFish:data-saver-core:1.2.2") + // https://mvnrepository.com/artifact/org.jetbrains.androidx.navigation/navigation-compose api("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10") api("androidx.compose.material3:material3-adaptive-navigation-suite") diff --git a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt index fc1f6491a..8c8025403 100644 --- a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt +++ b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt @@ -224,7 +224,10 @@ class LazyListAnimateScroller internal constructor( fun rememberLazyListAnimateScroller( listState: LazyListState, keys: () -> Collection = { emptyList() }, - defaultAnimationSpec: AnimationSpec = spring(stiffness = Spring.StiffnessVeryLow), + defaultAnimationSpec: AnimationSpec = spring( + stiffness = Spring.StiffnessVeryLow, + visibilityThreshold = 0.001f + ), enableScrollAnimation: () -> Boolean = { true }, ): LazyListAnimateScroller { val enableAnimation = rememberUpdatedState(enableScrollAnimation()) diff --git a/component/src/main/java/com/lalilu/component/extension/SplitMutableState.kt b/component/src/main/java/com/lalilu/component/extension/SplitMutableState.kt new file mode 100644 index 000000000..05746315a --- /dev/null +++ b/component/src/main/java/com/lalilu/component/extension/SplitMutableState.kt @@ -0,0 +1,72 @@ +package com.lalilu.component.extension + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlin.reflect.KProperty + +private class SettingListenState( + private val defaultValue: T, + private val onSetValue: (T) -> Unit = {}, + private val instance: MutableState = mutableStateOf(defaultValue), +) : MutableState { + override var value: T + get() = instance.value + set(value) { + if (instance.value != value) { + instance.value = value + onSetValue(value) + } + } + + override fun component1(): T = this.value + override fun component2(): (T) -> Unit = { this.value = it } + operator fun setValue(thisObj: Any?, property: KProperty<*>, v: T) = run { value = v } + operator fun getValue(thisObj: Any?, property: KProperty<*>): T = this.value +} + +interface Transform { + fun to(value: T): K + fun from(item: K): T +} + +@Composable +fun MutableState.split( + getValue: (T) -> K, + setValue: MutableState.(K) -> T, + transform: Transform +): MutableState { + return remember { + SettingListenState( + defaultValue = transform.to(getValue(this.value)), + onSetValue = { this.value = setValue(transform.from(it)) } + ) + }.also { it.value = transform.to(getValue(this.value)) } +} + +@Composable +fun MutableState.split( + getValue: (T) -> K, + onSetValue: MutableState.(K) -> T, +): MutableState { + return remember { + SettingListenState( + defaultValue = getValue(this.value), + onSetValue = { this.value = onSetValue(it) } + ) + }.also { it.value = getValue(this.value) } +} + +@Composable +fun transform( + to: (value: T) -> K, + from: (item: K) -> T +): Transform { + return remember { + object : Transform { + override fun to(value: T): K = to(value) + override fun from(item: K): T = from(item) + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt b/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt index 5a925065b..228a8a91f 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt @@ -1,6 +1,5 @@ package com.lalilu.component.settings -import androidx.annotation.StringRes import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -12,41 +11,24 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.component.base.ProgressSeekBar -import kotlin.math.roundToInt -@Composable -fun SettingProgressSeekBar( - state: MutableState, - selection: List, - @StringRes titleRes: Int, - @StringRes subTitleRes: Int? = null -) = SettingStateSeekBar( - state = state, - selection = selection, - title = stringResource(id = titleRes), - subTitle = subTitleRes?.let { stringResource(id = it) } -) @Composable fun SettingProgressSeekBar( - state: MutableState, + value: () -> Float, + onValueUpdate: (Float) -> Unit = {}, title: String, subTitle: String? = null, valueRange: IntRange ) { - var value by state - val tempValue = remember(value) { mutableStateOf(value.toFloat()) } + val tempValue = remember { mutableFloatStateOf(value()) } val interactionSource = remember { MutableInteractionSource() } val textColor = contentColorFor(backgroundColor = MaterialTheme.colors.background) @@ -68,13 +50,14 @@ fun SettingProgressSeekBar( fontSize = 14.sp ) ProgressSeekBar( - value = tempValue.value, - onValueChange = { tempValue.value = it }, + value = tempValue.floatValue, + onValueChange = { + tempValue.floatValue = it + onValueUpdate(it) + }, valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(), steps = valueRange.last - valueRange.first - 1, - onValueChangeFinished = { - value = tempValue.value.roundToInt() - } + onValueChangeFinished = { onValueUpdate(tempValue.floatValue) } ) if (subTitle != null) { Text( From f0695d1eb8a5e7db680a9b77ce776fe7bab14bd8 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 16 Feb 2025 01:17:42 +0800 Subject: [PATCH 177/213] =?UTF-8?q?[modify]=E8=A7=A3=E5=86=B3=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E7=BF=BB=E8=AF=91=E5=90=8E=EF=BC=8C=E4=BB=8D=E7=84=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E9=83=A8=E5=88=86=E7=BF=BB=E8=AF=91=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/lyric/impl/LyricContentWords.kt | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt index 74ae645ab..bcfcfb695 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt @@ -101,19 +101,28 @@ fun LyricContentWords( ), label = "" ) - val alpha = animateFloatAsState( - targetValue = when { - isCurrent -> 1f - context.currentTime() in lyric.startTime..lyric.endTime -> 0.75f - else -> 0.5f - }, + + val animateAlpha = animateFloatAsState( + targetValue = if (settings.translationVisible) 1f else 0f, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow + stiffness = Spring.StiffnessMediumLow ), - visibilityThreshold = 0.001f, label = "" ) +// val alpha = animateFloatAsState( +// targetValue = when { +// isCurrent -> 1f +// context.currentTime() in lyric.startTime..lyric.endTime -> 0.75f +// else -> 0.5f +// }, +// animationSpec = spring( +// dampingRatio = Spring.DampingRatioNoBouncy, +// stiffness = Spring.StiffnessLow +// ), +// visibilityThreshold = 0.001f, +// label = "" +// ) val (heightDp, translationTopLeft, pivotOffset) = remember( textResult, translateResult, settings @@ -255,8 +264,9 @@ fun LyricContentWords( drawText( color = Color(0x80FFFFFF), topLeft = translationTopLeft as? Offset ?: Offset.Zero, - shadow = DEFAULT_TEXT_SHADOW, +// shadow = DEFAULT_TEXT_SHADOW, textLayoutResult = translateResult, + alpha = animateAlpha.value ) } } From 4c2fea2accd5ee71e28a9cd8bbaef651f2f0eac7 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 16 Feb 2025 01:18:14 +0800 Subject: [PATCH 178/213] =?UTF-8?q?[modify]=E6=B7=BB=E5=8A=A0=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E8=87=AA=E5=AE=9A=E4=B9=89=E5=8F=82=E6=95=B0=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/playing/LyricViewToolbar.kt | 87 ++++++++++++++++++- .../screen/playing/lyric/LyricSettings.kt | 63 +++++++------- .../serializable/PaddingValueSerializer.kt | 65 ++++++++++++++ 3 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/PaddingValueSerializer.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt index b4552c391..8e7b12049 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt @@ -3,12 +3,16 @@ package com.lalilu.lmusic.compose.component.playing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Surface @@ -21,6 +25,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.funny.data_saver.core.DataSaverMutableState @@ -39,6 +44,8 @@ import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.extension.SleepTimerSmallEntry import org.koin.compose.koinInject import org.koin.core.qualifier.named +import kotlin.math.roundToInt +import kotlin.math.roundToLong private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.Transparent) { val settingsSp: SettingsSp = koinInject() @@ -64,7 +71,11 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T .navigationBarsPadding(), shape = RoundedCornerShape(15.dp) ) { - Column(modifier = Modifier) { + Column( + modifier = Modifier + .fillMaxHeight(0.4f) + .verticalScroll(state = rememberScrollState()) + ) { SettingStateSeekBar( state = { when (settings.value.textAlign) { @@ -93,6 +104,80 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T title = "歌词文字大小", valueRange = 14..36 ) + SettingProgressSeekBar( + value = { settings.value.mainLineHeight.value }, + onValueUpdate = { settings.value = settings.value.copy(mainLineHeight = it.sp) }, + title = "歌词行高大小", + valueRange = 14..48 + ) + SettingProgressSeekBar( + value = { settings.value.mainFontWeight.toFloat() }, + onValueUpdate = { + settings.value = settings.value.copy(mainFontWeight = it.roundToInt()) + }, + title = "歌词字重", + valueRange = 50..900 + ) + SettingProgressSeekBar( + value = { settings.value.translationFontSize.value }, + onValueUpdate = { + settings.value = settings.value.copy(translationFontSize = it.sp) + }, + title = "翻译文字大小", + valueRange = 14..36 + ) + SettingProgressSeekBar( + value = { settings.value.translationLineHeight.value }, + onValueUpdate = { + settings.value = settings.value.copy(translationLineHeight = it.sp) + }, + title = "翻译行高大小", + valueRange = 14..48 + ) + SettingProgressSeekBar( + value = { settings.value.translationFontWeight.toFloat() }, + onValueUpdate = { + settings.value = settings.value.copy(translationFontWeight = it.roundToInt()) + }, + title = "翻译字重", + valueRange = 50..900 + ) + SettingProgressSeekBar( + value = { settings.value.timeOffset.toFloat() }, + onValueUpdate = { + settings.value = settings.value.copy(timeOffset = it.roundToLong()) + }, + title = "歌词偏移时间(ms)", + valueRange = 0..500 + ) + SettingProgressSeekBar( + value = { settings.value.gapSize.value }, + onValueUpdate = { + settings.value = settings.value.copy(gapSize = it.dp) + }, + title = "歌词翻译间距", + valueRange = 0..50 + ) + + SettingProgressSeekBar( + value = { + settings.value.containerPadding.run { + (calculateLeftPadding(LayoutDirection.Ltr) + + calculateRightPadding(LayoutDirection.Ltr)) / 2 + }.value + }, + onValueUpdate = { + settings.value = settings.value.copy( + containerPadding = PaddingValues( + horizontal = it.dp, + vertical = (settings.value.containerPadding.calculateTopPadding() + + settings.value.containerPadding.calculateBottomPadding()) / 2 + ) + ) + }, + title = "横向边距", + valueRange = 0..50 + ) SettingSwitcher( title = "歌词模糊效果", subTitle = "为歌词添加一点模糊效果", diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt index ecc76df9d..cc2b54d98 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt @@ -2,6 +2,7 @@ TextAlignSerializer::class, TextUnitSerializer::class, DpSerializer::class, + PaddingValueSerializer::class ) package com.lalilu.lmusic.compose.screen.playing.lyric @@ -22,9 +23,11 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.lmusic.compose.screen.playing.lyric.serializable.DpSerializer +import com.lalilu.lmusic.compose.screen.playing.lyric.serializable.PaddingValueSerializer import com.lalilu.lmusic.compose.screen.playing.lyric.serializable.TextAlignSerializer import com.lalilu.lmusic.compose.screen.playing.lyric.serializable.TextUnitSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers @@ -58,39 +61,41 @@ data class LyricSettings( val translationVisible: Boolean = true, val variableFontWeightEnable: Boolean = false ) { - val mainTextStyle: TextStyle by lazy { - TextStyle.Default.copy( - fontSize = mainFontSize, - textAlign = textAlign, - lineHeight = mainLineHeight, - fontWeight = FontWeight(mainFontWeight), - fontFamily = FontFamily( - mainFont?.toFont( - variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) - ) ?: Font( - familyName = DeviceFontFamilyName("FontFamily.Monospace"), - variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) - ) + @Transient + val mainTextStyle: TextStyle = TextStyle.Default.copy( + fontSize = mainFontSize, + textAlign = textAlign, + lineHeight = mainLineHeight, + fontWeight = FontWeight(mainFontWeight), + fontFamily = FontFamily( + mainFont?.toFont( + weight = FontWeight(mainFontWeight), + variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) + ) ?: Font( + familyName = DeviceFontFamilyName("FontFamily.Monospace"), + weight = FontWeight(mainFontWeight), + variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) ) ) - } + ) - val translationTextStyle: TextStyle by lazy { - TextStyle.Default.copy( - fontSize = translationFontSize, - textAlign = textAlign, - lineHeight = translationLineHeight, - fontWeight = FontWeight(translationFontWeight), - fontFamily = FontFamily( - translationFont?.toFont( - variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) - ) ?: Font( - familyName = DeviceFontFamilyName("FontFamily.Monospace"), - variationSettings = FontVariation.Settings( - FontVariation.weight(translationFontWeight) - ) + @Transient + val translationTextStyle: TextStyle = TextStyle.Default.copy( + fontSize = translationFontSize, + textAlign = textAlign, + lineHeight = translationLineHeight, + fontWeight = FontWeight(translationFontWeight), + fontFamily = FontFamily( + translationFont?.toFont( + weight = FontWeight(mainFontWeight), + variationSettings = FontVariation.Settings(FontVariation.weight(mainFontWeight)) + ) ?: Font( + familyName = DeviceFontFamilyName("FontFamily.Monospace"), + weight = FontWeight(mainFontWeight), + variationSettings = FontVariation.Settings( + FontVariation.weight(translationFontWeight) ) ) ) - } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/PaddingValueSerializer.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/PaddingValueSerializer.kt new file mode 100644 index 000000000..be4c3bac5 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/serializable/PaddingValueSerializer.kt @@ -0,0 +1,65 @@ +package com.lalilu.lmusic.compose.screen.playing.lyric.serializable + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure + +class PaddingValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PaddingValues") { + element("left") + element("right") + element("top") + element("bottom") + } + + override fun deserialize(decoder: Decoder): PaddingValues { + return decoder.decodeStructure(descriptor) { + var left = 0f + var right = 0f + var top = 0f + var bottom = 0f + + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> left = decodeFloatElement(descriptor, 0) + 1 -> right = decodeFloatElement(descriptor, 1) + 2 -> top = decodeFloatElement(descriptor, 2) + 3 -> bottom = decodeFloatElement(descriptor, 3) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + + PaddingValues( + start = left.dp, + end = right.dp, + top = top.dp, + bottom = bottom.dp + ) + } + } + + override fun serialize(encoder: Encoder, value: PaddingValues) { + encoder.encodeStructure(descriptor) { + encodeFloatElement( + descriptor, 0, + value.calculateLeftPadding(LayoutDirection.Ltr).value + ) + encodeFloatElement( + descriptor, 1, + value.calculateRightPadding(LayoutDirection.Ltr).value + ) + encodeFloatElement(descriptor, 2, value.calculateTopPadding().value) + encodeFloatElement(descriptor, 3, value.calculateBottomPadding().value) + } + } +} \ No newline at end of file From fc7d42dc2bd2e2df77618ef1571660f1d2e0fd65 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 16 Feb 2025 14:51:27 +0800 Subject: [PATCH 179/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96Seekbar?= =?UTF-8?q?=E7=9A=84=E7=BB=98=E5=88=B6=E6=B5=81=E7=A8=8B=EF=BC=8C=E8=A7=A3?= =?UTF-8?q?=E5=86=B3Seekbar=E6=96=87=E5=AD=97=E8=BF=87=E5=BA=A6=E6=B5=8B?= =?UTF-8?q?=E9=87=8F=E5=AF=BC=E8=87=B4=E5=8D=A1=E9=A1=BF=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/playing/seekbar/SeekbarLayout.kt | 90 +++++++++++++------ 1 file changed, 61 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt index 89b2e27c6..f32edf82a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -33,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,6 +44,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.graphicsLayer @@ -53,8 +56,12 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +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.BaselineShift @@ -71,6 +78,9 @@ import com.lalilu.remixicon.Media import com.lalilu.remixicon.media.orderPlayFill import com.lalilu.remixicon.media.repeatOneFill import com.lalilu.remixicon.media.shuffleFill +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlin.math.absoluteValue @@ -160,6 +170,11 @@ fun SeekbarLayout( visibilityThreshold = 0.005f, label = "" ) + LaunchedEffect(Unit) { + snapshotFlow { animateValue.value } + .onEach { onValueChange(it) } + .launchIn(this) + } val touchingProgress = animateFloatAsState( targetValue = if (isTouching && !isCanceled) 1f else 0f, animationSpec = spring(stiffness = Spring.StiffnessLow), @@ -197,7 +212,8 @@ fun SeekbarLayout( fontWeight = FontWeight.Bold, baselineShift = BaselineShift.None, textAlign = TextAlign.End, - color = Color.White + color = Color.White, + fontFamily = FontFamily(Font(familyName = DeviceFontFamilyName("FontFamily.Monospace"))) ) } val textSize = remember { @@ -214,6 +230,29 @@ fun SeekbarLayout( } } + val currentTextResult = remember { mutableStateOf(null) } + val maxTextResult = remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + snapshotFlow { animateValue.value.toLong() } + .distinctUntilChangedBy { it / 1000L } + .onEach { + val text = it.durationToTime() + val result = textMeasurer.measure(text = text, style = textStyle) + currentTextResult.value = result + } + .launchIn(this) + + snapshotFlow { maxValue().toLong() } + .distinctUntilChangedBy { it / 1000L } + .onEach { + val text = it.durationToTime() + val result = textMeasurer.measure(text = text, style = textStyle) + maxTextResult.value = result + } + .launchIn(this) + } + val draggableState = rememberDraggable2DState { offset -> val oldState = seekbarState.value @@ -357,6 +396,7 @@ fun SeekbarLayout( } ) .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen translationY = -yTranslationAnimateValue.value * (scrollThreadHold / 2f) scaleX = 1f - (yTranslationAnimateValue.value * 0.1f) scaleY = scaleX @@ -369,17 +409,6 @@ fun SeekbarLayout( .clip(RoundedCornerShape(16.dp)) ) { val innerPath = Path() - val currentValueText = animateValue.value - .toLong() - .durationToTime() - val maxValueText = maxValue() - .toLong() - .durationToTime() - - val currentTextResult = textMeasurer - .measure(text = currentValueText, style = textStyle) - val maxTextResult = textMeasurer - .measure(text = maxValueText, style = textStyle) val maxPadding = 4.dp.toPx() val paddingValue = maxPadding * touchingProgress.value @@ -401,7 +430,6 @@ fun SeekbarLayout( val actualValue = animateValue.value val actualProgress = actualValue.normalize(minValue(), maxValue()) - onValueChange(actualValue) val thumbWidth = lerp( start = innerWidth * actualProgress, // 根据进度计算的宽度 @@ -437,15 +465,17 @@ fun SeekbarLayout( drawRect(color = Color(100, 100, 100, 50)) // 绘制总时长文本(固定右侧) - drawText( - textLayoutResult = maxTextResult, - color = Color.White, - alpha = 1f - switchingProgress.value, - topLeft = Offset( - x = size.width - textSize.value.first - 16.dp.toPx(), - y = (size.height - textSize.value.second) / 2f + maxTextResult.value?.let { + drawText( + textLayoutResult = it, + color = Color.White, + alpha = 1f - switchingProgress.value, + topLeft = Offset( + x = size.width - textSize.value.first - 16.dp.toPx(), + y = (size.height - textSize.value.second) / 2f + ) ) - ) + } // 绘制滑块 drawRoundRect( @@ -456,15 +486,17 @@ fun SeekbarLayout( ) // 绘制实时进度文本(移动) - drawText( - textLayoutResult = currentTextResult, - color = Color.White, - alpha = 1f - switchingProgress.value, - topLeft = Offset( - x = textX.toFloat(), - y = (size.height - textSize.value.second) / 2f + currentTextResult.value?.let { + drawText( + textLayoutResult = it, + color = Color.White, + alpha = 1f - switchingProgress.value, + topLeft = Offset( + x = textX.toFloat(), + y = (size.height - textSize.value.second) / 2f + ) ) - ) + } // 绘制把手元素 // drawRoundRect( From 74c30a3e98e96a00d0ed18a21ef12b97c908be19 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 16 Feb 2025 15:24:34 +0800 Subject: [PATCH 180/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E7=9A=84Blur=E5=AE=9E=E7=8E=B0=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8DblurRadius=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E9=87=8D=E7=BB=84=EF=BC=8C=E5=87=8F=E5=B0=91?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF=E7=8E=B0=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/lyric/impl/LyricContentNormal.kt | 10 ++++++---- .../playing/lyric/impl/LyricContentWords.kt | 10 ++++++---- .../playing/lyric/utils/TextLayoutUtils.kt | 20 +++++++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt index 0b5277746..8092b3a75 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt @@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy @@ -31,6 +29,7 @@ import com.lalilu.lmedia.lyric.LyricItem import com.lalilu.lmusic.compose.screen.playing.lyric.DEFAULT_TEXT_SHADOW import com.lalilu.lmusic.compose.screen.playing.lyric.LyricContext import com.lalilu.lmusic.compose.screen.playing.lyric.LyricSettings +import com.lalilu.lmusic.compose.screen.playing.lyric.utils.blur import kotlin.math.abs @@ -150,14 +149,17 @@ fun LyricContentNormal( if (!settings.blurEffectEnable) return@remember 0.dp abs(index - context.currentIndex()).coerceAtMost(5).dp } - val animateBlurRadius = animateDpAsState(targetValue = blurRadius, label = "") + val animateBlurRadius = animateDpAsState( + targetValue = blurRadius, + label = "" + ) Canvas( modifier = modifier .fillMaxWidth() .height(animateHeight.value) .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .blur(animateBlurRadius.value, BlurredEdgeTreatment.Unbounded) // TODO 对性能影响较大,待进一步优化 + .blur { animateBlurRadius.value } .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) .padding(settings.containerPadding) ) { diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt index bcfcfb695..e37890da7 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt @@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.BlendMode @@ -39,6 +37,7 @@ import com.lalilu.lmedia.lyric.getSentenceContent import com.lalilu.lmusic.compose.screen.playing.lyric.DEFAULT_TEXT_SHADOW import com.lalilu.lmusic.compose.screen.playing.lyric.LyricContext import com.lalilu.lmusic.compose.screen.playing.lyric.LyricSettings +import com.lalilu.lmusic.compose.screen.playing.lyric.utils.blur import com.lalilu.lmusic.compose.screen.playing.lyric.utils.getPathForProgress import com.lalilu.lmusic.compose.screen.playing.lyric.utils.normalized import kotlin.math.abs @@ -169,7 +168,10 @@ fun LyricContentWords( if (!settings.blurEffectEnable) return@remember 0.dp abs(index - context.currentIndex()).coerceAtMost(5).dp } - val animateBlurRadius = animateDpAsState(targetValue = blurRadius, label = "") + val animateBlurRadius = animateDpAsState( + targetValue = blurRadius, + label = "" + ) Canvas( modifier = modifier @@ -177,7 +179,7 @@ fun LyricContentWords( .fillMaxWidth() .height(animateHeight.value) .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) - .blur(animateBlurRadius.value, BlurredEdgeTreatment.Unbounded) // TODO 对性能影响较大,待进一步优化 + .blur { animateBlurRadius.value } .padding(settings.containerPadding) ) { val now = context.currentTime() diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt index 547bc5695..2437bb302 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt @@ -2,9 +2,14 @@ package com.lalilu.lmusic.compose.screen.playing.lyric.utils import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Dp import kotlin.math.abs /** @@ -122,4 +127,19 @@ fun normalized(start: Float, end: Float, current: Float): Float { if (start >= end) return 0f val result = (current - start) / (end - start) return result.coerceIn(0f, 1f) +} + +private val blurEffectMap = mutableMapOf() +internal fun Modifier.blur(radius: () -> Dp) = graphicsLayer { + val px = radius().roundToPx() + this.renderEffect = + if (px > 0f) blurEffectMap.getOrPut(px) { + BlurEffect( + px.toFloat(), + px.toFloat(), + TileMode.Decal + ) + } + else null + this.clip = false } \ No newline at end of file From c487b1d150896d66c1a3c92e844922f1df715eff Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 17 Feb 2025 00:12:29 +0800 Subject: [PATCH 181/213] =?UTF-8?q?[modify]=E8=B0=83=E6=95=B4=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E9=85=8D=E7=BD=AE=E5=AD=98=E5=82=A8=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E6=AF=8F=E6=AC=A1=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E9=83=BD=E7=9B=B4=E6=8E=A5=E8=A7=A6=E5=8F=91=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/component/playing/LyricViewToolbar.kt | 15 ++++++++++++++- .../screen/playing/lyric/LyricSettingsState.kt | 4 +++- .../component/settings/SettingProgressSeekBar.kt | 3 ++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt index 8e7b12049..fd817bed4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt @@ -94,6 +94,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T else -> TextAlign.Start } ) + settings.saveData() }, selection = stringArrayResource(id = R.array.lyric_gravity_text).toList(), title = stringResource(R.string.preference_lyric_settings_text_gravity) @@ -101,12 +102,14 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T SettingProgressSeekBar( value = { settings.value.mainFontSize.value }, onValueUpdate = { settings.value = settings.value.copy(mainFontSize = it.sp) }, + onFinishedUpdate = { settings.saveData() }, title = "歌词文字大小", valueRange = 14..36 ) SettingProgressSeekBar( value = { settings.value.mainLineHeight.value }, onValueUpdate = { settings.value = settings.value.copy(mainLineHeight = it.sp) }, + onFinishedUpdate = { settings.saveData() }, title = "歌词行高大小", valueRange = 14..48 ) @@ -115,6 +118,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T onValueUpdate = { settings.value = settings.value.copy(mainFontWeight = it.roundToInt()) }, + onFinishedUpdate = { settings.saveData() }, title = "歌词字重", valueRange = 50..900 ) @@ -123,6 +127,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T onValueUpdate = { settings.value = settings.value.copy(translationFontSize = it.sp) }, + onFinishedUpdate = { settings.saveData() }, title = "翻译文字大小", valueRange = 14..36 ) @@ -131,6 +136,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T onValueUpdate = { settings.value = settings.value.copy(translationLineHeight = it.sp) }, + onFinishedUpdate = { settings.saveData() }, title = "翻译行高大小", valueRange = 14..48 ) @@ -139,6 +145,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T onValueUpdate = { settings.value = settings.value.copy(translationFontWeight = it.roundToInt()) }, + onFinishedUpdate = { settings.saveData() }, title = "翻译字重", valueRange = 50..900 ) @@ -147,6 +154,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T onValueUpdate = { settings.value = settings.value.copy(timeOffset = it.roundToLong()) }, + onFinishedUpdate = { settings.saveData() }, title = "歌词偏移时间(ms)", valueRange = 0..500 ) @@ -155,6 +163,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T onValueUpdate = { settings.value = settings.value.copy(gapSize = it.dp) }, + onFinishedUpdate = { settings.saveData() }, title = "歌词翻译间距", valueRange = 0..50 ) @@ -175,6 +184,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T ) ) }, + onFinishedUpdate = { settings.saveData() }, title = "横向边距", valueRange = 0..50 ) @@ -182,7 +192,10 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T title = "歌词模糊效果", subTitle = "为歌词添加一点模糊效果", state = { settings.value.blurEffectEnable }, - onStateUpdate = { settings.value = settings.value.copy(blurEffectEnable = it) } + onStateUpdate = { + settings.value = settings.value.copy(blurEffectEnable = it) + settings.saveData() + } ) SettingSwitcher( title = "歌词页展开时隐藏其他组件", diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt index 64df2d723..3f84914e4 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt @@ -3,6 +3,7 @@ package com.lalilu.lmusic.compose.screen.playing.lyric import com.funny.data_saver.core.DataSaverConverter import com.funny.data_saver.core.DataSaverInterface import com.funny.data_saver.core.DataSaverMutableState +import com.funny.data_saver.core.SavePolicy import com.funny.data_saver.core.mutableDataSaverStateOf import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -23,6 +24,7 @@ fun provideLyricSettingsState( return mutableDataSaverStateOf( dataSaverInterface = dataSaverInterface, key = "LyricSettings", - initialValue = LyricSettings() + initialValue = LyricSettings(), + savePolicy = SavePolicy.NEVER ) } diff --git a/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt b/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt index 228a8a91f..f8ae5d713 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingProgressSeekBar.kt @@ -24,6 +24,7 @@ import com.lalilu.component.base.ProgressSeekBar fun SettingProgressSeekBar( value: () -> Float, onValueUpdate: (Float) -> Unit = {}, + onFinishedUpdate: (Float) -> Unit = {}, title: String, subTitle: String? = null, valueRange: IntRange @@ -57,7 +58,7 @@ fun SettingProgressSeekBar( }, valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(), steps = valueRange.last - valueRange.first - 1, - onValueChangeFinished = { onValueUpdate(tempValue.floatValue) } + onValueChangeFinished = { onFinishedUpdate(tempValue.floatValue) } ) if (subTitle != null) { Text( From 82adfe94e39c6a9e40d8b741cb8907e6943d9972 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 17 Feb 2025 01:30:57 +0800 Subject: [PATCH 182/213] =?UTF-8?q?[modify]=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=AE=A1=E7=AE=97=E5=B9=B3=E5=9D=87=E4=BA=AE=E5=BA=A6?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E9=81=BF=E5=85=8D=E8=BF=87=E5=BA=A6?= =?UTF-8?q?=E5=88=9B=E5=BB=BAList=E5=AF=BC=E8=87=B4=E5=86=85=E5=AD=98?= =?UTF-8?q?=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lalilu/lmusic/utils/DynamicStatusBarUtils.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt b/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt index 485fb3b9f..002ed60d2 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt @@ -62,14 +62,13 @@ fun ComponentActivity.dynamicUpdateStatusBarColor( if (!isActive) break // 计算Bitmap内的平均亮度 - val averageLuminance = (0.. - (0.. - Color(bitmap.getPixel(x, y)).luminance() - } + var sumValue = 0f + for (x in 0.. Date: Mon, 17 Feb 2025 01:32:10 +0800 Subject: [PATCH 183/213] =?UTF-8?q?perf(utils):=20=E4=BC=98=E5=8C=96=20Sta?= =?UTF-8?q?ckBlurUtils=20=E7=BC=93=E5=AD=98=E7=AD=96=E7=95=A5=E5=B9=B6?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=8D=8F=E7=A8=8B=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重写 LruCache,优化缓存大小计算和内存回收 - 将 coroutineContext 从 Dispatchers.Default 改为 Dispatchers.IO - 移除未使用的 evictAll 函数 --- .../com/lalilu/lmusic/utils/StackBlurUtils.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/utils/StackBlurUtils.kt b/app/src/main/java/com/lalilu/lmusic/utils/StackBlurUtils.kt index 73eee1621..292d6ac2a 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/StackBlurUtils.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/StackBlurUtils.kt @@ -13,13 +13,29 @@ import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext object StackBlurUtils : NativeBlurProcess(), CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.IO + const val MAX_RADIUS = 40 + private val cache = object : LruCache(50 * 1024 * 1024) { + override fun sizeOf(key: String?, value: Bitmap?): Int { + return value?.byteCount ?: 0 + } - private val cache = LruCache(MAX_RADIUS + 1) - override val coroutineContext: CoroutineContext = Dispatchers.Default + override fun entryRemoved( + evicted: Boolean, + key: String?, + oldValue: Bitmap?, + newValue: Bitmap? + ) { + runCatching { + if (oldValue?.isRecycled == false) { + oldValue.recycle() + } + } + } + } private var preloadJob: Job? = null - fun evictAll() = cache.evictAll() fun processWithCache( source: Bitmap, From 288ee951385683c9975c2b608635d4154f314ab9 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 20 Feb 2025 23:56:18 +0800 Subject: [PATCH 184/213] =?UTF-8?q?feat(component):=20=E4=B8=BA=20DialogHo?= =?UTF-8?q?st=E7=BB=84=E4=BB=B6=E6=B7=BB=E5=8A=A0=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AnimatedContent 中添加了自定义的 transitionSpec - 使用 slideInVertically、slideOutVertically 和 scaleOut 动画组合 -调整了动画的 stiffness 和 offset 参数以实现特定的动画效果 --- .../lalilu/component/extension/DialogHost.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/component/src/main/java/com/lalilu/component/extension/DialogHost.kt b/component/src/main/java/com/lalilu/component/extension/DialogHost.kt index 7af242ce0..586fad370 100644 --- a/component/src/main/java/com/lalilu/component/extension/DialogHost.kt +++ b/component/src/main/java/com/lalilu/component/extension/DialogHost.kt @@ -1,6 +1,12 @@ package com.lalilu.component.extension import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -33,6 +39,7 @@ import com.melody.dialog.any_pop.AnyPopDialog import com.melody.dialog.any_pop.AnyPopDialogProperties import com.melody.dialog.any_pop.DirectionState import kotlinx.coroutines.flow.collectLatest +import kotlin.math.roundToInt private val DEFAULT_DIALOG_PROPERTIES = AnyPopDialogProperties( direction = DirectionState.BOTTOM @@ -161,7 +168,19 @@ object DialogWrapper : DialogHost, DialogContext { content = { AnimatedContent( targetState = dialogItem, - label = "" + label = "", + transitionSpec = { + slideInVertically( + animationSpec = spring(stiffness = Spring.StiffnessLow), + initialOffsetY = { (it * 1.2f).roundToInt() } + ) togetherWith slideOutVertically( + animationSpec = spring(stiffness = Spring.StiffnessVeryLow), + targetOffsetY = { (it * 1.2f).roundToInt() } + ) + scaleOut( + animationSpec = spring(stiffness = Spring.StiffnessVeryLow), + targetScale = 0.6f + ) + } ) { dialog -> dialog?.apply { when (this) { From 0f6b148004eb4fdd1ca4341381c216a09c1e25f5 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 21 Feb 2025 00:01:48 +0800 Subject: [PATCH 185/213] =?UTF-8?q?refactor(lmusic):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20SeekbarLayout=20=E4=B8=AD=E7=9A=84=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=92=8C=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构了 seekbar 的更新逻辑,简化了状态管理- 使用 derivedStateOf 和 LaunchedEffect 优化性能 - 移除了冗余的 when 表达式,使代码更加简洁 --- .../screen/playing/seekbar/SeekbarLayout.kt | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt index f32edf82a..a962a00a1 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt @@ -146,27 +146,19 @@ fun SeekbarLayout( derivedStateOf { seekbarState.value is SeekbarState.Cancel || seekbarState.value is SeekbarState.Dispatcher } } - val resultValue = remember { - derivedStateOf { - when { - isSwitching -> { - progressKeeper.updateValue(dataValue()) - false to dataValue() - } - - isTouching && !isCanceled -> true to progressKeeper.nowValue - else -> { - progressKeeper.updateValue(dataValue()) - false to dataValue() - } + val snap = remember { derivedStateOf { !(isSwitching || !isTouching || isCanceled) } } + LaunchedEffect(Unit) { + snapshotFlow { dataValue() }.onEach { + if (isSwitching || !isTouching || isCanceled) { + progressKeeper.updateValue(it) } - } + }.launchIn(this) } // 使值的变化平滑 val animateValue = animateFloatAsState( - targetValue = resultValue.value.second, - animationSpec = if (resultValue.value.first) snap() else spring(stiffness = Spring.StiffnessLow), + targetValue = if (snap.value) progressKeeper.nowValue else dataValue(), + animationSpec = if (snap.value) snap() else spring(stiffness = Spring.StiffnessLow), visibilityThreshold = 0.005f, label = "" ) From ec0f81708595fc8634d6a86d100d31ada0d23627 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Fri, 21 Feb 2025 00:02:55 +0800 Subject: [PATCH 186/213] =?UTF-8?q?perf:=E4=BC=98=E5=8C=96=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E5=8A=A0=E8=BD=BD=E5=8D=8F=E7=A8=8B=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 launch(Dispatchers.IO) 替换为 withContext(Dispatchers.IO) -这一更改可以提高性能,因为 withContext 不会额外启动一个协程 - 而是在当前协程的上下文中执行异步任务 --- .../com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index e1c85d370..1c84940c6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -60,7 +60,7 @@ import com.lalilu.lplayer.action.PlayerAction import com.lalilu.lplayer.extensions.PlayMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.compose.koinInject import kotlin.math.pow @@ -239,7 +239,7 @@ fun PlayingLayout( val lyrics = remember { mutableStateOf>(emptyList()) } LaunchedEffect(key1 = MPlayer.currentMediaItem) { - launch(Dispatchers.IO) { + withContext(Dispatchers.IO) { MPlayer.currentMediaItem ?.let { lyricSource.loadLyric(it) } ?.let { LyricUtils.parseLrc(it.first, it.second) } From 3952cab6d76cc8b607eb2f84ca7aab52ba194f8e Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 23 Feb 2025 18:29:02 +0800 Subject: [PATCH 187/213] =?UTF-8?q?refactor(lmusic):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20SeekbarLayout=20=E4=B8=AD=E7=9A=84=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E5=92=8C=E5=80=BC=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E8=A7=A3=E5=86=B3=E4=BD=BF=E7=94=A8animateValue=E4=B8=8D?= =?UTF-8?q?=E6=96=AD=E8=A7=A6=E5=8F=91=E9=87=8D=E7=BB=84=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 Animatable 替代 animateFloatAsState 以更好地控制动画 - 优化值更新逻辑,确保平滑过渡和正确同步 - 调整触摸响应和进度更新的处理方式- 重构部分代码以提高可读性和性能 --- .../screen/playing/seekbar/SeekbarLayout.kt | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt index a962a00a1..f7b8d5493 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt @@ -1,11 +1,11 @@ package com.lalilu.lmusic.compose.screen.playing.seekbar import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateTo -import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -78,6 +78,7 @@ import com.lalilu.remixicon.Media import com.lalilu.remixicon.media.orderPlayFill import com.lalilu.remixicon.media.repeatOneFill import com.lalilu.remixicon.media.shuffleFill +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -147,26 +148,27 @@ fun SeekbarLayout( } val snap = remember { derivedStateOf { !(isSwitching || !isTouching || isCanceled) } } - LaunchedEffect(Unit) { - snapshotFlow { dataValue() }.onEach { - if (isSwitching || !isTouching || isCanceled) { - progressKeeper.updateValue(it) - } - }.launchIn(this) - } + val animation = remember { Animatable(0f) } // 使值的变化平滑 - val animateValue = animateFloatAsState( - targetValue = if (snap.value) progressKeeper.nowValue else dataValue(), - animationSpec = if (snap.value) snap() else spring(stiffness = Spring.StiffnessLow), - visibilityThreshold = 0.005f, - label = "" - ) LaunchedEffect(Unit) { - snapshotFlow { animateValue.value } - .onEach { onValueChange(it) } - .launchIn(this) + snapshotFlow { if (snap.value) progressKeeper.nowValue else dataValue() } + .distinctUntilChanged() + .onEach { value -> + if (snap.value) { + animation.snapTo(value) + } else { + progressKeeper.updateValue(value) + launch { + animation.animateTo( + targetValue = value, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } + } + }.launchIn(this) } + val touchingProgress = animateFloatAsState( targetValue = if (isTouching && !isCanceled) 1f else 0f, animationSpec = spring(stiffness = Spring.StiffnessLow), @@ -226,7 +228,7 @@ fun SeekbarLayout( val maxTextResult = remember { mutableStateOf(null) } LaunchedEffect(Unit) { - snapshotFlow { animateValue.value.toLong() } + snapshotFlow { animation.value.toLong() } .distinctUntilChangedBy { it / 1000L } .onEach { val text = it.durationToTime() @@ -323,7 +325,7 @@ fun SeekbarLayout( when (event.type) { PointerEventType.Press -> { // 开始触摸时,将当前可见的进度值记录下来 - progressKeeper.updateValue(animateValue.value) + progressKeeper.updateValue(animation.value) isTouching = true isMoved = false } @@ -394,14 +396,14 @@ fun SeekbarLayout( scaleY = scaleX } ) { + val innerPath = remember { Path() } + Canvas( modifier = Modifier .fillMaxWidth() .height(56.dp) .clip(RoundedCornerShape(16.dp)) ) { - val innerPath = Path() - val maxPadding = 4.dp.toPx() val paddingValue = maxPadding * touchingProgress.value @@ -420,7 +422,7 @@ fun SeekbarLayout( ) ) - val actualValue = animateValue.value + val actualValue = animation.value.also { onValueChange(it) } val actualProgress = actualValue.normalize(minValue(), maxValue()) val thumbWidth = lerp( From 79d669ae8f2f97c4b5870dba9428afc0664a5ed4 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 2 Mar 2025 17:09:58 +0800 Subject: [PATCH 188/213] =?UTF-8?q?refactor(seekbar):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20SeekbarLayout=20=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E6=98=BE=E7=A4=BA=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -拆分 SeekbarLayout 为多个子组件,提高代码可读性和可维护性 - 优化动画效果,使用 Animatable 替代手动动画控制 - 改进触摸事件处理逻辑,提高用户体验 - 重构文本显示逻辑,支持动态更新 --- .../compose/screen/playing/PlayingLayout.kt | 13 +- .../screen/playing/seekbar/SeekbarLayout.kt | 825 +++++++++--------- 2 files changed, 438 insertions(+), 400 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt index 1c84940c6..2aac778a5 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayout.kt @@ -3,6 +3,7 @@ package com.lalilu.lmusic.compose.screen.playing import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.DecelerateInterpolator import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring @@ -24,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.withFrameMillis @@ -79,8 +79,8 @@ fun PlayingLayout( val backgroundColor = remember { mutableStateOf(Color.DarkGray) } val animateColor = animateColorAsState(targetValue = backgroundColor.value, label = "") val scrollToTopEvent = remember { mutableStateOf(0L) } - val seekbarTime = remember { mutableLongStateOf(0L) } val currentPosition = remember { mutableFloatStateOf(0f) } + val animation = remember { Animatable(0f) } val draggable = rememberCustomAnchoredDraggableState { oldState, newState -> if (newState == DragAnchor.MiddleXMax && oldState != DragAnchor.MiddleXMax) { @@ -265,7 +265,7 @@ fun PlayingLayout( }, lyricEntry = lyrics, listState = listState, - currentTime = { seekbarTime.longValue }, + currentTime = { animation.value.toLong() }, screenConstraints = constraints, isUserClickEnable = { draggable.state.value == DragAnchor.Max }, isUserScrollEnable = { isLyricScrollEnable.value }, @@ -313,10 +313,13 @@ fun PlayingLayout( } ) { SeekbarLayout( - modifier = Modifier.hideControl(enable = { hideComponent.value }), + modifier = Modifier + .hideControl(enable = { hideComponent.value }) + .padding(horizontal = 40.dp) + .padding(bottom = 100.dp), animateColor = { animateColor.value }, - onValueChange = { seekbarTime.longValue = it.toLong() }, maxValue = { MPlayer.currentDuration.toFloat() }, + animation = animation, dataValue = { currentPosition.floatValue }, onDispatchDragOffset = { enhanceSheetState?.dispatch(it) }, onDragStop = { result -> diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt index f7b8d5493..d281a5536 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt @@ -1,32 +1,30 @@ package com.lalilu.lmusic.compose.screen.playing.seekbar -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberDraggable2DState -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -53,44 +51,30 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.font.DeviceFontFamilyName -import androidx.compose.ui.text.font.Font 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.BaselineShift import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.lerp -import com.lalilu.RemixIcon import com.lalilu.common.AccumulatedValue -import com.lalilu.lmusic.utils.extension.durationToTime -import com.lalilu.remixicon.Media -import com.lalilu.remixicon.media.orderPlayFill -import com.lalilu.remixicon.media.repeatOneFill -import com.lalilu.remixicon.media.shuffleFill import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlin.math.absoluteValue -private sealed class SeekbarState { - data object Idle : SeekbarState() - data object ProgressBar : SeekbarState() - data object Switcher : SeekbarState() - data object Cancel : SeekbarState() - data object Dispatcher : SeekbarState() +sealed interface SeekbarState { + data object Idle : SeekbarState + data object ProgressBar : SeekbarState + data object Switcher : SeekbarState + data object Cancel : SeekbarState + data object Dispatcher : SeekbarState } sealed interface ClickPart { @@ -99,6 +83,13 @@ sealed interface ClickPart { data object End : ClickPart } +fun SeekbarState.isCanceled(): Boolean { + return when (this) { + is SeekbarState.Cancel, is SeekbarState.Dispatcher -> true + else -> false + } +} + @Preview @Composable fun SeekbarLayout( @@ -107,98 +98,16 @@ fun SeekbarLayout( maxValue: () -> Float = { 0f }, dataValue: () -> Float = { 0f }, switchIndex: () -> Int = { 0 }, + scrollThreadHold: Float = 200f, + animation: Animatable = remember { Animatable(0f) }, animateColor: () -> Color = { Color.DarkGray }, onDragStart: suspend (Offset) -> Unit = {}, onDragStop: suspend (Int) -> Unit = {}, onDispatchDragOffset: (Float) -> Unit = {}, - onValueChange: (Float) -> Unit = {}, onSeekTo: (Float) -> Unit = {}, onSwitchTo: (Int) -> Unit = {}, onClick: (ClickPart) -> Unit = {} ) { - val haptic = LocalHapticFeedback.current - val density = LocalDensity.current - val scope = rememberCoroutineScope() - val textMeasurer = rememberTextMeasurer() - val bgColor = MaterialTheme.colors.background - val accumulator = remember { AccumulatedValue() } - - val scrollSensitivity = remember { 1.3f } - val scrollThreadHold = remember { 200f } - val seekbarPaddingBottom = remember { density.run { 156.dp.toPx() } } - var boxSize by remember { mutableStateOf(IntSize.Zero) } - - val progressKeeper = rememberSeekbarProgressKeeper( - minValue = minValue, - maxValue = maxValue, - sizeWidth = { boxSize.width.toFloat() }, - scrollSensitivity = scrollSensitivity - ) - - val seekbarOffsetY = remember { mutableFloatStateOf(0f) } - val switchMode = remember { mutableStateOf(false) } - val switchModeX = remember { mutableFloatStateOf(0f) } - val seekbarState = remember { mutableStateOf(SeekbarState.Idle) } - - var isMoved by remember { mutableStateOf(false) } - var isTouching by remember { mutableStateOf(false) } - val isSwitching by remember { derivedStateOf { seekbarState.value is SeekbarState.Switcher } } - val isCanceled by remember { - derivedStateOf { seekbarState.value is SeekbarState.Cancel || seekbarState.value is SeekbarState.Dispatcher } - } - - val snap = remember { derivedStateOf { !(isSwitching || !isTouching || isCanceled) } } - val animation = remember { Animatable(0f) } - - // 使值的变化平滑 - LaunchedEffect(Unit) { - snapshotFlow { if (snap.value) progressKeeper.nowValue else dataValue() } - .distinctUntilChanged() - .onEach { value -> - if (snap.value) { - animation.snapTo(value) - } else { - progressKeeper.updateValue(value) - launch { - animation.animateTo( - targetValue = value, - animationSpec = spring(stiffness = Spring.StiffnessLow) - ) - } - } - }.launchIn(this) - } - - val touchingProgress = animateFloatAsState( - targetValue = if (isTouching && !isCanceled) 1f else 0f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - visibilityThreshold = 0.001f, - label = "" - ) - val switchingProgress = animateFloatAsState( - targetValue = if (isSwitching) 1f else 0f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - visibilityThreshold = 0.001f, - label = "" - ) - val yProgressValue = remember { - derivedStateOf { - val value = seekbarOffsetY.floatValue.coerceAtMost(0f) - .absoluteValue - .takeIf { it < (scrollThreadHold / 2f) } - ?: 0f - - (value / (scrollThreadHold / 2f)).coerceIn(0f, 1f) - } - } - val yTranslationAnimateValue = animateFloatAsState( - targetValue = yProgressValue.value, - animationSpec = spring( - stiffness = Spring.StiffnessLow, - dampingRatio = Spring.DampingRatioMediumBouncy - ), - label = "" - ) val textStyle = remember { TextStyle.Default.copy( fontSize = 16.sp, @@ -207,337 +116,418 @@ fun SeekbarLayout( baselineShift = BaselineShift.None, textAlign = TextAlign.End, color = Color.White, - fontFamily = FontFamily(Font(familyName = DeviceFontFamilyName("FontFamily.Monospace"))) + fontFamily = FontFamily.Monospace ) } - val textSize = remember { - derivedStateOf { - // 获取最大时长,并使用0替换其内部的数字,计算其最大宽度 - val text = maxValue().toLong() - .durationToTime() - .replace(Regex("[0-9]"), "0") - val result = textMeasurer.measure( - text = text, - style = textStyle - ) - result.size.width to result.size.height - } - } - val currentTextResult = remember { mutableStateOf(null) } - val maxTextResult = remember { mutableStateOf(null) } + BoxWithConstraints( + modifier = modifier + ) { + val boxSize = constraints + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val seekbarPaddingBottom = remember { 100.dp } + val seekbarHeight = remember { 56.dp } + + val progressKeeper = rememberSeekbarProgressKeeper( + minValue = minValue, + maxValue = maxValue, + sizeWidth = { boxSize.maxWidth.toFloat() }, + ) - LaunchedEffect(Unit) { - snapshotFlow { animation.value.toLong() } - .distinctUntilChangedBy { it / 1000L } - .onEach { - val text = it.durationToTime() - val result = textMeasurer.measure(text = text, style = textStyle) - currentTextResult.value = result - } - .launchIn(this) + val switchMode = remember { mutableStateOf(false) } + val switchModeX = remember { mutableFloatStateOf(0f) } + val seekbarOffsetY = remember { mutableFloatStateOf(0f) } + val seekbarState = remember { mutableStateOf(SeekbarState.Idle) } + + var isMoved by remember { mutableStateOf(false) } + var isTouching by remember { mutableStateOf(false) } + val isSwitching by remember { derivedStateOf { seekbarState.value is SeekbarState.Switcher } } + val isCanceled by remember { derivedStateOf { seekbarState.value.isCanceled() } } + val snap = remember { derivedStateOf { !(isSwitching || !isTouching || isCanceled) } } + + val maxDurationText = remember(maxValue()) { maxValue().toLong().durationToTime() } + val currentTimeText = durationToText(duration = { animation.value.toLong() }) + + // 使值的变化平滑 + LaunchedEffect(Unit) { + snapshotFlow { if (snap.value) progressKeeper.nowValue else dataValue() } + .distinctUntilChanged() + .onEach { value -> + if (snap.value) { + animation.snapTo(value) + } else { + progressKeeper.updateValue(value) + launch { + animation.animateTo( + targetValue = value, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } + } + }.launchIn(this) + } - snapshotFlow { maxValue().toLong() } - .distinctUntilChangedBy { it / 1000L } - .onEach { - val text = it.durationToTime() - val result = textMeasurer.measure(text = text, style = textStyle) - maxTextResult.value = result + val offsetY = remember { + derivedStateOf { + val offsetY = seekbarOffsetY.floatValue + .coerceAtMost(0f) + .absoluteValue + .takeIf { it < (scrollThreadHold / 2f) } + ?: 0f + (offsetY / (scrollThreadHold / 2f)).coerceIn(0f, 1f) } - .launchIn(this) - } + } + val offsetYProgress = animateFloatAsState( + targetValue = offsetY.value, + animationSpec = spring( + stiffness = Spring.StiffnessLow, + dampingRatio = Spring.DampingRatioMediumBouncy + ), + ) - val draggableState = rememberDraggable2DState { offset -> - val oldState = seekbarState.value + val draggableState = rememberDraggable2DState { offset -> + val oldState = seekbarState.value - val deltaY = offset.y - val deltaX = offset.x + val deltaY = offset.y + val deltaX = offset.x - // 直接记录Y轴上的滚动距离 - seekbarOffsetY.floatValue += deltaY + // 直接记录Y轴上的滚动距离 + seekbarOffsetY.floatValue += deltaY - // 根据当前状态控制进度变量 - when { - isSwitching -> { - switchModeX.floatValue += deltaX + // 根据当前状态控制进度变量 + when { + isSwitching -> { + switchModeX.floatValue += deltaX + } + + oldState == SeekbarState.ProgressBar -> { + progressKeeper.updateValueByDelta(delta = deltaX) + } } - oldState == SeekbarState.ProgressBar -> { - progressKeeper.updateValueByDelta(delta = deltaX) + // 根据Y轴滚动距离决定新的状态 + seekbarState.value = when { + seekbarOffsetY.floatValue < -scrollThreadHold -> SeekbarState.Dispatcher + seekbarOffsetY.floatValue < -(scrollThreadHold / 2f) -> SeekbarState.Cancel + else -> if (switchMode.value) SeekbarState.Switcher else SeekbarState.ProgressBar } - } - // 根据Y轴滚动距离决定新的状态 - seekbarState.value = when { - seekbarOffsetY.floatValue < -scrollThreadHold -> SeekbarState.Dispatcher - seekbarOffsetY.floatValue < -(scrollThreadHold / 2f) -> SeekbarState.Cancel - else -> if (switchMode.value) SeekbarState.Switcher else SeekbarState.ProgressBar - } + // 当状态发生变化的时候,进行震动 + if (oldState != seekbarState.value) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } - // 当状态发生变化的时候,进行震动 - if (oldState != seekbarState.value) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } + when (oldState) { + seekbarState.value -> {} + SeekbarState.Dispatcher -> scope.launch { onDragStop(-1) } - when (oldState) { - seekbarState.value -> {} - SeekbarState.Dispatcher -> scope.launch { onDragStop(-1) } - - SeekbarState.Cancel -> when (seekbarState.value) { - SeekbarState.Dispatcher -> { - val animationState = AnimationState( - initialValue = 0f, - initialVelocity = 100f, - ) - scope.launch { - var lastValue = 0f - animationState.animateTo(scrollThreadHold + seekbarPaddingBottom) { - val dt = value - lastValue - lastValue = value - onDispatchDragOffset(-dt) + SeekbarState.Cancel -> when (seekbarState.value) { + SeekbarState.Dispatcher -> { + val animationState = AnimationState( + initialValue = 0f, + initialVelocity = 100f, + ) + scope.launch { + var lastValue = 0f + val targetOffset = scrollThreadHold + + density.run { (seekbarPaddingBottom + seekbarHeight).toPx() } + + animationState.animateTo(targetOffset) { + val dt = value - lastValue + lastValue = value + onDispatchDragOffset(-dt) + } } } + + else -> {} } else -> {} } - else -> {} - } - - // 若当前状态为Dispatcher,则将滚动的位移量向外分发 - if (seekbarState.value is SeekbarState.Dispatcher) { - onDispatchDragOffset(deltaY) + // 若当前状态为Dispatcher,则将滚动的位移量向外分发 + if (seekbarState.value is SeekbarState.Dispatcher) { + onDispatchDragOffset(deltaY) + } } - } - Box( - modifier = modifier - .padding(bottom = 100.dp) - .fillMaxWidth(0.7f) - .height(IntrinsicSize.Max) - .onPlaced { boxSize = it.size } - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - - when (event.type) { - PointerEventType.Press -> { - // 开始触摸时,将当前可见的进度值记录下来 - progressKeeper.updateValue(animation.value) - isTouching = true - isMoved = false - } + Box( + modifier = Modifier + .fillMaxWidth() + .height(seekbarHeight) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + + when (event.type) { + PointerEventType.Press -> { + // 开始触摸时,将当前可见的进度值记录下来 + progressKeeper.updateValue(animation.value) + isTouching = true + isMoved = false + } - PointerEventType.Release -> { - if (isMoved && !isCanceled && !isSwitching) { - onSeekTo(progressKeeper.nowValue) + PointerEventType.Release -> { + if (isMoved && !isCanceled && !isSwitching) { + onSeekTo(progressKeeper.nowValue) + } + isTouching = false } - isTouching = false } } } } - } - .pointerInput(Unit) { - detectTapGestures { position -> - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - - val clickPart = when (position.x) { - in 0f..(boxSize.width / 3f) -> ClickPart.Start - in (boxSize.width * 2 / 3f)..boxSize.width.toFloat() -> ClickPart.End - else -> ClickPart.Middle + .pointerInput(Unit) { + detectTapGestures { position -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + + val clickPart = when (position.x) { + in 0f..(boxSize.maxWidth / 3f) -> ClickPart.Start + in (boxSize.maxWidth * 2 / 3f)..boxSize.maxWidth.toFloat() -> ClickPart.End + else -> ClickPart.Middle + } + onClick(clickPart) } - onClick(clickPart) } - } - .combineDetectDrag( - onLongClickStart = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - switchModeX.floatValue = it.x - switchMode.value = true - seekbarState.value = SeekbarState.Switcher - }, - onDragStart = { - isMoved = true - seekbarState.value = if (switchMode.value) SeekbarState.Switcher - else SeekbarState.ProgressBar - - seekbarOffsetY.floatValue = it.y - scope.launch { onDragStart(it) } - }, - onDragEnd = { - if (isSwitching) { - val actualWidth = boxSize.width - density.run { 4.dp.roundToPx() } - val singleWidth = actualWidth / 3f - - when (switchModeX.floatValue) { - in 0f..singleWidth -> onSwitchTo(0) - in singleWidth..(singleWidth * 2) -> onSwitchTo(1) - else -> onSwitchTo(2) + .combineDetectDrag( + onLongClickStart = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + switchModeX.floatValue = it.x + switchMode.value = true + seekbarState.value = SeekbarState.Switcher + }, + onDragStart = { + isMoved = true + seekbarState.value = if (switchMode.value) SeekbarState.Switcher + else SeekbarState.ProgressBar + + seekbarOffsetY.floatValue = it.y + scope.launch { onDragStart(it) } + }, + onDragEnd = { + if (isSwitching) { + val actualWidth = boxSize.maxWidth - density.run { 4.dp.roundToPx() } + val singleWidth = actualWidth / 3f + + when (switchModeX.floatValue) { + in 0f..singleWidth -> onSwitchTo(0) + in singleWidth..(singleWidth * 2) -> onSwitchTo(1) + else -> onSwitchTo(2) + } } - } - switchMode.value = false - seekbarState.value = SeekbarState.Idle + switchMode.value = false + seekbarState.value = SeekbarState.Idle - seekbarOffsetY.floatValue = 0f - scope.launch { onDragStop(0) } - }, - onDrag = { _, dragAmount -> - draggableState.dispatchRawDelta(dragAmount) - } - ) - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - translationY = -yTranslationAnimateValue.value * (scrollThreadHold / 2f) - scaleX = 1f - (yTranslationAnimateValue.value * 0.1f) - scaleY = scaleX - } - ) { - val innerPath = remember { Path() } + seekbarOffsetY.floatValue = 0f + scope.launch { onDragStop(0) } + }, + onDrag = { _, dragAmount -> + draggableState.dispatchRawDelta(dragAmount) + } + ) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(56.dp) + translationY = -offsetYProgress.value * (scrollThreadHold / 2f) + scaleX = 1f - (offsetYProgress.value * 0.1f) + scaleY = scaleX + } .clip(RoundedCornerShape(16.dp)) ) { - val maxPadding = 4.dp.toPx() - val paddingValue = maxPadding * touchingProgress.value - - val innerRadius = 16.dp.toPx() - paddingValue - val innerHeight = size.height - (paddingValue * 2f) - val innerWidth = size.width - (paddingValue * 2f) - - innerPath.reset() - innerPath.addRoundRect( - RoundRect( - rect = Rect( - offset = Offset(x = paddingValue, y = paddingValue), - size = Size(width = innerWidth, height = innerHeight) - ), - cornerRadius = CornerRadius(innerRadius, innerRadius) - ) + SeekbarBackground(visible = { isTouching && !isCanceled }) + SeekbarContentMask(clip = { isTouching && !isCanceled }) + SeekbarDuration( + modifier = Modifier + .align(Alignment.CenterEnd), + visible = { !isSwitching }, + text = { maxDurationText }, + textStyle = textStyle ) - - val actualValue = animation.value.also { onValueChange(it) } - val actualProgress = actualValue.normalize(minValue(), maxValue()) - - val thumbWidth = lerp( - start = innerWidth * actualProgress, // 根据进度计算的宽度 - stop = innerWidth / 3f, // 进度条均分宽度 - fraction = switchingProgress.value // 根据切换进度进行插值 - ).coerceIn(0f, innerWidth) - - val thumbLeft = lerp( - start = paddingValue, - stop = switchModeX.floatValue - (innerWidth / 3f) / 2f, - fraction = switchingProgress.value - ).coerceIn( - paddingValue, - paddingValue + innerWidth - (innerWidth / 3f) - ) // 限制滑块位置,确保其始终处于可见范围内 - - val thumbTop = paddingValue - val thumbHeight = innerHeight - - val textX = - (paddingValue + (innerWidth * actualProgress) - 16.dp.toPx() - textSize.value.first) - .let { accumulator.accumulate(it) } - .coerceAtLeast(16.dp.roundToPx()) - - // 纯色背景 - drawRect( - color = bgColor, - alpha = touchingProgress.value + SeekbarThumb( + clip = { isTouching && !isCanceled }, + thumbColor = animateColor, + progress = { animation.value.normalize(minValue(), maxValue()) }, + switching = { isSwitching }, + switchModeX = { switchModeX.floatValue } + ) + SeekbarDuration( + modifier = Modifier + .align(Alignment.CenterStart), + visible = { !isSwitching }, + text = { currentTimeText.value }, + textStyle = textStyle, + offsetProgress = { animation.value.normalize(minValue(), maxValue()) } ) + } + } +} - // 圆角裁切 - clipPath(innerPath) { - drawRect(color = Color(100, 100, 100, 50)) - - // 绘制总时长文本(固定右侧) - maxTextResult.value?.let { - drawText( - textLayoutResult = it, - color = Color.White, - alpha = 1f - switchingProgress.value, - topLeft = Offset( - x = size.width - textSize.value.first - 16.dp.toPx(), - y = (size.height - textSize.value.second) / 2f - ) - ) - } +@Composable +fun SeekbarBackground( + modifier: Modifier = Modifier, + bgColor: Color = MaterialTheme.colors.background, + visible: () -> Boolean = { false } +) { + val bgAlpha = animateFloatAsState( + targetValue = if (visible()) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + visibilityThreshold = 0.001f, + label = "SeekbarBackground_bgAlpha" + ) + Canvas(modifier = modifier.fillMaxSize()) { + drawRect( + color = bgColor, + alpha = bgAlpha.value + ) + } +} - // 绘制滑块 - drawRoundRect( - color = animateColor(), - cornerRadius = CornerRadius(innerRadius, innerRadius), - topLeft = Offset(x = thumbLeft, y = thumbTop), - size = Size(width = thumbWidth, height = thumbHeight) - ) +@Composable +fun SeekbarContentMask( + modifier: Modifier = Modifier, + maskColor: Color = Color(0x33646464), + clip: () -> Boolean = { false } +) { + val path = remember { Path() } + val clipProgress = animateFloatAsState( + targetValue = if (clip()) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + visibilityThreshold = 0.001f, + label = "SeekbarContentMask_clipProgress" + ) - // 绘制实时进度文本(移动) - currentTextResult.value?.let { - drawText( - textLayoutResult = it, - color = Color.White, - alpha = 1f - switchingProgress.value, - topLeft = Offset( - x = textX.toFloat(), - y = (size.height - textSize.value.second) / 2f - ) - ) - } + Canvas(modifier = modifier.fillMaxSize()) { + val maxPadding = 4.dp.toPx() + val paddingValue = maxPadding * clipProgress.value + + val innerRadius = 16.dp.toPx() - paddingValue + val innerHeight = size.height - (paddingValue * 2f) + val innerWidth = size.width - (paddingValue * 2f) + + path.reset() + path.addRoundRect( + RoundRect( + rect = Rect( + offset = Offset(x = paddingValue, y = paddingValue), + size = Size(width = innerWidth, height = innerHeight) + ), + cornerRadius = CornerRadius(innerRadius, innerRadius) + ) + ) - // 绘制把手元素 -// drawRoundRect( -// color = Color.White, -// alpha = alpha.value, -// cornerRadius = CornerRadius(50f), -// topLeft = Offset( -// x = innerWidth * actualProgress + paddingAnimate - 8.dp.toPx(), -// y = (size.height - (innerHeight * 0.5f)) / 2f -// ), -// size = Size( -// width = 4.dp.toPx(), -// height = innerHeight * 0.5f -// ) -// ) - } + clipPath(path) { + drawRect(color = maskColor) } + } +} - AnimatedVisibility( - modifier = Modifier.fillMaxSize(), - visible = isSwitching, - enter = fadeIn(), - exit = fadeOut() - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceAround - ) { - Icon( - imageVector = RemixIcon.Media.orderPlayFill, - contentDescription = null, - tint = Color.White - ) - Icon( - imageVector = RemixIcon.Media.repeatOneFill, - contentDescription = null, - tint = Color.White - ) - Icon( - imageVector = RemixIcon.Media.shuffleFill, - contentDescription = null, - tint = Color.White - ) - } +@Composable +fun SeekbarDuration( + modifier: Modifier = Modifier, + text: () -> String, + textStyle: TextStyle, + visible: () -> Boolean = { true }, + offsetProgress: () -> Float = { 0f } +) { + val accumulator = remember { AccumulatedValue() } + val alphaValue = animateFloatAsState( + targetValue = if (visible()) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + visibilityThreshold = 0.001f, + label = "SeekbarDuration_alphaValue" + ) + + BoxWithConstraints(modifier = modifier.wrapContentSize()) { + Text( + modifier = Modifier + .padding(horizontal = 16.dp) + .graphicsLayer { + alpha = alphaValue.value + + val maxPadding = 4.dp.toPx() + val innerWidth = constraints.maxWidth - (maxPadding * 2f) + + translationX = + ((innerWidth * offsetProgress()) - size.width - 32.dp.toPx()) + .let { accumulator.accumulate(it).toFloat() } + .coerceAtLeast(0f) + }, + text = text(), + style = textStyle + ) + } +} + +@Composable +fun SeekbarThumb( + modifier: Modifier = Modifier, + thumbColor: () -> Color = { Color(0xFF007AD5) }, + progress: () -> Float = { 1f }, + clip: () -> Boolean = { false }, + switching: () -> Boolean = { false }, + switchModeX: () -> Float = { 0f } +) { + val path = remember { Path() } + val clipProgress = animateFloatAsState( + targetValue = if (clip()) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + visibilityThreshold = 0.001f, + label = "SeekbarThumb_clipProgress" + ) + val switchProgress = animateFloatAsState( + targetValue = if (switching()) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + visibilityThreshold = 0.001f, + label = "SeekbarThumb_switchProgress" + ) + + Canvas(modifier = modifier.fillMaxSize()) { + val maxPadding = 4.dp.toPx() + val paddingValue = maxPadding * clipProgress.value + + val innerRadius = 16.dp.toPx() - paddingValue + val innerHeight = size.height - (paddingValue * 2f) + val innerWidth = size.width - (paddingValue * 2f) + + val thumbWidth = lerp( + start = innerWidth * progress(), // 根据进度计算的宽度 + stop = innerWidth / 3f, // 进度条均分宽度 + fraction = switchProgress.value // 根据切换进度进行插值 + ).coerceIn(0f, innerWidth) + + val thumbLeft = lerp( + start = paddingValue, + stop = switchModeX() - (innerWidth / 3f) / 2f, + fraction = switchProgress.value + ).coerceIn( + paddingValue, + paddingValue + innerWidth - (innerWidth / 3f) + ) // 限制滑块位置,确保其始终处于可见范围内 + + path.reset() + path.addRoundRect( + RoundRect( + rect = Rect( + offset = Offset(x = paddingValue, y = paddingValue), + size = Size(width = innerWidth, height = innerHeight) + ), + cornerRadius = CornerRadius(innerRadius, innerRadius) + ) + ) + + clipPath(path) { + // 绘制滑块 + drawRoundRect( + color = thumbColor(), + cornerRadius = CornerRadius(innerRadius, innerRadius), + topLeft = Offset(x = thumbLeft, y = paddingValue), + size = Size(width = thumbWidth, height = innerHeight) + ) } } } @@ -579,4 +569,49 @@ private fun Float.normalize(minValue: Float, maxValue: Float): Float { return ((this - min) / (max - min)) .coerceIn(0f, 1f) +} + +fun Long.durationToTime(): String { + val hour = this / 3600000 + val minute = this / 60000 % 60 + val second = this / 1000 % 60 + return if (hour > 0L) "%02d:%02d:%02d".format(hour, minute, second) + else "%02d:%02d".format(minute, second) +} + +@Composable +fun durationToText( + duration: () -> Long = { 0L } +): MutableState { + val durationText = remember { mutableStateOf(duration().durationToTime()) } + + LaunchedEffect(Unit) { + var lastTime = -1L + var hour = 0 + var minute = 0 + var second = 0 + + snapshotFlow { duration() } + .onEach { timeValue -> + if (timeValue / 1000L != lastTime) { + val hourTemp = (timeValue / 3600000).toInt() + val minuteTemp = (timeValue / 60000 % 60).toInt() + val secondTemp = (timeValue / 1000 % 60).toInt() + + if (hourTemp != hour || minuteTemp != minute || secondTemp != second) { + hour = hourTemp + minute = minuteTemp + second = secondTemp + + durationText.value = + if (hour > 0L) "%02d:%02d:%02d".format(hour, minute, second) + else "%02d:%02d".format(minute, second) + } + } + lastTime = timeValue / 1000L + } + .launchIn(this) + } + + return durationText } \ No newline at end of file From 9ca0fc34d5b55188cbe55da39d876d5559396c36 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 2 Mar 2025 17:15:28 +0800 Subject: [PATCH 189/213] =?UTF-8?q?feat(compose):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 SeekbarLayout 中添加 SeekbarSwitcher 组件,用于显示播放模式切换选项 - 新增播放模式切换相关的图标和动画效果 - 优化 seekbar 相关组件,支持播放模式切换功能 --- .../screen/playing/seekbar/SeekbarLayout.kt | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt index d281a5536..1c205d743 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt @@ -1,5 +1,6 @@ package com.lalilu.lmusic.compose.screen.playing.seekbar +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.AnimationVector1D @@ -7,19 +8,24 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberDraggable2DState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -62,14 +68,19 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.lerp +import com.lalilu.RemixIcon import com.lalilu.common.AccumulatedValue +import com.lalilu.remixicon.Media +import com.lalilu.remixicon.media.orderPlayFill +import com.lalilu.remixicon.media.repeatOneFill +import com.lalilu.remixicon.media.shuffleFill import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlin.math.absoluteValue -sealed interface SeekbarState { +private sealed interface SeekbarState { data object Idle : SeekbarState data object ProgressBar : SeekbarState data object Switcher : SeekbarState @@ -83,7 +94,7 @@ sealed interface ClickPart { data object End : ClickPart } -fun SeekbarState.isCanceled(): Boolean { +private fun SeekbarState.isCanceled(): Boolean { return when (this) { is SeekbarState.Cancel, is SeekbarState.Dispatcher -> true else -> false @@ -363,12 +374,13 @@ fun SeekbarLayout( textStyle = textStyle, offsetProgress = { animation.value.normalize(minValue(), maxValue()) } ) + SeekbarSwitcher(switching = { isSwitching }) } } } @Composable -fun SeekbarBackground( +private fun SeekbarBackground( modifier: Modifier = Modifier, bgColor: Color = MaterialTheme.colors.background, visible: () -> Boolean = { false } @@ -388,7 +400,7 @@ fun SeekbarBackground( } @Composable -fun SeekbarContentMask( +private fun SeekbarContentMask( modifier: Modifier = Modifier, maskColor: Color = Color(0x33646464), clip: () -> Boolean = { false } @@ -427,7 +439,7 @@ fun SeekbarContentMask( } @Composable -fun SeekbarDuration( +private fun SeekbarDuration( modifier: Modifier = Modifier, text: () -> String, textStyle: TextStyle, @@ -464,7 +476,7 @@ fun SeekbarDuration( } @Composable -fun SeekbarThumb( +private fun SeekbarThumb( modifier: Modifier = Modifier, thumbColor: () -> Color = { Color(0xFF007AD5) }, progress: () -> Float = { 1f }, @@ -532,6 +544,43 @@ fun SeekbarThumb( } } +@Composable +private fun SeekbarSwitcher( + modifier: Modifier = Modifier, + switching: () -> Boolean = { false }, +) { + AnimatedVisibility( + modifier = modifier.fillMaxSize(), + visible = switching(), + enter = fadeIn(), + exit = fadeOut() + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround + ) { + Icon( + imageVector = RemixIcon.Media.orderPlayFill, + contentDescription = null, + tint = Color.White + ) + Icon( + imageVector = RemixIcon.Media.repeatOneFill, + contentDescription = null, + tint = Color.White + ) + Icon( + imageVector = RemixIcon.Media.shuffleFill, + contentDescription = null, + tint = Color.White + ) + } + } +} + private fun Modifier.combineDetectDrag( key: Any = Unit, onDragStart: (Offset) -> Unit = { }, @@ -571,7 +620,7 @@ private fun Float.normalize(minValue: Float, maxValue: Float): Float { .coerceIn(0f, 1f) } -fun Long.durationToTime(): String { +private fun Long.durationToTime(): String { val hour = this / 3600000 val minute = this / 60000 % 60 val second = this / 1000 % 60 @@ -580,7 +629,7 @@ fun Long.durationToTime(): String { } @Composable -fun durationToText( +private fun durationToText( duration: () -> Long = { 0L } ): MutableState { val durationText = remember { mutableStateOf(duration().durationToTime()) } From 399b038804af7b23870cf69d3e67905082b74d6b Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 2 Mar 2025 23:16:51 +0800 Subject: [PATCH 190/213] =?UTF-8?q?feat(component):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20SlotContent=20=E5=92=8C=20SlotState=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 SlotContent 和 SlotState 接口,用于扩展组件功能 - 实现状态管理和内容展示的分离,提高组件灵活性 - 新增 StickerRow 组件,用于展示歌曲卡片上的贴纸图标 -重构 SongCard 组件,支持自定义贴纸内容和布局 --- .../compose/screen/playing/PlaylistLayout.kt | 129 ++++++++---------- .../search/extensions/SearchSongsResult.kt | 5 +- .../screen/songs/SongsScreenContent.kt | 3 + .../java/com/lalilu/common/base/Sticker.kt | 6 + .../java/com/lalilu/component/SlotContent.kt | 121 ++++++++++++++++ .../java/com/lalilu/component/SlotState.kt | 37 +++++ .../com/lalilu/component/card/SongCard.kt | 115 +++++++--------- .../com/lalilu/component/card/StickerRow.kt | 67 +++++++++ .../lalbum/screen/AlbumDetailScreenContent.kt | 3 + .../screen/ArtistDetailScreenContent.kt | 3 + .../java/com/lalilu/lhistory/HistoryPanel.kt | 19 +++ .../lalilu/lhistory/viewmodel/HistoryVM.kt | 11 ++ .../com/lalilu/lplaylist/PlaylistModule.kt | 17 ++- .../detail/PlaylistDetailScreenContent.kt | 4 + 14 files changed, 403 insertions(+), 137 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/SlotContent.kt create mode 100644 component/src/main/java/com/lalilu/component/SlotState.kt create mode 100644 component/src/main/java/com/lalilu/component/card/StickerRow.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index bd4bfce82..b6f970a03 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -1,22 +1,14 @@ package com.lalilu.lmusic.compose.screen.playing -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.combinedClickable +import androidx.annotation.OptIn +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -24,16 +16,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.media3.common.MediaItem -import coil3.compose.AsyncImage +import androidx.media3.common.util.UnstableApi +import com.lalilu.common.base.Sticker +import com.lalilu.component.card.SongCard +import com.lalilu.component.card.StickerRow import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.state import com.lalilu.lmusic.compose.screen.playing.util.DiffUtil import com.lalilu.lmusic.compose.screen.playing.util.ListUpdateCallback import com.lalilu.lplayer.action.PlayerAction @@ -103,6 +95,7 @@ fun PlaylistLayout( val view = LocalView.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() + val favouriteIds = state("favourite_ids", emptyList()) var actualItems by remember { mutableStateOf(emptyList>()) } @@ -141,16 +134,18 @@ fun PlaylistLayout( LazyColumn( state = listState, modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 200.dp) + contentPadding = PaddingValues(bottom = 200.dp), ) { items( items = actualItems, key = { it.key }, ) { item -> - MediaCard( + SongCardReverse( modifier = Modifier.animateItem(), - item = item.data, - onPlayItem = { PlayerAction.PlayById(item.data.mediaId).action() }, + horizontalArrangement = Arrangement.spacedBy(16.dp), + song = { item.data }, + isFavour = { favouriteIds.value.contains(item.data.mediaId) }, + onClick = { PlayerAction.PlayById(item.data.mediaId).action() }, onLongClick = { AppRouter.route("/pages/songs/detail") .with("mediaId", item.data.mediaId) @@ -161,55 +156,51 @@ fun PlaylistLayout( } } +@OptIn(UnstableApi::class) @Composable -fun MediaCard( +private fun SongCardReverse( modifier: Modifier = Modifier, - item: MediaItem, - onPlayItem: () -> Unit = {}, - onLongClick: () -> Unit = {} + dragModifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(20.dp), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + song: () -> MediaItem, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + isFavour: () -> Boolean, + hasLyric: () -> Boolean = { false }, + isPlaying: () -> Boolean = { false }, + isSelected: () -> Boolean = { false }, + showPrefix: () -> Boolean = { false }, + fixedHeight: () -> Boolean = { false }, + stickerContent: @Composable RowScope.() -> Unit = { + StickerRow( + isFavour = isFavour, + hasLyric = hasLyric, + extSticker = Sticker.ExtSticker(song().localConfiguration?.mimeType ?: "") + ) + }, + prefixContent: @Composable (Modifier) -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .combinedClickable( - onClick = { onPlayItem() }, - onLongClick = { onLongClick() } - ) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colors.background.copy(0.15f), - elevation = 2.dp, - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colors.onBackground.copy(0.1f) - ) - ) { - AsyncImage( - modifier = Modifier.size(64.dp), - contentScale = ContentScale.Crop, - model = item, - contentDescription = null - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = "${item.mediaMetadata.title}", - fontSize = 16.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.onBackground - ) - Text( - text = "${item.mediaMetadata.subtitle}", - fontSize = 10.sp, - lineHeight = 12.sp, - color = MaterialTheme.colors.onBackground.copy(0.7f) - ) - } - } -} + SongCard( + modifier = modifier, + dragModifier = dragModifier, + horizontalArrangement = horizontalArrangement, + interactionSource = interactionSource, + paddingValues = PaddingValues(16.dp), + title = { song().mediaMetadata.title.toString() }, + subTitle = { song().mediaMetadata.artist.toString() }, + duration = { song().mediaMetadata.durationMs ?: 0L }, + imageData = song, + onClick = onClick, + onLongClick = onLongClick, + onDoubleClick = null, + onEnterSelect = onLongClick, + isPlaying = isPlaying, + fixedHeight = fixedHeight, + reverseLayout = { true }, + isSelected = isSelected, + showPrefix = showPrefix, + stickerContent = stickerContent, + prefixContent = prefixContent + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt index d6e981a04..901aa4b87 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.sp import com.lalilu.RemixIcon import com.lalilu.component.LazyGridContent import com.lalilu.component.card.SongCard +import com.lalilu.component.state import com.lalilu.lmedia.entity.LSong import com.lalilu.remixicon.Arrows import com.lalilu.remixicon.Media @@ -41,6 +42,7 @@ class SearchSongsResult( @Composable override fun register(): LazyGridScope.() -> Unit { val collapsed = remember { mutableStateOf(false) } + val favouriteIds = state("favourite_ids", emptyList()) return fun LazyGridScope.() { if (songsResult().isNotEmpty()) { @@ -101,7 +103,8 @@ class SearchSongsResult( modifier = Modifier .fillMaxWidth() .animateItem(), - song = { it } + song = { it }, + isFavour = { favouriteIds.value.contains(it.id) } ) } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt index 41c9f07eb..953beaccd 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -38,6 +38,7 @@ import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.state import com.lalilu.lmedia.entity.FileInfo import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.entity.Metadata @@ -63,6 +64,7 @@ internal fun SongsScreenContent( val density = LocalDensity.current val listState: LazyListState = rememberLazyListState() val statusBar = WindowInsets.statusBars + val favouriteIds = state("favourite_ids", emptyList()) val scroller = rememberLazyListAnimateScroller( listState = listState, keys = keys @@ -168,6 +170,7 @@ internal fun SongsScreenContent( ) { SongCard( song = { it }, + isFavour = { favouriteIds.value.contains(it.id) }, isSelected = { isSelected(it) }, onClick = { if (isSelecting()) { diff --git a/common/src/main/java/com/lalilu/common/base/Sticker.kt b/common/src/main/java/com/lalilu/common/base/Sticker.kt index 885a28c54..88bf7bfba 100644 --- a/common/src/main/java/com/lalilu/common/base/Sticker.kt +++ b/common/src/main/java/com/lalilu/common/base/Sticker.kt @@ -1,7 +1,13 @@ package com.lalilu.common.base +import androidx.compose.runtime.Stable + +@Stable sealed class Sticker(val name: String) { + @Stable open class ExtSticker(ext: String) : Sticker(ext) + + @Stable open class SourceSticker(sourceType: SourceType) : Sticker(sourceType.name) data object HasLyricSticker : Sticker("LRC") data object HiresSticker : Sticker("HIRES") diff --git a/component/src/main/java/com/lalilu/component/SlotContent.kt b/component/src/main/java/com/lalilu/component/SlotContent.kt new file mode 100644 index 000000000..9b709853e --- /dev/null +++ b/component/src/main/java/com/lalilu/component/SlotContent.kt @@ -0,0 +1,121 @@ +package com.lalilu.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.koin.compose.currentKoinScope +import org.koin.core.parameter.ParametersDefinition +import org.koin.core.parameter.ParametersHolder +import org.koin.core.qualifier.Qualifier +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope + + +/** + * 需要注意Koin注入的时候不能定义默认值 + */ +fun interface SlotContent { + @Composable + fun Content(modifier: Modifier) +} + +@Composable +fun slot( + modifier: Modifier = Modifier, + key: String, + parameters: ParametersDefinition? = null, + elseContent: @Composable (modifier: Modifier) -> Unit = { + UnlinkSlot(modifier = it, key = key) + } +) { + val content = koinInjectOrNull( + qualifier = named(key), + parameters = parameters + ) + content?.Content(modifier) ?: elseContent(modifier) +} + +/** + * 需确保函数的调用顺序和参数顺序一致 + */ +class SlotParamContext { + private val array = mutableListOf() + fun value(value: T) = array.add(value) + fun values(vararg value: T) = value.forEach { array.add(it) } + fun funcT(func: (T) -> Unit) = array.add(func) + fun funcK(func: () -> T) = array.add(func) + fun funcTK(func: (T) -> K) = array.add(func) + fun build(): ParametersHolder = ParametersHolder(array) +} + +/** + * 构建自定义的参数注入 + */ +@Stable +fun slotParams(block: SlotParamContext.() -> Unit): () -> ParametersHolder = { + SlotParamContext().apply(block).build() +} + +@Composable +private inline fun koinInjectOrNull( + qualifier: Qualifier? = null, + scope: Scope = currentKoinScope(), + noinline parameters: ParametersDefinition? = null, +): T? { + val params: ParametersHolder? = parameters?.invoke() + return remember(qualifier, scope, params) { + runCatching { + scope.getOrNull(T::class, qualifier, parameters) + }.getOrElse { + it.printStackTrace() + null + } + } +} + +@Composable +private fun UnlinkSlot( + modifier: Modifier = Modifier, + key: String +) { + Box( + modifier = modifier + ) { + Card( + modifier = Modifier.padding(16.dp), + shape = RoundedCornerShape(8.dp), + elevation = 0.dp, + backgroundColor = MaterialTheme.colors.onBackground.copy(0.1f) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier, + text = "Unsupported content, Please upgrade to latest version.", + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + Text( + modifier = Modifier, + text = "UnlinkSlot: $key", + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onBackground.copy(0.7f) + ) + } + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/SlotState.kt b/component/src/main/java/com/lalilu/component/SlotState.kt new file mode 100644 index 000000000..8440de117 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/SlotState.kt @@ -0,0 +1,37 @@ +package com.lalilu.component + + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import org.koin.compose.currentKoinScope +import org.koin.core.qualifier.Qualifier +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope + +fun interface SlotState { + @Composable + fun state(): State +} + +@Composable +fun state(key: String, defaultValue: T): State { + val state = koinInjectOrNull>(qualifier = named(key)) + return state?.state() ?: remember { mutableStateOf(defaultValue) } +} + +@Composable +private inline fun koinInjectOrNull( + qualifier: Qualifier? = null, + scope: Scope = currentKoinScope(), +): T? { + return remember(qualifier, scope) { + runCatching { + scope.getOrNull(T::class, qualifier) + }.getOrElse { + it.printStackTrace() + null + } + } +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/card/SongCard.kt b/component/src/main/java/com/lalilu/component/card/SongCard.kt index 2f99c0cc6..62727ebdf 100644 --- a/component/src/main/java/com/lalilu/component/card/SongCard.kt +++ b/component/src/main/java/com/lalilu/component/card/SongCard.kt @@ -6,13 +6,13 @@ import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally -import androidx.compose.foundation.Image import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.aspectRatio @@ -36,7 +36,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -49,27 +48,34 @@ import coil3.request.error import coil3.request.placeholder import com.lalilu.common.base.Sticker import com.lalilu.component.R -import com.lalilu.component.extension.dayNightTextColor import com.lalilu.component.extension.durationMsToString -import com.lalilu.component.extension.mimeTypeToIcon -import com.lalilu.component.extension.toColorFilter import com.lalilu.lmedia.entity.LSong @Composable fun SongCard( modifier: Modifier = Modifier, dragModifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(20.dp), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, song: () -> LSong, onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, onEnterSelect: () -> Unit = {}, + isFavour: () -> Boolean, hasLyric: () -> Boolean = { false }, isPlaying: () -> Boolean = { false }, isSelected: () -> Boolean = { false }, showPrefix: () -> Boolean = { false }, fixedHeight: () -> Boolean = { false }, + reverseLayout: () -> Boolean = { false }, + stickerContent: @Composable RowScope.() -> Unit = { + StickerRow( + isFavour = isFavour, + hasLyric = hasLyric, + extSticker = Sticker.ExtSticker(song().fileInfo.mimeType) + ) + }, prefixContent: @Composable (Modifier) -> Unit = {} ) { val item = remember { song() } @@ -77,17 +83,11 @@ fun SongCard( SongCard( modifier = modifier, dragModifier = dragModifier, + horizontalArrangement = horizontalArrangement, interactionSource = interactionSource, title = { item.metadata.title }, subTitle = { item.metadata.artist }, duration = { item.metadata.duration }, - sticker = { - listOfNotNull( - Sticker.ExtSticker(item.fileInfo.mimeType), - if (hasLyric()) Sticker.HasLyricSticker else null, - Sticker.SourceSticker(item.sourceType) - ) - }, imageData = { item }, onClick = onClick, onLongClick = onLongClick, @@ -95,22 +95,25 @@ fun SongCard( onEnterSelect = onEnterSelect, isPlaying = isPlaying, fixedHeight = fixedHeight, + reverseLayout = reverseLayout, isSelected = isSelected, showPrefix = showPrefix, + stickerContent = stickerContent, prefixContent = prefixContent ) } @Composable -internal fun SongCard( +fun SongCard( modifier: Modifier = Modifier, dragModifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(20.dp), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + paddingValues: PaddingValues = PaddingValues(vertical = 8.dp, horizontal = 16.dp), title: () -> String, subTitle: () -> String, duration: () -> Long, - sticker: () -> List, imageData: () -> Any?, onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, @@ -118,12 +121,15 @@ internal fun SongCard( onEnterSelect: () -> Unit = {}, isPlaying: () -> Boolean = { false }, fixedHeight: () -> Boolean = { false }, + reverseLayout: () -> Boolean = { false }, isSelected: () -> Boolean = { false }, showPrefix: () -> Boolean = { false }, + stickerContent: @Composable RowScope.() -> Unit = {}, prefixContent: @Composable (Modifier) -> Unit = {} ) { val bgColor by animateColorAsState( - targetValue = if (isSelected()) dayNightTextColor(0.15f) else Color.Transparent, + targetValue = if (isSelected()) MaterialTheme.colors.onBackground.copy(0.15f) + else Color.Transparent, label = "" ) @@ -140,10 +146,20 @@ internal fun SongCard( onLongClick = onLongClick, onDoubleClick = onDoubleClick ) - .padding(vertical = 8.dp, horizontal = 15.dp), + .padding(paddingValues), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.dp) + horizontalArrangement = horizontalArrangement ) { + if (reverseLayout()) { + SongCardImage( + modifier = dragModifier, + imageData = imageData, + interaction = interactionSource, + onClick = onClick, + onLongClick = onEnterSelect + ) + } + SongCardContent( modifier = Modifier.weight(1f), title = title, @@ -153,46 +169,18 @@ internal fun SongCard( showPrefix = showPrefix, fixedHeight = fixedHeight, prefixContent = prefixContent, - stickerContent = { - val stickers = remember { sticker() } - - stickers.firstOrNull { it is Sticker.HasLyricSticker }?.let { - HasLyricIcon( - hasLyric = { true }, - fixedHeight = fixedHeight - ) - } - - stickers.firstOrNull { it is Sticker.HiresSticker }?.let { - Image( - painter = painterResource(id = R.drawable.ic_ape_line), - contentDescription = "Hires Icon", - colorFilter = Color(0xFFFFC107).copy(0.9f).toColorFilter(), - modifier = Modifier - .size(20.dp) - .aspectRatio(1f) - ) - } - - stickers.firstOrNull { it is Sticker.ExtSticker }?.let { - Image( - painter = painterResource(id = mimeTypeToIcon(mimeType = it.name)), - contentDescription = "MediaType Icon", - colorFilter = MaterialTheme.colors.onBackground.copy(0.9f).toColorFilter(), - modifier = Modifier - .size(20.dp) - .aspectRatio(1f) - ) - } - } - ) - SongCardImage( - modifier = dragModifier, - imageData = imageData, - interaction = interactionSource, - onClick = onClick, - onLongClick = onEnterSelect + stickerContent = stickerContent ) + + if (!reverseLayout()) { + SongCardImage( + modifier = dragModifier, + imageData = imageData, + interaction = interactionSource, + onClick = onClick, + onLongClick = onEnterSelect + ) + } } } @@ -222,7 +210,7 @@ fun SongCardContent( text = title(), maxLines = if (fixedHeight()) 1 else Int.MAX_VALUE, overflow = TextOverflow.Ellipsis, - color = dayNightTextColor(), + color = MaterialTheme.colors.onBackground, style = MaterialTheme.typography.subtitle1 ) stickerContent() @@ -234,7 +222,7 @@ fun SongCardContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { -// PlayingTipIcon(isPlaying = isPlaying) + PlayingTipIcon(isPlaying = isPlaying) AnimatedVisibility( visible = showPrefix(), modifier = Modifier.wrapContentWidth(), @@ -243,7 +231,7 @@ fun SongCardContent( ) { ProvideTextStyle( value = MaterialTheme.typography.caption - .copy(color = dayNightTextColor(0.5f)), + .copy(color = MaterialTheme.colors.onBackground.copy(0.5f)), ) { prefixContent(Modifier.padding(end = 5.dp)) } @@ -253,7 +241,7 @@ fun SongCardContent( text = subTitle(), maxLines = 1, overflow = TextOverflow.Ellipsis, - color = dayNightTextColor(0.5f), + color = MaterialTheme.colors.onBackground.copy(0.5f), style = MaterialTheme.typography.caption, ) Text( @@ -261,7 +249,7 @@ fun SongCardContent( text = durationMsToString(duration = duration()), fontSize = 12.sp, letterSpacing = 0.05.em, - color = dayNightTextColor(0.7f) + color = MaterialTheme.colors.onBackground.copy(0.7f) ) } } @@ -312,7 +300,6 @@ private fun SongCardPreview() { title = { "歌いましょう鳴らしましょう" }, subTitle = { "MyGO!!!!!" }, duration = { 189999L }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) } @@ -325,28 +312,24 @@ private fun SongCardPreviewMulti() { title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) SongCard( title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) SongCard( title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) SongCard( title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) } diff --git a/component/src/main/java/com/lalilu/component/card/StickerRow.kt b/component/src/main/java/com/lalilu/component/card/StickerRow.kt new file mode 100644 index 000000000..b91fa7bd1 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/card/StickerRow.kt @@ -0,0 +1,67 @@ +package com.lalilu.component.card + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.lalilu.RemixIcon +import com.lalilu.common.base.Sticker +import com.lalilu.component.R +import com.lalilu.component.extension.mimeTypeToIcon +import com.lalilu.component.extension.toColorFilter +import com.lalilu.remixicon.HealthAndMedical +import com.lalilu.remixicon.healthandmedical.heart3Fill + +@Composable +fun StickerRow( + isFavour: () -> Boolean = { false }, + hasLyric: () -> Boolean = { false }, + isHires: () -> Boolean = { false }, + extSticker: Sticker.ExtSticker? = null +) { + if (isFavour()) { + Icon( + imageVector = RemixIcon.HealthAndMedical.heart3Fill, + contentDescription = "Heart Icon", + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(20.dp) + .aspectRatio(1f) + ) + } + + if (hasLyric()) { + HasLyricIcon( + hasLyric = { true }, + fixedHeight = { true } + ) + } + + if (isHires()) { + Image( + painter = painterResource(id = R.drawable.ic_ape_line), + contentDescription = "Hires Icon", + colorFilter = Color(0xFFFFC107).copy(0.9f).toColorFilter(), + modifier = Modifier + .size(20.dp) + .aspectRatio(1f) + ) + } + + extSticker?.let { + Image( + painter = painterResource(id = mimeTypeToIcon(mimeType = it.name)), + contentDescription = "MediaType Icon", + colorFilter = MaterialTheme.colors.onBackground.copy(0.9f).toColorFilter(), + modifier = Modifier + .size(20.dp) + .aspectRatio(1f) + ) + } +} \ No newline at end of file diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt index b94f7916c..8000618c6 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt @@ -40,6 +40,7 @@ import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.state import com.lalilu.lalbum.viewModel.AlbumDetailEvent import com.lalilu.lmedia.entity.LAlbum import com.lalilu.lmedia.entity.LSong @@ -64,6 +65,7 @@ fun AlbumDetailScreenContent( val listState = rememberLazyListState() val statusBar = WindowInsets.statusBars val density = LocalDensity.current + val favouriteIds = state("favourite_ids", emptyList()) val stickyHeaderContentType = remember { "group" } val scroller = rememberLazyListAnimateScroller( listState = listState, @@ -182,6 +184,7 @@ fun AlbumDetailScreenContent( SongCard( song = { it }, isSelected = { isSelected(it) }, + isFavour = { favouriteIds.value.contains(it.id) }, onClick = { if (isSelecting()) { onSelect(it) diff --git a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt index ad5193081..cf80d200f 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt @@ -35,6 +35,7 @@ import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter import com.lalilu.component.navigation.NavIntent +import com.lalilu.component.state import com.lalilu.lartist.component.ArtistCard import com.lalilu.lartist.viewModel.ArtistDetailEvent import com.lalilu.lmedia.entity.LArtist @@ -62,6 +63,7 @@ internal fun ArtistDetailScreenContent( val statusBar = WindowInsets.statusBars val density = LocalDensity.current val stickyHeaderContentType = remember { "group" } + val favouriteIds = state("favourite_ids", emptyList()) val scroller = rememberLazyListAnimateScroller( listState = listState, keys = keys @@ -174,6 +176,7 @@ internal fun ArtistDetailScreenContent( SongCard( song = { it }, isSelected = { isSelected(it) }, + isFavour = { favouriteIds.value.contains(it.id) }, onClick = { if (isSelecting()) { onSelect(it) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt index 3bc45561e..3f94583cb 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt @@ -12,9 +12,13 @@ import androidx.compose.ui.unit.dp import com.lalilu.component.LazyGridContent import com.lalilu.component.base.LocalWindowSize import com.lalilu.component.card.SongCard +import com.lalilu.component.extension.DynamicTipsHost +import com.lalilu.component.extension.DynamicTipsItem import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.state import com.lalilu.lhistory.viewmodel.HistoryVM import com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.action.MediaControl import org.koin.compose.koinInject import org.koin.core.annotation.Named import org.koin.core.annotation.Single @@ -29,6 +33,7 @@ class HistoryPanel : LazyGridContent { val historyVM = koinInject() val widthSizeClass = LocalWindowSize.current.widthSizeClass val items by historyVM.historyState + val favouriteIds = state("favourite_ids", emptyList()) return fun LazyGridScope.() { // 若列表为空,则不显示 @@ -48,9 +53,23 @@ class HistoryPanel : LazyGridContent { .animateItem() .padding(bottom = 5.dp), song = { item }, + isFavour = { favouriteIds.value.contains(item.id) }, isPlaying = { MPlayer.isItemPlaying(item.id) }, onClick = { + historyVM.getHistoryPlayedIds { list -> + MediaControl.playWithList( + mediaIds = list, + mediaId = item.id + ) + DynamicTipsHost.show( + DynamicTipsItem.Static( + title = "历史播放", + imageData = item, + subTitle = "播放历史列表" + ) + ) + } }, onLongClick = { AppRouter.route("/pages/songs/detail") diff --git a/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt index a26855f7e..5c69caad9 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt @@ -10,8 +10,10 @@ import com.lalilu.lhistory.repository.HistoryRepository import com.lalilu.lmedia.LMedia import com.lalilu.lmedia.entity.LSong import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.koin.core.annotation.Single @OptIn(ExperimentalCoroutinesApi::class) @@ -39,4 +41,13 @@ class HistoryVM( } ).flow.cachedIn(viewModelScope) + fun getHistoryPlayedIds(block: (list: List) -> Unit) = viewModelScope.launch { + val list = historyRepo.getHistoriesIdsMapWithLastTime() + .firstOrNull() + ?.toList() + ?.sortedByDescending { it.second } + ?.map { it.first } + ?: emptyList() + block(list) + } } \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt index e9c96d256..455436720 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt @@ -1,8 +1,23 @@ package com.lalilu.lplaylist +import androidx.compose.runtime.collectAsState +import com.lalilu.component.SlotState +import com.lalilu.lplaylist.repository.PlaylistRepository import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single @Module @ComponentScan("com.lalilu.lplaylist") -object PlaylistModule \ No newline at end of file +object PlaylistModule + + +@Single +@Named("favourite_ids") +fun provideFavouriteIds( + playlistRepo: PlaylistRepository, +) = SlotState { + playlistRepo.getFavouriteMediaIds() + .collectAsState(emptyList()) +} \ No newline at end of file diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt index 16067b6c7..5710dd973 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt @@ -39,6 +39,7 @@ import com.lalilu.component.extension.ItemRecorder import com.lalilu.component.extension.rememberLazyListAnimateScroller import com.lalilu.component.extension.startRecord import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.state import com.lalilu.lmedia.entity.LSong import com.lalilu.lmedia.extension.GroupIdentity import com.lalilu.lplayer.action.MediaControl @@ -70,6 +71,7 @@ internal fun PlaylistDetailScreenContent( val statusBar = WindowInsets.statusBars val listState: LazyListState = rememberLazyListState() val stickyHeaderContentType = remember { "group" } + val favouriteIds = state("favourite_ids", emptyList()) val scroller = rememberLazyListAnimateScroller( listState = listState, keys = keys @@ -200,6 +202,7 @@ internal fun PlaylistDetailScreenContent( ), song = { item }, isSelected = { isSelected(item) }, + isFavour = { favouriteIds.value.contains(item.id) }, onClick = { if (isSelecting()) { onSelect(item) @@ -249,6 +252,7 @@ internal fun PlaylistDetailScreenContent( modifier = Modifier.animateItem(), song = { item }, isSelected = { isSelected(item) }, + isFavour = { favouriteIds.value.contains(item.id) }, onClick = { if (isSelecting()) { onSelect(item) From 086853b355cae29a5a58ccc4823b25262a617bc4 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 2 Mar 2025 23:17:01 +0800 Subject: [PATCH 191/213] =?UTF-8?q?refactor(playlist):=20=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=92=AD=E6=94=BE=E5=88=97=E8=A1=A8=E5=AA=92=E4=BD=93?= =?UTF-8?q?=20ID=20=E6=B7=BB=E5=8A=A0=E9=80=BB=E8=BE=91-=20=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E4=BA=86=20addMediaIdsToPlaylist=20=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=AA=92=E4=BD=93=20ID=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=80=BB=E8=BE=91=20-=20=E5=B0=86=E5=8E=9F=E6=9C=89=E5=AA=92?= =?UTF-8?q?=E4=BD=93=20ID=20=E5=88=97=E8=A1=A8=E4=B8=8E=E6=96=B0=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=9A=84=E5=AA=92=E4=BD=93=20ID=20=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=9A=84=E4=BD=8D=E7=BD=AE=E5=AF=B9=E8=B0=83=20-=20=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E8=BF=99=E7=A7=8D=E6=96=B9=E5=BC=8F=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E6=96=B0=E6=B7=BB=E5=8A=A0=E7=9A=84=E5=AA=92=E4=BD=93?= =?UTF-8?q?=20ID=20=E4=BC=98=E5=85=88=E6=98=BE=E7=A4=BA=E5=9C=A8=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=88=97=E8=A1=A8=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt index 1d4de80a0..138f7bfc3 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt @@ -92,7 +92,7 @@ internal class PlaylistRepositoryImpl( } override fun addMediaIdsToPlaylist(mediaIds: List, playlistId: String) { - updatePlaylist(playlistId) { it.copy(mediaIds = it.mediaIds.plus(mediaIds).distinct()) } + updatePlaylist(playlistId) { it.copy(mediaIds = mediaIds.plus(it.mediaIds).distinct()) } } override fun addMediaIdsToPlaylists(mediaIds: List, playlistIds: List) { From 81c2064f3a78af6b3856d5ba73cc6cbab625c1cf Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 2 Mar 2025 23:17:35 +0800 Subject: [PATCH 192/213] =?UTF-8?q?feat(service):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=9C=80=E5=A4=A7=E5=9B=9E=E9=80=80=E6=97=B6=E9=97=B4=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E9=87=8D=E5=A4=8D=E7=82=B9=E5=87=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 MService 中设置了最大回退时间,以避免播放上一首歌曲时需要点击两次 - 使用了 Long.MAX_VALUE 作为回退时间,确保用户在回退到上一首歌曲时不会遇到延迟 --- lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index e714ffd5c..b1dbd3c32 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -72,6 +72,7 @@ class MService : MediaLibraryService(), CoroutineScope { .setRenderersFactory(FadeTransitionRenderersFactory(this, this)) .setHandleAudioBecomingNoisy(MPlayerKV.handleBecomeNoisy.value != false) .setAudioAttributes(defaultAudioAttributes, MPlayerKV.handleAudioFocus.value != false) + .setMaxSeekToPreviousPositionMs(Long.MAX_VALUE) // 避免播放上一首需要点两次 .build() .apply { addAnalyticsListener(historyAnalyticsListener) } .setUpQueueControl() From aa5f25be0bcabb26f5c8fae39eb6bc6fd074b6bc Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Wed, 19 Mar 2025 09:17:40 +0800 Subject: [PATCH 193/213] =?UTF-8?q?feat(component):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20Lumo=20=E7=BB=84=E4=BB=B6=E5=BA=93=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 build.gradle.kts 中添加 Lumo 插件依赖 - 新增 Color、Typography、Ripple 等基础组件和配置文件 - 创建 LMusicTheme 主题,包括暗黑模式支持 - 更新项目配置,启用 Compose 编译器插件 --- build.gradle.kts | 1 + .../java/com/lalilu/component/lumo/Color.kt | 158 +++++++++++++ .../java/com/lalilu/component/lumo/Theme.kt | 61 +++++ .../com/lalilu/component/lumo/Typography.kt | 139 +++++++++++ .../component/lumo/foundation/Elevation.kt | 74 ++++++ .../component/lumo/foundation/Providers.kt | 28 +++ .../component/lumo/foundation/Ripple.kt | 216 ++++++++++++++++++ gradle/libs.versions.toml | 11 +- lumo.properties | 8 + 9 files changed, 691 insertions(+), 5 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/lumo/Color.kt create mode 100644 component/src/main/java/com/lalilu/component/lumo/Theme.kt create mode 100644 component/src/main/java/com/lalilu/component/lumo/Typography.kt create mode 100644 component/src/main/java/com/lalilu/component/lumo/foundation/Elevation.kt create mode 100644 component/src/main/java/com/lalilu/component/lumo/foundation/Providers.kt create mode 100644 component/src/main/java/com/lalilu/component/lumo/foundation/Ripple.kt create mode 100644 lumo.properties diff --git a/build.gradle.kts b/build.gradle.kts index c5f8f20b2..7e136ccd7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.flyjingfish.aop) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.krouter.plugin) + alias(libs.plugins.lumo) } // 配置注入遍历的起点项目 diff --git a/component/src/main/java/com/lalilu/component/lumo/Color.kt b/component/src/main/java/com/lalilu/component/lumo/Color.kt new file mode 100644 index 000000000..918fb5c20 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/lumo/Color.kt @@ -0,0 +1,158 @@ +package com.lalilu.component.lumo + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +val Black: Color = Color(0xFF000000) +val Gray900: Color = Color(0xFF282828) +val Gray800: Color = Color(0xFF4b4b4b) +val Gray700: Color = Color(0xFF5e5e5e) +val Gray600: Color = Color(0xFF727272) +val Gray500: Color = Color(0xFF868686) +val Gray400: Color = Color(0xFFC7C7C7) +val Gray300: Color = Color(0xFFDFDFDF) +val Gray200: Color = Color(0xFFE2E2E2) +val Gray100: Color = Color(0xFFF7F7F7) +val Gray50: Color = Color(0xFFFFFFFF) +val White: Color = Color(0xFFFFFFFF) + +val Red900: Color = Color(0xFF520810) +val Red800: Color = Color(0xFF950f22) +val Red700: Color = Color(0xFFbb032a) +val Red600: Color = Color(0xFFde1135) +val Red500: Color = Color(0xFFf83446) +val Red400: Color = Color(0xFFfc7f79) +val Red300: Color = Color(0xFFffb2ab) +val Red200: Color = Color(0xFFffd2cd) +val Red100: Color = Color(0xFFffe1de) +val Red50: Color = Color(0xFFfff0ee) + +val Blue900: Color = Color(0xFF276EF1) +val Blue800: Color = Color(0xFF3F7EF2) +val Blue700: Color = Color(0xFF578EF4) +val Blue600: Color = Color(0xFF6F9EF5) +val Blue500: Color = Color(0xFF87AEF7) +val Blue400: Color = Color(0xFF9FBFF8) +val Blue300: Color = Color(0xFFB7CEFA) +val Blue200: Color = Color(0xFFCFDEFB) +val Blue100: Color = Color(0xFFE7EEFD) +val Blue50: Color = Color(0xFFFFFFFF) + +val Green950: Color = Color(0xFF0B4627) +val Green900: Color = Color(0xFF16643B) +val Green800: Color = Color(0xFF1A7544) +val Green700: Color = Color(0xFF178C4E) +val Green600: Color = Color(0xFF1DAF61) +val Green500: Color = Color(0xFF1FC16B) +val Green400: Color = Color(0xFF3EE089) +val Green300: Color = Color(0xFF84EBB4) +val Green200: Color = Color(0xFFC2F5DA) +val Green100: Color = Color(0xFFD0FBE9) +val Green50: Color = Color(0xFFE0FAEC) + +@Immutable +data class Colors( + val primary: Color, + val onPrimary: Color, + val secondary: Color, + val onSecondary: Color, + val tertiary: Color, + val onTertiary: Color, + val error: Color, + val onError: Color, + val success: Color, + val onSuccess: Color, + val disabled: Color, + val onDisabled: Color, + val surface: Color, + val onSurface: Color, + val background: Color, + val onBackground: Color, + val outline: Color, + val transparent: Color = Color.Transparent, + val white: Color = White, + val black: Color = Black, + val text: Color, + val textSecondary: Color, + val textDisabled: Color, + val scrim: Color, + val elevation: Color, +) + +internal val LightColors = + Colors( + primary = Black, + onPrimary = White, + secondary = Gray400, + onSecondary = Black, + tertiary = Blue900, + onTertiary = White, + surface = Gray200, + onSurface = Black, + error = Red600, + onError = White, + success = Green600, + onSuccess = White, + disabled = Gray100, + onDisabled = Gray500, + background = White, + onBackground = Black, + outline = Gray300, + transparent = Color.Transparent, + white = White, + black = Black, + text = Black, + textSecondary = Gray700, + textDisabled = Gray400, + scrim = Color.Black.copy(alpha = 0.32f), + elevation = Gray700, + ) + +internal val DarkColors = + Colors( + primary = White, + onPrimary = Black, + secondary = Gray400, + onSecondary = White, + tertiary = Blue300, + onTertiary = Black, + surface = Gray900, + onSurface = White, + error = Red400, + onError = Black, + success = Green700, + onSuccess = Black, + disabled = Gray700, + onDisabled = Gray500, + background = Black, + onBackground = White, + outline = Gray800, + transparent = Color.Transparent, + white = White, + black = Black, + text = White, + textSecondary = Gray300, + textDisabled = Gray600, + scrim = Color.Black.copy(alpha = 0.72f), + elevation = Gray200, + ) + +val LocalColors = staticCompositionLocalOf { LightColors } +val LocalContentColor = compositionLocalOf { Color.Black } +val LocalContentAlpha = compositionLocalOf { 1f } + +fun Colors.contentColorFor(backgroundColor: Color): Color { + return when (backgroundColor) { + primary -> onPrimary + secondary -> onSecondary + tertiary -> onTertiary + surface -> onSurface + error -> onError + success -> onSuccess + disabled -> onDisabled + background -> onBackground + else -> Color.Unspecified + } +} diff --git a/component/src/main/java/com/lalilu/component/lumo/Theme.kt b/component/src/main/java/com/lalilu/component/lumo/Theme.kt new file mode 100644 index 000000000..3f2c2e591 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/lumo/Theme.kt @@ -0,0 +1,61 @@ +package com.lalilu.component.lumo + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.lalilu.component.lumo.foundation.ripple + +object LMusicTheme { + val colors: Colors + @ReadOnlyComposable @Composable + get() = LocalColors.current + + val typography: Typography + @ReadOnlyComposable @Composable + get() = LocalTypography.current +} + +@Composable +fun LMusicTheme( + isDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val rippleIndication = ripple() + val selectionColors = rememberTextSelectionColors(LightColors) + val typography = provideTypography() + val colors = if (isDarkTheme) DarkColors else LightColors + + CompositionLocalProvider( + LocalColors provides colors, + LocalTypography provides typography, + LocalIndication provides rippleIndication, + LocalTextSelectionColors provides selectionColors, + LocalContentColor provides colors.contentColorFor(colors.background), + LocalTextStyle provides typography.body1, + content = content, + ) +} + +@Composable +fun contentColorFor(color: Color): Color { + return LMusicTheme.colors.contentColorFor(color) +} + +@Composable +internal fun rememberTextSelectionColors(colorScheme: Colors): TextSelectionColors { + val primaryColor = colorScheme.primary + return remember(primaryColor) { + TextSelectionColors( + handleColor = primaryColor, + backgroundColor = primaryColor.copy(alpha = TextSelectionBackgroundOpacity), + ) + } +} + +internal const val TextSelectionBackgroundOpacity = 0.4f diff --git a/component/src/main/java/com/lalilu/component/lumo/Typography.kt b/component/src/main/java/com/lalilu/component/lumo/Typography.kt new file mode 100644 index 000000000..75a6ec155 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/lumo/Typography.kt @@ -0,0 +1,139 @@ +package com.lalilu.component.lumo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +@Composable +fun fontFamily() = FontFamily.Default + +data class Typography( + val h1: TextStyle, + val h2: TextStyle, + val h3: TextStyle, + val h4: TextStyle, + val body1: TextStyle, + val body2: TextStyle, + val body3: TextStyle, + val label1: TextStyle, + val label2: TextStyle, + val label3: TextStyle, + val button: TextStyle, + val input: TextStyle, +) + +private val defaultTypography = + Typography( + h1 = + TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + h2 = + TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + h3 = + TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.sp, + ), + h4 = + TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.sp, + ), + body1 = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.sp, + ), + body2 = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.15.sp, + ), + body3 = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.15.sp, + ), + label1 = + TextStyle( + fontWeight = FontWeight.W500, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + label2 = + TextStyle( + fontWeight = FontWeight.W500, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + label3 = + TextStyle( + fontWeight = FontWeight.W500, + fontSize = 10.sp, + lineHeight = 12.sp, + letterSpacing = 0.5.sp, + ), + button = + TextStyle( + fontWeight = FontWeight.W500, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 1.sp, + ), + input = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.sp, + ), + ) + +@Composable +fun provideTypography(): Typography { + val fontFamily = fontFamily() + + return defaultTypography.copy( + h1 = defaultTypography.h1.copy(fontFamily = fontFamily), + h2 = defaultTypography.h2.copy(fontFamily = fontFamily), + h3 = defaultTypography.h3.copy(fontFamily = fontFamily), + h4 = defaultTypography.h4.copy(fontFamily = fontFamily), + body1 = defaultTypography.body1.copy(fontFamily = fontFamily), + body2 = defaultTypography.body2.copy(fontFamily = fontFamily), + body3 = defaultTypography.body3.copy(fontFamily = fontFamily), + label1 = defaultTypography.label1.copy(fontFamily = fontFamily), + label2 = defaultTypography.label2.copy(fontFamily = fontFamily), + label3 = defaultTypography.label3.copy(fontFamily = fontFamily), + button = defaultTypography.button.copy(fontFamily = fontFamily), + input = defaultTypography.input.copy(fontFamily = fontFamily), + ) +} + +val LocalTypography = staticCompositionLocalOf { defaultTypography } +val LocalTextStyle = compositionLocalOf(structuralEqualityPolicy()) { TextStyle.Default } diff --git a/component/src/main/java/com/lalilu/component/lumo/foundation/Elevation.kt b/component/src/main/java/com/lalilu/component/lumo/foundation/Elevation.kt new file mode 100644 index 000000000..8b9cd916a --- /dev/null +++ b/component/src/main/java/com/lalilu/component/lumo/foundation/Elevation.kt @@ -0,0 +1,74 @@ +package com.lalilu.component.lumo.foundation + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.HoverInteraction +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.ui.unit.Dp + +internal suspend fun Animatable.animateElevation( + target: Dp, + from: Interaction? = null, + to: Interaction? = null, +) { + val spec = + when { + // Moving to a new state + to != null -> ElevationDefaults.incomingAnimationSpecForInteraction(to) + // Moving to default, from a previous state + from != null -> ElevationDefaults.outgoingAnimationSpecForInteraction(from) + // Loading the initial state, or moving back to the baseline state from a disabled / + // unknown state, so just snap to the final value. + else -> null + } + if (spec != null) animateTo(target, spec) else snapTo(target) +} + +private object ElevationDefaults { + fun incomingAnimationSpecForInteraction(interaction: Interaction): AnimationSpec? { + return when (interaction) { + is PressInteraction.Press -> DefaultIncomingSpec + is DragInteraction.Start -> DefaultIncomingSpec + is HoverInteraction.Enter -> DefaultIncomingSpec + is FocusInteraction.Focus -> DefaultIncomingSpec + else -> null + } + } + + fun outgoingAnimationSpecForInteraction(interaction: Interaction): AnimationSpec? { + return when (interaction) { + is PressInteraction.Press -> DefaultOutgoingSpec + is DragInteraction.Start -> DefaultOutgoingSpec + is HoverInteraction.Enter -> HoveredOutgoingSpec + is FocusInteraction.Focus -> DefaultOutgoingSpec + else -> null + } + } +} + +private val OutgoingSpecEasing: Easing = CubicBezierEasing(0.40f, 0.00f, 0.60f, 1.00f) + +private val DefaultIncomingSpec = + TweenSpec( + durationMillis = 120, + easing = FastOutSlowInEasing, + ) + +private val DefaultOutgoingSpec = + TweenSpec( + durationMillis = 150, + easing = OutgoingSpecEasing, + ) + +private val HoveredOutgoingSpec = + TweenSpec( + durationMillis = 120, + easing = OutgoingSpecEasing, + ) diff --git a/component/src/main/java/com/lalilu/component/lumo/foundation/Providers.kt b/component/src/main/java/com/lalilu/component/lumo/foundation/Providers.kt new file mode 100644 index 000000000..7f60cebcd --- /dev/null +++ b/component/src/main/java/com/lalilu/component/lumo/foundation/Providers.kt @@ -0,0 +1,28 @@ +package com.lalilu.component.lumo.foundation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import com.lalilu.component.lumo.LocalContentColor +import com.lalilu.component.lumo.LocalTextStyle + +@Composable +fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) { + val mergedStyle = LocalTextStyle.current.merge(value) + CompositionLocalProvider(LocalTextStyle provides mergedStyle, content = content) +} + +@Composable +internal fun ProvideContentColorTextStyle( + contentColor: Color, + textStyle: TextStyle, + content: @Composable () -> Unit, +) { + val mergedStyle = LocalTextStyle.current.merge(textStyle) + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalTextStyle provides mergedStyle, + content = content, + ) +} \ No newline at end of file diff --git a/component/src/main/java/com/lalilu/component/lumo/foundation/Ripple.kt b/component/src/main/java/com/lalilu/component/lumo/foundation/Ripple.kt new file mode 100644 index 000000000..9f1127fd1 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/lumo/foundation/Ripple.kt @@ -0,0 +1,216 @@ +package com.lalilu.component.lumo.foundation + +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material.ripple.createRippleModifierNode +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.observeReads +import androidx.compose.ui.unit.Dp +import com.lalilu.component.lumo.LocalContentColor + +@Stable +fun ripple( + bounded: Boolean = true, + radius: Dp = Dp.Unspecified, + color: Color = Color.Unspecified, +): IndicationNodeFactory { + return if (radius == Dp.Unspecified && color == Color.Unspecified) { + if (bounded) return DefaultBoundedRipple else DefaultUnboundedRipple + } else { + RippleNodeFactory(bounded, radius, color) + } +} + +@Stable +fun ripple( + color: ColorProducer, + bounded: Boolean = true, + radius: Dp = Dp.Unspecified, +): IndicationNodeFactory { + return RippleNodeFactory(bounded, radius, color) +} + +/** Default values used by [ripple]. */ +object RippleDefaults { + /** + * Represents the default [RippleAlpha] that will be used for a ripple to indicate different + * states. + */ + val RippleAlpha: RippleAlpha = + RippleAlpha( + pressedAlpha = StateTokens.PressedStateLayerOpacity, + focusedAlpha = StateTokens.FocusStateLayerOpacity, + draggedAlpha = StateTokens.DraggedStateLayerOpacity, + hoveredAlpha = StateTokens.HoverStateLayerOpacity, + ) +} + +val LocalRippleConfiguration: ProvidableCompositionLocal = + compositionLocalOf { + RippleConfiguration() + } + +@Immutable +class RippleConfiguration( + val color: Color = Color.Unspecified, + val rippleAlpha: RippleAlpha? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RippleConfiguration) return false + + if (color != other.color) return false + if (rippleAlpha != other.rippleAlpha) return false + + return true + } + + override fun hashCode(): Int { + var result = color.hashCode() + result = 31 * result + (rippleAlpha?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "RippleConfiguration(color=$color, rippleAlpha=$rippleAlpha)" + } +} + +@Stable +private class RippleNodeFactory + private constructor( + private val bounded: Boolean, + private val radius: Dp, + private val colorProducer: ColorProducer?, + private val color: Color, + ) : IndicationNodeFactory { + constructor( + bounded: Boolean, + radius: Dp, + colorProducer: ColorProducer, + ) : this(bounded, radius, colorProducer, Color.Unspecified) + + constructor(bounded: Boolean, radius: Dp, color: Color) : this(bounded, radius, null, color) + + override fun create(interactionSource: InteractionSource): DelegatableNode { + val colorProducer = colorProducer ?: ColorProducer { color } + return DelegatingThemeAwareRippleNode(interactionSource, bounded, radius, colorProducer) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RippleNodeFactory) return false + + if (bounded != other.bounded) return false + if (radius != other.radius) return false + if (colorProducer != other.colorProducer) return false + return color == other.color + } + + override fun hashCode(): Int { + var result = bounded.hashCode() + result = 31 * result + radius.hashCode() + result = 31 * result + colorProducer.hashCode() + result = 31 * result + color.hashCode() + return result + } + } + +private class DelegatingThemeAwareRippleNode( + private val interactionSource: InteractionSource, + private val bounded: Boolean, + private val radius: Dp, + private val color: ColorProducer, +) : DelegatingNode(), CompositionLocalConsumerModifierNode, ObserverModifierNode { + private var rippleNode: DelegatableNode? = null + + override fun onAttach() { + updateConfiguration() + } + + override fun onObservedReadsChanged() { + updateConfiguration() + } + + /** + * Handles [LocalRippleConfiguration] changing between null / non-null. Changes to + * [RippleConfiguration.color] and [RippleConfiguration.rippleAlpha] are handled as part of the + * ripple definition. + */ + private fun updateConfiguration() { + observeReads { + val configuration = currentValueOf(LocalRippleConfiguration) + if (configuration == null) { + removeRipple() + } else { + if (rippleNode == null) attachNewRipple() + } + } + } + + private fun attachNewRipple() { + val calculateColor = + ColorProducer { + val userDefinedColor = color() + if (userDefinedColor.isSpecified) { + userDefinedColor + } else { + // If this is null, the ripple will be removed, so this should always be non-null in + // normal use + val rippleConfiguration = currentValueOf(LocalRippleConfiguration) + if (rippleConfiguration?.color?.isSpecified == true) { + rippleConfiguration.color + } else { + currentValueOf(LocalContentColor) + } + } + } + + val calculateRippleAlpha = { + // If this is null, the ripple will be removed, so this should always be non-null in + // normal use + val rippleConfiguration = currentValueOf(LocalRippleConfiguration) + rippleConfiguration?.rippleAlpha ?: RippleDefaults.RippleAlpha + } + + rippleNode = + delegate( + createRippleModifierNode( + interactionSource, + bounded, + radius, + calculateColor, + calculateRippleAlpha, + ), + ) + } + + private fun removeRipple() { + rippleNode?.let { undelegate(it) } + rippleNode = null + } +} + +private object StateTokens { + const val DraggedStateLayerOpacity = 0.16f + const val FocusStateLayerOpacity = 0.1f + const val HoverStateLayerOpacity = 0.08f + const val PressedStateLayerOpacity = 0.1f +} + +private val DefaultBoundedRipple = + RippleNodeFactory(bounded = true, radius = Dp.Unspecified, color = Color.Unspecified) +private val DefaultUnboundedRipple = + RippleNodeFactory(bounded = false, radius = Dp.Unspecified, color = Color.Unspecified) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f13e078ea..b5b7952d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,8 +8,8 @@ ksp_version = "2.1.0-1.0.29" koin_version = "4.0.1" koin_ksp_version = "1.4.0" -compose_bom_alpha_version = "2025.01.00" -compose_bom_version = "2025.01.00" +compose_bom_alpha_version = "2025.03.00" +compose_bom_version = "2025.03.00" accompanist_version = "0.32.0" voyager = "1.1.0-beta03" lottie-compose = "6.6.0" @@ -21,15 +21,15 @@ utilcodex_version = "1.31.1" appcompat = "1.7.0" core-ktx = "1.15.0" palette-ktx = "1.0.0" -dynamicanimation-ktx = "1.0.0-alpha03" +dynamicanimation-ktx = "1.0.0-beta01" startup-runtime = "1.2.0" -activity-compose = "1.10.0" +activity-compose = "1.10.1" room_version = "2.6.1" media = "1.7.0" media3 = "1.5.1" gson = "2.11.0" flyjingfish-aop = "1.9.7" -paging_version = "3.3.5" +paging_version = "3.3.6" krouter_version = "0.0.3" xmlutil = "0.90.3" @@ -125,6 +125,7 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp_version" } flyjingfish-aop = { id = "io.github.FlyJingFish.AndroidAop.android-aop", version.ref = "flyjingfish-aop" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin_version" } krouter-plugin = { id = "io.github.cy745.KRouter.plugin", version.ref = "krouter_version" } +lumo = { id = "com.nomanr.plugin.lumo", version = "1.2.1" } [bundles] diff --git a/lumo.properties b/lumo.properties new file mode 100644 index 000000000..837c7524a --- /dev/null +++ b/lumo.properties @@ -0,0 +1,8 @@ +# Lumo UI Plugin +# This file is used to store configurations for the Lumo UI Plugin +# Do not delete this file +ThemeName=LMusicTheme +ComponentsDir=component/src/main/java/com/lalilu/component/lumo +PackageName=com.lalilu.component.lumo +# Uncomment this line if you are using Kotlin Multiplatform +# KotlinMultiplatform=false \ No newline at end of file From d28953609f341cf5f56db6507a77e0f3d26b5508 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Thu, 20 Mar 2025 02:05:46 +0800 Subject: [PATCH 194/213] =?UTF-8?q?refactor(ui):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E7=BB=84=E4=BB=B6=E5=B9=B6=E9=80=82=E9=85=8D?= =?UTF-8?q?=20Lumo=20=E4=B8=BB=E9=A2=98-=20=E5=9C=A8=20App.kt=20=E4=B8=AD?= =?UTF-8?q?=E5=BC=95=E5=85=A5=20LumoTheme=20-=20=E4=BF=AE=E6=94=B9=20Layou?= =?UTF-8?q?tWrapperContent.kt=20=E4=B8=AD=E7=9A=84=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=88=A4=E6=96=AD=E9=80=BB=E8=BE=91=20-=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=AE=BE=E7=BD=AE=E5=BC=80=E5=85=B3=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E4=BD=BF=E7=94=A8=E6=96=B0=E7=9A=84=20LumoTh?= =?UTF-8?q?eme=20=E9=A3=8E=E6=A0=BC=20-=20=E6=96=B0=E5=A2=9E=20Switch.kt?= =?UTF-8?q?=20=E6=96=87=E4=BB=B6=EF=BC=8C=E5=AE=9E=E7=8E=B0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=BC=80=E5=85=B3=E7=BB=84=E4=BB=B6=20-=20?= =?UTF-8?q?=E8=B0=83=E6=95=B4=20Theme.kt=EF=BC=8C=E5=B0=86=20LMusicTheme?= =?UTF-8?q?=20=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=BA=20LumoTheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/lalilu/lmusic/compose/App.kt | 13 +- .../lmusic/compose/LayoutWrapperContent.kt | 2 +- .../java/com/lalilu/component/lumo/Theme.kt | 6 +- .../component/lumo/components/Switch.kt | 385 ++++++++++++++++++ .../component/settings/SettingSwitcher.kt | 6 +- lumo.properties | 2 +- 6 files changed, 399 insertions(+), 15 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/lumo/components/Switch.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/App.kt b/app/src/main/java/com/lalilu/lmusic/compose/App.kt index 275a7b143..d1ba2dba2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/App.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/App.kt @@ -13,6 +13,7 @@ import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.jetpack.ProvideNavigatorLifecycleKMPSupport import com.funny.data_saver.core.LocalDataSaver import com.lalilu.component.base.LocalWindowSize +import com.lalilu.component.lumo.LumoTheme import com.lalilu.lmusic.LMusicTheme import org.koin.compose.koinInject @@ -35,11 +36,13 @@ object App { fun Environment(activity: Activity, content: @Composable () -> Unit) { LMusicTheme { MaterialTheme { - CompositionLocalProvider( - LocalWindowSize provides calculateWindowSizeClass(activity = activity), - LocalDataSaver provides koinInject(), - content = content - ) + LumoTheme { + CompositionLocalProvider( + LocalWindowSize provides calculateWindowSizeClass(activity = activity), + LocalDataSaver provides koinInject(), + content = content + ) + } } } } diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt index 78ad6afc0..c9cfb4603 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt @@ -72,7 +72,7 @@ fun BoxScope.LayoutWrapperContent() { } } - if (windowClass.widthSizeClass == WindowWidthSizeClass.Expanded) { + if (windowClass.widthSizeClass != WindowWidthSizeClass.Compact) { LayoutForPad( navHostContent = navHostContent, navigationSmartBar = navigationSmartBar diff --git a/component/src/main/java/com/lalilu/component/lumo/Theme.kt b/component/src/main/java/com/lalilu/component/lumo/Theme.kt index 3f2c2e591..c4e3dbef9 100644 --- a/component/src/main/java/com/lalilu/component/lumo/Theme.kt +++ b/component/src/main/java/com/lalilu/component/lumo/Theme.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import com.lalilu.component.lumo.foundation.ripple -object LMusicTheme { +object LumoTheme { val colors: Colors @ReadOnlyComposable @Composable get() = LocalColors.current @@ -22,7 +22,7 @@ object LMusicTheme { } @Composable -fun LMusicTheme( +fun LumoTheme( isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { @@ -44,7 +44,7 @@ fun LMusicTheme( @Composable fun contentColorFor(color: Color): Color { - return LMusicTheme.colors.contentColorFor(color) + return LumoTheme.colors.contentColorFor(color) } @Composable diff --git a/component/src/main/java/com/lalilu/component/lumo/components/Switch.kt b/component/src/main/java/com/lalilu/component/lumo/components/Switch.kt new file mode 100644 index 000000000..91b936656 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/lumo/components/Switch.kt @@ -0,0 +1,385 @@ +package com.lalilu.component.lumo.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.lalilu.component.lumo.LumoTheme +import com.lalilu.component.lumo.LocalContentColor +import com.lalilu.component.lumo.components.SwitchDefaults.RippleRadius +import com.lalilu.component.lumo.components.SwitchDefaults.SwitchHeight +import com.lalilu.component.lumo.components.SwitchDefaults.SwitchWidth +import com.lalilu.component.lumo.components.SwitchDefaults.ThumbSize +import com.lalilu.component.lumo.components.SwitchDefaults.ThumbSizeStateOffset +import com.lalilu.component.lumo.components.SwitchDefaults.TrackBorderWidth +import com.lalilu.component.lumo.components.SwitchDefaults.TrackShape +import com.lalilu.component.lumo.components.SwitchDefaults.UncheckedThumbSize +import com.lalilu.component.lumo.foundation.ripple +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import androidx.compose.ui.tooling.preview.Preview +import kotlin.math.roundToInt + +@Composable +fun Switch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + thumbContent: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + colors: SwitchColors = SwitchDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val scope = rememberCoroutineScope() + val pressed by interactionSource.collectIsPressedAsState() + + val animationState = + remember { + SwitchAnimationState(checked, pressed) + } + + LaunchedEffect(checked, pressed) { + animationState.animateTo(checked, pressed, scope) + } + + val toggleableModifier = + if (onCheckedChange != null) { + Modifier.toggleable( + value = checked, + onValueChange = onCheckedChange, + enabled = enabled, + role = Role.Switch, + interactionSource = interactionSource, + indication = null, + ) + } else { + Modifier + } + + SwitchComponent( + modifier = modifier.then(toggleableModifier), + checked = checked, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + thumbContent = thumbContent, + thumbPosition = animationState.thumbPosition.value, + thumbSizeOffset = animationState.thumbSizeOffset.value, + ) +} + +@Composable +private fun SwitchComponent( + modifier: Modifier, + checked: Boolean, + enabled: Boolean, + colors: SwitchColors, + interactionSource: InteractionSource, + thumbContent: (@Composable () -> Unit)?, + thumbPosition: Float, + thumbSizeOffset: Float, +) { + val borderColor = colors.borderColor(enabled = enabled, checked = checked) + + Box( + modifier = + modifier + .size(SwitchWidth, SwitchHeight) + .background( + color = colors.trackColor(enabled, checked), + shape = TrackShape, + ) + .border( + width = TrackBorderWidth, + color = borderColor, + shape = TrackShape, + ), + ) { + val checkedThumbSize = UncheckedThumbSize + ThumbSizeStateOffset * thumbPosition + val uncheckedThumbSize = + UncheckedThumbSize + ThumbSizeStateOffset * if (thumbPosition == 0f) thumbSizeOffset else thumbPosition + + val thumbSize = if (checked) checkedThumbSize else uncheckedThumbSize + val verticalPadding = (SwitchHeight - ThumbSize) / 2 + + Box( + modifier = + Modifier + .align(Alignment.CenterStart) + .size(thumbSize) + .offset { + val trackWidth = SwitchWidth.toPx() + val currentThumbSize = thumbSize.toPx() + val maxThumbSize = ThumbSize.toPx() + val padding = verticalPadding.toPx() + + val totalMovableDistance = trackWidth - maxThumbSize - (padding * 2) + val sizeDifference = (maxThumbSize - currentThumbSize) / 2 + + IntOffset( + x = (padding + sizeDifference + (totalMovableDistance * thumbPosition)).roundToInt(), + y = 0, + ) + } + .drawBehind { + drawCircle( + color = colors.thumbColor(enabled, checked), + ) + } + .indication( + interactionSource = interactionSource, + indication = + ripple( + bounded = false, + radius = RippleRadius, + ), + ), + contentAlignment = Alignment.Center, + ) { + if (thumbContent != null) { + CompositionLocalProvider( + LocalContentColor provides colors.iconColor(enabled, checked), + ) { + thumbContent() + } + } + } + } +} + +object SwitchDefaults { + val ThumbSize = 16.dp + val UncheckedThumbSize = 12.dp + val ThumbSizeStateOffset = ThumbSize - UncheckedThumbSize + val SwitchWidth = 40.dp + val SwitchHeight = 24.dp + val TrackBorderWidth = 2.dp + val TrackShape = RoundedCornerShape(50) + val RippleRadius = 20.dp + + @Composable + fun colors( + checkedThumbColor: Color = LumoTheme.colors.onPrimary, + checkedTrackColor: Color = LumoTheme.colors.primary, + checkedBorderColor: Color = LumoTheme.colors.primary, + checkedIconColor: Color = LumoTheme.colors.primary, + uncheckedThumbColor: Color = LumoTheme.colors.primary, + uncheckedTrackColor: Color = LumoTheme.colors.background, + uncheckedBorderColor: Color = LumoTheme.colors.primary, + uncheckedIconColor: Color = LumoTheme.colors.onPrimary, + disabledCheckedThumbColor: Color = LumoTheme.colors.onDisabled, + disabledCheckedTrackColor: Color = LumoTheme.colors.disabled, + disabledCheckedBorderColor: Color = LumoTheme.colors.disabled, + disabledCheckedIconColor: Color = LumoTheme.colors.disabled, + disabledUncheckedThumbColor: Color = LumoTheme.colors.disabled, + disabledUncheckedTrackColor: Color = LumoTheme.colors.transparent, + disabledUncheckedBorderColor: Color = LumoTheme.colors.disabled, + disabledUncheckedIconColor: Color = LumoTheme.colors.onDisabled, + ): SwitchColors = + SwitchColors( + checkedThumbColor = checkedThumbColor, + checkedTrackColor = checkedTrackColor, + checkedBorderColor = checkedBorderColor, + checkedIconColor = checkedIconColor, + uncheckedThumbColor = uncheckedThumbColor, + uncheckedTrackColor = uncheckedTrackColor, + uncheckedBorderColor = uncheckedBorderColor, + uncheckedIconColor = uncheckedIconColor, + disabledCheckedThumbColor = disabledCheckedThumbColor, + disabledCheckedTrackColor = disabledCheckedTrackColor, + disabledCheckedBorderColor = disabledCheckedBorderColor, + disabledCheckedIconColor = disabledCheckedIconColor, + disabledUncheckedThumbColor = disabledUncheckedThumbColor, + disabledUncheckedTrackColor = disabledUncheckedTrackColor, + disabledUncheckedBorderColor = disabledUncheckedBorderColor, + disabledUncheckedIconColor = disabledUncheckedIconColor, + ) +} + +@Stable +class SwitchColors( + private val checkedThumbColor: Color, + private val checkedTrackColor: Color, + private val checkedBorderColor: Color, + private val checkedIconColor: Color, + private val uncheckedThumbColor: Color, + private val uncheckedTrackColor: Color, + private val uncheckedBorderColor: Color, + private val uncheckedIconColor: Color, + private val disabledCheckedThumbColor: Color, + private val disabledCheckedTrackColor: Color, + private val disabledCheckedBorderColor: Color, + private val disabledCheckedIconColor: Color, + private val disabledUncheckedThumbColor: Color, + private val disabledUncheckedTrackColor: Color, + private val disabledUncheckedBorderColor: Color, + private val disabledUncheckedIconColor: Color, +) { + @Stable + internal fun thumbColor(enabled: Boolean, checked: Boolean): Color = + when { + enabled && checked -> checkedThumbColor + enabled && !checked -> uncheckedThumbColor + !enabled && checked -> disabledCheckedThumbColor + else -> disabledUncheckedThumbColor + } + + @Stable + internal fun trackColor(enabled: Boolean, checked: Boolean): Color = + when { + enabled && checked -> checkedTrackColor + enabled && !checked -> uncheckedTrackColor + !enabled && checked -> disabledCheckedTrackColor + else -> disabledUncheckedTrackColor + } + + @Stable + internal fun borderColor(enabled: Boolean, checked: Boolean): Color = + when { + enabled && checked -> checkedBorderColor + enabled && !checked -> uncheckedBorderColor + !enabled && checked -> disabledCheckedBorderColor + else -> disabledUncheckedBorderColor + } + + @Stable + internal fun iconColor(enabled: Boolean, checked: Boolean): Color = + when { + enabled && checked -> checkedIconColor + enabled && !checked -> uncheckedIconColor + !enabled && checked -> disabledCheckedIconColor + else -> disabledUncheckedIconColor + } +} + +@Stable +private class SwitchAnimationState( + initialChecked: Boolean, + initialPressed: Boolean, +) { + var checked by mutableStateOf(initialChecked) + var pressed by mutableStateOf(initialPressed) + + val thumbPosition = Animatable(if (checked) 1f else 0f) + val thumbSizeOffset = Animatable(0f) + + val animationSpec = + tween( + durationMillis = 100, + easing = FastOutSlowInEasing, + ) + + suspend fun animateTo( + targetChecked: Boolean, + targetPressed: Boolean, + scope: CoroutineScope, + ) { + checked = targetChecked + pressed = targetPressed + + scope.launch { + thumbPosition.animateTo( + targetValue = if (targetChecked) 1f else 0f, + animationSpec = animationSpec, + ) + } + scope.launch { + thumbSizeOffset.animateTo( + targetValue = if (targetPressed) 1f else 0f, + animationSpec = animationSpec, + ) + } + } +} + +@Preview +@Composable +private fun SwitchPreview() { + LumoTheme { + Column(modifier = Modifier.padding(16.dp)) { + val value = + remember { + mutableStateOf(false) + } + + Spacer(modifier = Modifier.size(16.dp)) + Switch( + checked = value.value, + onCheckedChange = { + value.value = it + }, + ) + Spacer(modifier = Modifier.size(16.dp)) + Switch( + checked = value.value, + onCheckedChange = { + value.value = it + }, + ) + Spacer(modifier = Modifier.size(16.dp)) + + Switch( + checked = true, + onCheckedChange = { + value.value = it + }, + ) + Spacer(modifier = Modifier.size(16.dp)) + + Switch( + checked = false, + onCheckedChange = { + value.value = it + }, + ) + Spacer(modifier = Modifier.size(16.dp)) + + Switch( + checked = true, + enabled = false, + onCheckedChange = { + value.value = it + }, + ) + Spacer(modifier = Modifier.size(16.dp)) + + Switch( + checked = false, + enabled = false, + onCheckedChange = { + value.value = it + }, + ) + Spacer(modifier = Modifier.size(16.dp)) + } + } +} diff --git a/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt b/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt index 1c071ceff..22a489db0 100644 --- a/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt +++ b/component/src/main/java/com/lalilu/component/settings/SettingSwitcher.kt @@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.MaterialTheme -import androidx.compose.material.Switch -import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable @@ -27,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.lalilu.component.extension.enableFor +import com.lalilu.component.lumo.components.Switch @Composable fun SettingSwitcher( @@ -101,9 +100,6 @@ fun SettingSwitcher( checked = state(), onCheckedChange = { onStateUpdate(it) }, interactionSource = interaction, - colors = SwitchDefaults.colors( - checkedThumbColor = textColor.multiply(0.7f) - ) ) } ) diff --git a/lumo.properties b/lumo.properties index 837c7524a..d8ec3ecda 100644 --- a/lumo.properties +++ b/lumo.properties @@ -1,7 +1,7 @@ # Lumo UI Plugin # This file is used to store configurations for the Lumo UI Plugin # Do not delete this file -ThemeName=LMusicTheme +ThemeName=LumoTheme ComponentsDir=component/src/main/java/com/lalilu/component/lumo PackageName=com.lalilu.component.lumo # Uncomment this line if you are using Kotlin Multiplatform From 0f6ee8fa0fb8fd52db74a781f2db1f3e71cbcb8a Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Mar 2025 20:36:25 +0800 Subject: [PATCH 195/213] =?UTF-8?q?fix(common):=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=A2=9C=E8=89=B2=E9=80=89=E6=8B=A9=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 getDarkVibrantColor 和 getDarkMutedColor 方法的默认颜色从 Color.LTGRAY 改为 Color.DKGRAY - 这种修改可以提供更好的颜色对比度,特别是在光线较暗的环境下 --- common/src/main/java/com/lalilu/common/ColorUtils.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/com/lalilu/common/ColorUtils.kt b/common/src/main/java/com/lalilu/common/ColorUtils.kt index 67a1cb5e5..0984fdcf1 100644 --- a/common/src/main/java/com/lalilu/common/ColorUtils.kt +++ b/common/src/main/java/com/lalilu/common/ColorUtils.kt @@ -7,9 +7,9 @@ import androidx.palette.graphics.Palette fun Palette?.getAutomaticColor(): Int { if (this == null) return Color.DKGRAY - var oldColor = this.getDarkVibrantColor(Color.LTGRAY) + var oldColor = this.getDarkVibrantColor(Color.DKGRAY) if (ColorUtils.isLightColor(oldColor)) - oldColor = this.getDarkMutedColor(Color.LTGRAY) + oldColor = this.getDarkMutedColor(Color.DKGRAY) return oldColor } From c3d71798ea59607f8452a763bc87616dc0e48fc1 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Mar 2025 20:36:44 +0800 Subject: [PATCH 196/213] =?UTF-8?q?refactor(lalbum):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=B9=B3=E6=9D=BF=E8=AE=BE=E5=A4=87=E6=A3=80=E6=B5=8B=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将平板设备检测条件从宽度尺寸类为 Expanded 修改为非 Compact -这个修改可以更好地适应不同尺寸的设备,提高用户体验 --- .../main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt index cc4a01da4..ad621a167 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt @@ -43,7 +43,7 @@ internal fun AlbumsScreenContent( albums: () -> Map> = { emptyMap() }, showText: () -> Boolean = { false }, ) { - val isPad = LocalWindowSize.current.widthSizeClass == WindowWidthSizeClass.Expanded + val isPad = LocalWindowSize.current.widthSizeClass != WindowWidthSizeClass.Compact val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val gridState = rememberLazyStaggeredGridState() From 1a3e211f7e6b72309ca84e3ae8983f04a23a9d06 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Mar 2025 20:38:44 +0800 Subject: [PATCH 197/213] =?UTF-8?q?refactor(lhistory):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E7=9A=84=E5=B8=83=E5=B1=80=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 derivedStateOf 和 remember 优化性能 - 根据窗口宽度重新排序历史记录项 -调整网格布局的跨度逻辑 --- .../java/com/lalilu/lhistory/HistoryPanel.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt index 3f94583cb..ec39ede28 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lalilu.component.LazyGridContent @@ -33,6 +35,22 @@ class HistoryPanel : LazyGridContent { val historyVM = koinInject() val widthSizeClass = LocalWindowSize.current.widthSizeClass val items by historyVM.historyState + val itemsWithReplace = remember { + derivedStateOf { + if (widthSizeClass == WindowWidthSizeClass.Compact) { + items + } else { + listOfNotNull( + items.getOrNull(0), + items.getOrNull(3), + items.getOrNull(1), + items.getOrNull(4), + items.getOrNull(2), + items.getOrNull(5) + ) + } + } + } val favouriteIds = state("favourite_ids", emptyList()) return fun LazyGridScope.() { @@ -40,11 +58,11 @@ class HistoryPanel : LazyGridContent { if (items.isEmpty()) return items( - items = items, + items = itemsWithReplace.value, key = { it.id }, contentType = { "History_item" }, span = { - if (widthSizeClass == WindowWidthSizeClass.Expanded) GridItemSpan(maxLineSpan / 2) + if (widthSizeClass != WindowWidthSizeClass.Compact) GridItemSpan(maxLineSpan / 2) else GridItemSpan(maxLineSpan) } ) { item -> From 37a9b8a3d022fceefa4568ae0334437d31d8cf9a Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Mar 2025 20:54:29 +0800 Subject: [PATCH 198/213] =?UTF-8?q?fix(component):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=8F=90=E7=A4=BA=E5=9B=BE=E6=A0=87=E7=9A=84?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 PlayingTipIcon 组件中,更新了 AnimatedVisibility 的 enter 和 exit 参数 - 添加了 clip = false 参数,以解决动画过程中图标显示的问题 --- .../src/main/java/com/lalilu/component/card/PlayingTipIcon.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/component/src/main/java/com/lalilu/component/card/PlayingTipIcon.kt b/component/src/main/java/com/lalilu/component/card/PlayingTipIcon.kt index 4447b3488..e56f64b6c 100644 --- a/component/src/main/java/com/lalilu/component/card/PlayingTipIcon.kt +++ b/component/src/main/java/com/lalilu/component/card/PlayingTipIcon.kt @@ -33,8 +33,8 @@ fun PlayingTipIcon( AnimatedVisibility( visible = isPlaying(), modifier = modifier.wrapContentWidth(), - enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkHorizontally() + enter = fadeIn() + expandHorizontally(clip = false), + exit = fadeOut() + shrinkHorizontally(clip = false) ) { val composition by rememberLottieComposition(LottieCompositionSpec.Asset("anim/90463-wave.json")) val properties = rememberLottieDynamicProperties( From e6720a4939ca85f6f2140d4047285d7cdaf8477b Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Mar 2025 20:58:50 +0800 Subject: [PATCH 199/213] =?UTF-8?q?refactor(component):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20SettingSmallProgressSeekBar=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=B9=B6=E8=B0=83=E6=95=B4=E6=AD=8C=E8=AF=8D=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SettingSmallProgressSeekBar 组件,用于替代 SettingProgressSeekBar - 在歌词设置界面中使用新组件替换旧组件 - 调整歌词设置界面布局,增加列高度比例 - 更新字体大小和行高设置的范围 - 修复部分组件的对齐问题 --- .../component/playing/LyricViewToolbar.kt | 45 +++++----- .../settings/SettingSmallProgressSeekBar.kt | 90 +++++++++++++++++++ 2 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/settings/SettingSmallProgressSeekBar.kt diff --git a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt index fd817bed4..c8da7025e 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt @@ -35,7 +35,7 @@ import com.lalilu.component.extension.DialogWrapper import com.lalilu.component.extension.split import com.lalilu.component.extension.transform import com.lalilu.component.settings.SettingFilePicker -import com.lalilu.component.settings.SettingProgressSeekBar +import com.lalilu.component.settings.SettingSmallProgressSeekBar import com.lalilu.component.settings.SettingStateSeekBar import com.lalilu.component.settings.SettingSwitcher import com.lalilu.lmusic.compose.screen.playing.lyric.LyricSettings @@ -47,7 +47,7 @@ import org.koin.core.qualifier.named import kotlin.math.roundToInt import kotlin.math.roundToLong -private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.Transparent) { +val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.Transparent) { val settingsSp: SettingsSp = koinInject() val settings: DataSaverMutableState = koinInject(named("LyricSettings")) val lyricTypefacePath = settings.split( @@ -73,7 +73,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T ) { Column( modifier = Modifier - .fillMaxHeight(0.4f) + .fillMaxHeight(0.6f) .verticalScroll(state = rememberScrollState()) ) { SettingStateSeekBar( @@ -99,21 +99,25 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T selection = stringArrayResource(id = R.array.lyric_gravity_text).toList(), title = stringResource(R.string.preference_lyric_settings_text_gravity) ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.mainFontSize.value }, - onValueUpdate = { settings.value = settings.value.copy(mainFontSize = it.sp) }, + onValueUpdate = { + settings.value = settings.value.copy(mainFontSize = it.sp) + }, onFinishedUpdate = { settings.saveData() }, title = "歌词文字大小", - valueRange = 14..36 + valueRange = 14..64 ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.mainLineHeight.value }, - onValueUpdate = { settings.value = settings.value.copy(mainLineHeight = it.sp) }, + onValueUpdate = { + settings.value = settings.value.copy(mainLineHeight = it.sp) + }, onFinishedUpdate = { settings.saveData() }, title = "歌词行高大小", - valueRange = 14..48 + valueRange = 14..72 ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.mainFontWeight.toFloat() }, onValueUpdate = { settings.value = settings.value.copy(mainFontWeight = it.roundToInt()) @@ -122,34 +126,35 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T title = "歌词字重", valueRange = 50..900 ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.translationFontSize.value }, onValueUpdate = { settings.value = settings.value.copy(translationFontSize = it.sp) }, onFinishedUpdate = { settings.saveData() }, title = "翻译文字大小", - valueRange = 14..36 + valueRange = 14..64 ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.translationLineHeight.value }, onValueUpdate = { settings.value = settings.value.copy(translationLineHeight = it.sp) }, onFinishedUpdate = { settings.saveData() }, title = "翻译行高大小", - valueRange = 14..48 + valueRange = 14..72 ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.translationFontWeight.toFloat() }, onValueUpdate = { - settings.value = settings.value.copy(translationFontWeight = it.roundToInt()) + settings.value = + settings.value.copy(translationFontWeight = it.roundToInt()) }, onFinishedUpdate = { settings.saveData() }, title = "翻译字重", valueRange = 50..900 ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.timeOffset.toFloat() }, onValueUpdate = { settings.value = settings.value.copy(timeOffset = it.roundToLong()) @@ -158,7 +163,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T title = "歌词偏移时间(ms)", valueRange = 0..500 ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.gapSize.value }, onValueUpdate = { settings.value = settings.value.copy(gapSize = it.dp) @@ -168,7 +173,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T valueRange = 0..50 ) - SettingProgressSeekBar( + SettingSmallProgressSeekBar( value = { settings.value.containerPadding.run { (calculateLeftPadding(LayoutDirection.Ltr) + @@ -205,7 +210,7 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T SettingFilePicker( state = lyricTypefacePath, title = "自定义字体", - subTitle = "请选择TTF格式的字体文件", + subTitle = "请选择TTF格式的字体文件(存在bug,待修复)", mimeType = "font/ttf" ) } diff --git a/component/src/main/java/com/lalilu/component/settings/SettingSmallProgressSeekBar.kt b/component/src/main/java/com/lalilu/component/settings/SettingSmallProgressSeekBar.kt new file mode 100644 index 000000000..372e05dc7 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/settings/SettingSmallProgressSeekBar.kt @@ -0,0 +1,90 @@ +package com.lalilu.component.settings + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.MarqueeSpacing +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.base.ProgressSeekBar + + +@Composable +fun SettingSmallProgressSeekBar( + value: () -> Float, + onValueUpdate: (Float) -> Unit = {}, + onFinishedUpdate: (Float) -> Unit = {}, + title: String, + subTitle: String? = null, + valueRange: IntRange +) { + val tempValue = remember { mutableFloatStateOf(value()) } + val interactionSource = remember { MutableInteractionSource() } + val textColor = contentColorFor(backgroundColor = MaterialTheme.colors.background) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + onClick = { } + ) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.weight(0.8f), + ) { + Text( + modifier = Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + spacing = MarqueeSpacing(30.dp) + ), + maxLines = 1, + text = title, + color = textColor, + fontSize = 14.sp + ) + if (subTitle != null) { + Text( + modifier = Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + spacing = MarqueeSpacing(30.dp) + ), + maxLines = 1, + text = subTitle, + fontSize = 12.sp, + color = textColor.copy(0.5f) + ) + } + } + + ProgressSeekBar( + modifier = Modifier.weight(1.2f), + value = tempValue.floatValue, + onValueChange = { + tempValue.floatValue = it + onValueUpdate(it) + }, + valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(), + steps = valueRange.last - valueRange.first - 1, + onValueChangeFinished = { onFinishedUpdate(tempValue.floatValue) } + ) + } +} \ No newline at end of file From f1c35cce4de5f5834e3fd44041c4c068484857de Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Mar 2025 21:12:22 +0800 Subject: [PATCH 200/213] =?UTF-8?q?refactor(lmusic):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E5=B8=83=E5=B1=80=E7=9A=84=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E5=92=8C=E9=80=82=E9=85=8D=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 LocalDensity以实现更精确的尺寸计算- 动态计算滚动分割高度,提高不同屏幕尺寸的适配性 - 调整内容填充和透明边缘的尺寸,改善滚动体验 - 优化"暂无歌词"提示的显示方式,使用 LyricContentNormal 组件 --- .../screen/playing/lyric/LyricLayout.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt index 49c0a1912..f33f467f0 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints @@ -66,6 +67,7 @@ fun LyricLayout( onItemClick: (LyricItem) -> Unit = {}, onItemLongClick: (LyricItem) -> Unit = {}, ) { + val density = LocalDensity.current val settings: DataSaverMutableState = koinInject(named("LyricSettings")) val textMeasurer = rememberTextMeasurer() val isUserScrolling = remember { mutableStateOf(isUserScrollEnable()) } @@ -132,18 +134,41 @@ fun LyricLayout( } Box(modifier = Modifier.fillMaxSize()) { + val heightSplit = remember(screenConstraints) { + density.run { screenConstraints.maxHeight.toDp() / 3f } + } + LazyColumn( state = listState, modifier = modifier .fillMaxSize() - .edgeTransparent(top = 300.dp, bottom = 250.dp), + .edgeTransparent(top = heightSplit, bottom = heightSplit), userScrollEnabled = true, - contentPadding = remember { PaddingValues(top = 300.dp, bottom = 500.dp) } + contentPadding = PaddingValues( + top = heightSplit, + bottom = heightSplit * 2f + ) ) { startRecord(recorder) { if (lyricEntry.value.isEmpty()) { itemWithRecord(key = "EMPTY_TIPS") { - Text("暂无歌词") + val item = remember { + LyricItem.NormalLyric( + key = "0", + content = "暂无歌词", + time = 0L + ) + } + + LyricContentNormal( + lyric = item, + index = context.currentIndex(), + modifier = Modifier, + settings = settings.value, + context = context, + onLongClick = { if (isUserClickEnable()) onItemLongClick(item) }, + onClick = { } + ) } } else { itemsIndexedWithRecord( From 1a25df71cc63e6c96c02112a1c56b3b4776bc410 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Mar 2025 21:14:14 +0800 Subject: [PATCH 201/213] =?UTF-8?q?feat(playing):=20=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8B=E6=98=BE=E7=A4=BA=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 LyricPanel 组件用于显示歌词- 重构 PlayingLayoutExpended组件布局 - 优化 PlayerPanel 组件样式 - 调整 ControlPanel 组件布局 --- .../lmusic/compose/LayoutWrapperContent.kt | 10 +- .../screen/playing/PlayingLayoutExpended.kt | 229 +++++++++++++----- 2 files changed, 182 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt index c9cfb4603..d6b4d93c9 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt @@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues 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.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.BottomSheetValue @@ -92,15 +95,16 @@ fun BoxScope.LayoutWrapperContent() { @Composable private fun LayoutForPad( modifier: Modifier = Modifier, - navigatorBarHeight: Dp = 56.dp, + smartBarHeight: Dp = 56.dp, navHostContent: @Composable ((Navigator) -> Unit) -> Unit, navigationSmartBar: @Composable (Modifier) -> Unit ) { val navigator = remember { mutableStateOf(null) } + val navigatorBar = WindowInsets.navigationBars.asPaddingValues() BottomSheetLayout2( modifier = modifier, - sheetPeekHeight = navigatorBarHeight, + sheetPeekHeight = smartBarHeight + navigatorBar.calculateBottomPadding(), sheetContent = { enhanceSheetState -> BoxWithConstraints(modifier = Modifier.fillMaxSize()) { Box( @@ -121,7 +125,7 @@ private fun LayoutForPad( Row( modifier = Modifier .padding(horizontal = 16.dp) - .height(navigatorBarHeight) + .height(smartBarHeight + navigatorBar.calculateBottomPadding()) .align(Alignment.TopCenter) .graphicsLayer { val progress = enhanceSheetState.progress( diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt index 56696fb14..ff59bfd0f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt @@ -9,42 +9,68 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.IconToggleButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import androidx.media3.common.MediaItem import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import coil3.request.transformations import com.lalilu.R -import com.lalilu.RemixIcon +import com.lalilu.component.extension.DialogWrapper +import com.lalilu.lmedia.lyric.LyricItem +import com.lalilu.lmedia.lyric.LyricSourceEmbedded +import com.lalilu.lmedia.lyric.LyricUtils +import com.lalilu.lmusic.compose.component.playing.LyricViewActionDialog +import com.lalilu.lmusic.compose.screen.playing.lyric.LyricLayout import com.lalilu.lmusic.datastore.SettingsSp import com.lalilu.lmusic.utils.coil.BlurTransformation import com.lalilu.lplayer.MPlayer import com.lalilu.lplayer.action.PlayerAction -import com.lalilu.remixicon.Media -import com.lalilu.remixicon.media.playLine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext import org.koin.compose.koinInject +/** + * Expended 状态下的播放布局 + */ @Composable fun PlayingLayoutExpended( modifier: Modifier = Modifier, @@ -84,43 +110,130 @@ fun PlayingLayoutExpended( Row( modifier = Modifier .fillMaxSize() - .padding(64.dp), + .padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.spacedBy(24.dp) ) { - Column( + PlayerPanel( modifier = Modifier - .fillMaxSize() - .padding(24.dp) - .weight(1f), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.SpaceBetween + .weight(1f) + .fillMaxHeight(), + currentPlaying = currentPlaying + ) + + LyricPanel( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } +} + +@Composable +private fun PlayerPanel( + modifier: Modifier = Modifier, + currentPlaying: MediaItem? = null +) { + Column( + modifier = modifier + .statusBarsPadding() + .padding(vertical = 16.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AnimatedContent( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + transitionSpec = { + fadeIn(tween(500)) togetherWith fadeOut(tween(300, 500)) + }, + targetState = currentPlaying, + label = "" + ) { model -> + Card( + shape = RoundedCornerShape(2.dp) ) { - AnimatedContent( - modifier = Modifier.size(300.dp), - transitionSpec = { - fadeIn(tween(500)) togetherWith fadeOut(tween(300, 500)) - }, - targetState = currentPlaying, - label = "" - ) { model -> - AsyncImage( - modifier = Modifier.fillMaxSize(), - model = model, - contentScale = ContentScale.Crop, - contentDescription = null - ) - } + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = model, + contentScale = ContentScale.Crop, + contentDescription = null + ) + } + } + + SongDetailPanel(playable = currentPlaying) + Spacer(Modifier.weight(1f)) + ControlPanel() + } +} - SongDetailPanel(playable = currentPlaying) - ControlPanel() +@Composable +fun LyricPanel( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current + val lyricSource = remember { LyricSourceEmbedded(context = context) } + val lyrics = remember { mutableStateOf>(emptyList()) } + val isLyricScrollEnable = remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val currentPosition = remember { mutableLongStateOf(0L) } + + LaunchedEffect(key1 = MPlayer.currentMediaItem) { + withContext(Dispatchers.IO) { + MPlayer.currentMediaItem + ?.let { lyricSource.loadLyric(it) } + ?.let { LyricUtils.parseLrc(it.first, it.second) } + .let { if (isActive) lyrics.value = it ?: emptyList() } + } + } + + LaunchedEffect(Unit) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + withFrameMillis { + val newValue = MPlayer.currentPosition + if (currentPosition.longValue != newValue) { + currentPosition.longValue = newValue + } + } } } } + + BoxWithConstraints(modifier = modifier) { + LyricLayout( + modifier = Modifier + .fillMaxSize(), + lyricEntry = lyrics, + listState = listState, + currentTime = { currentPosition.longValue }, + screenConstraints = constraints, + isUserClickEnable = { true }, + isUserScrollEnable = { isLyricScrollEnable.value }, + onPositionReset = { + if (isLyricScrollEnable.value) { + isLyricScrollEnable.value = false + } + }, + onItemClick = { + if (isLyricScrollEnable.value) { + isLyricScrollEnable.value = false + } + PlayerAction.SeekTo(it.time).action() + }, + onItemLongClick = { + isLyricScrollEnable.value = !isLyricScrollEnable.value + }, + ) + } } @Composable -fun SongDetailPanel( +private fun SongDetailPanel( playable: MediaItem?, ) { if (playable == null) { @@ -133,37 +246,38 @@ fun SongDetailPanel( } Column( horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = playable.mediaMetadata.title.toString(), color = Color.White, - fontSize = 24.sp + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Bold ) Text( text = playable.mediaMetadata.subtitle.toString(), color = Color.White, - fontSize = 16.sp - ) - Text( - text = playable.mediaMetadata.subtitle.toString(), - color = Color.White, - fontSize = 12.sp + fontSize = 12.sp, + lineHeight = 18.sp ) } } @Composable -fun ControlPanel( +private fun ControlPanel( settingsSp: SettingsSp = koinInject() ) { val isPlaying = remember { derivedStateOf { MPlayer.isPlaying } } var playMode by settingsSp.playMode Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) + horizontalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.CenterHorizontally) ) { IconButton(onClick = { PlayerAction.SkipToPrevious.action() }) { Image( @@ -193,21 +307,28 @@ fun ControlPanel( modifier = Modifier.size(28.dp) ) } - IconButton(onClick = { playMode = (playMode + 1) % 3 }) { - Image( - // TODO 待完善播放模式的显示 - imageVector = RemixIcon.Media.playLine, -// painter = painterResource( -// when (PlayMode.values()[playMode]) { -// PlayMode.ListRecycle -> R.drawable.ic_order_play_line -// PlayMode.RepeatOne -> R.drawable.ic_repeat_one_line -// PlayMode.Shuffle -> R.drawable.ic_shuffle_line -// } -// ), - contentDescription = "play_pause", - colorFilter = ColorFilter.tint(color = Color.White), - modifier = Modifier.size(24.dp) + IconButton(onClick = { DialogWrapper.push(LyricViewActionDialog) }) { + Icon( + painter = painterResource(id = R.drawable.ic_text), + contentDescription = "", + tint = Color.White ) } +// IconButton(onClick = { playMode = (playMode + 1) % 3 }) { +// Image( +// // TODO 待完善播放模式的显示 +// imageVector = RemixIcon.Media.playLine, +//// painter = painterResource( +//// when (PlayMode.values()[playMode]) { +//// PlayMode.ListRecycle -> R.drawable.ic_order_play_line +//// PlayMode.RepeatOne -> R.drawable.ic_repeat_one_line +//// PlayMode.Shuffle -> R.drawable.ic_shuffle_line +//// } +//// ), +// contentDescription = "play_pause", +// colorFilter = ColorFilter.tint(color = Color.White), +// modifier = Modifier.size(24.dp) +// ) +// } } } \ No newline at end of file From 116d004dfca499e642f48c960dea81e2069c8fb3 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sat, 29 Mar 2025 21:15:38 +0800 Subject: [PATCH 202/213] =?UTF-8?q?feat(component):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20Slider=20=E7=BB=84=E4=BB=B6-=20=E5=9C=A8=20component=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E4=B8=AD=E6=96=B0=E5=A2=9E=20Slider.kt=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E5=AE=9E=E7=8E=B0=E6=BB=91=E5=8A=A8?= =?UTF-8?q?=E6=9D=A1=E7=BB=84=E4=BB=B6=20-=20=E6=B7=BB=E5=8A=A0=E4=BA=86?= =?UTF-8?q?=E5=8D=95=E4=B8=AA=E6=BB=91=E5=8A=A8=E6=9D=A1=E5=92=8C=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E6=BB=91=E5=8A=A8=E6=9D=A1=E7=9A=84=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=20-=20=E6=8F=90=E4=BE=9B=E4=BA=86=E6=BB=91=E5=8A=A8=E6=9D=A1?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E5=92=8C=E6=A0=B7=E5=BC=8F=E7=9A=84=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E9=80=89=E9=A1=B9=20-=20=E5=9C=A8=20AndroidM?= =?UTF-8?q?anifest.xml=20=E4=B8=AD=E6=9B=B4=E6=96=B0=E4=BA=86=20uses-sdk?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE-=20=E5=9C=A8=20build.gradle.kts=20?= =?UTF-8?q?=E4=B8=AD=E6=B7=BB=E5=8A=A0=E4=BA=86=20rebugger=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=B9=B6=E9=85=8D=E7=BD=AE=E4=BA=86=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 8 +- app/src/main/AndroidManifest.xml | 5 +- component/build.gradle.kts | 1 + .../component/lumo/components/Slider.kt | 438 ++++++++++++++++++ 4 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 component/src/main/java/com/lalilu/component/lumo/components/Slider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 478f1472c..1e4799771 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag import java.io.FileInputStream import java.text.SimpleDateFormat import java.util.Date @@ -143,6 +144,9 @@ android { versionNameSuffix = "-DEBUG_${releaseTime("yyyyMMdd")}" applicationIdSuffix = ".debug" signingConfig = signingConfigs.getByName("debug") + isProfileable = true + isDebuggable = true + isJniDebuggable = true resValue("string", "app_name", "@string/app_name_debug") } @@ -162,7 +166,8 @@ android { } composeCompiler { - enableStrongSkippingMode = true + composeCompiler.featureFlags.add(ComposeFeatureFlag.StrongSkipping) + composeCompiler.featureFlags.add(ComposeFeatureFlag.PausableComposition) } dependencies { @@ -218,6 +223,7 @@ dependencies { // debugImplementation("io.github.knight-zxw:blockcanary-ui:0.0.5") // debugImplementation("com.github.cy745:wytrace:d0df4c2d15") // debugImplementation("com.bytedance.android:shadowhook:1.0.10") + implementation("io.github.theapache64:rebugger:1.0.0-rc03") implementation(libs.bundles.flyjingfish.aop) ksp(libs.flyjingfish.aop.ksp) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 169a64824..41ca92a6c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ android:glEsVersion="0x00030000" android:required="true" /> - + @@ -38,7 +38,8 @@ android:supportsRtl="true" android:theme="@style/Theme.Music" android:usesCleartextTraffic="true" - tools:ignore="AllowBackup,UnusedAttribute"> + tools:ignore="AllowBackup,UnusedAttribute" + tools:overrideLibrary="com.nomanr.composables"> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + @IntRange(from = 0) steps: Int = 0, + valueRange: ClosedFloatingPointRange = 0f..1f, +) { + val state = + remember(steps, valueRange) { + SliderState( + value, + steps, + onValueChangeFinished, + valueRange, + ) + } + + state.onValueChangeFinished = onValueChangeFinished + state.onValueChange = onValueChange + state.value = value + + Slider( + state = state, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + colors = colors, + ) +} + +@Composable +fun Slider( + state: SliderState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + require(state.steps >= 0) { "steps should be >= 0" } + + BasicSlider(modifier = modifier, state = state, colors = colors, enabled = enabled, interactionSource = interactionSource) +} + +@Composable +fun RangeSlider( + value: ClosedFloatingPointRange, + onValueChange: (ClosedFloatingPointRange) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + @IntRange(from = 0) steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val state = + remember(steps, valueRange) { + RangeSliderState( + value.start, + value.endInclusive, + steps, + onValueChangeFinished, + valueRange, + ) + } + + state.onValueChangeFinished = onValueChangeFinished + state.onValueChange = { onValueChange(it.start..it.endInclusive) } + state.activeRangeStart = value.start + state.activeRangeEnd = value.endInclusive + + RangeSlider( + state = state, + modifier = modifier, + enabled = enabled, + colors = colors, + startInteractionSource = startInteractionSource, + endInteractionSource = endInteractionSource, + ) +} + +@Composable +fun RangeSlider( + state: RangeSliderState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: SliderColors = SliderDefaults.colors(), + startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + require(state.steps >= 0) { "steps should be >= 0" } + + BasicRangeSlider( + modifier = modifier, + state = state, + enabled = enabled, + startInteractionSource = startInteractionSource, + endInteractionSource = endInteractionSource, + colors = colors, + ) +} + +@Stable +object SliderDefaults { + @Composable + fun colors( + thumbColor: Color = LumoTheme.colors.primary, + activeTrackColor: Color = LumoTheme.colors.primary, + activeTickColor: Color = LumoTheme.colors.onPrimary, + inactiveTrackColor: Color = LumoTheme.colors.secondary, + inactiveTickColor: Color = LumoTheme.colors.primary, + disabledThumbColor: Color = LumoTheme.colors.disabled, + disabledActiveTrackColor: Color = LumoTheme.colors.disabled, + disabledActiveTickColor: Color = LumoTheme.colors.disabled, + disabledInactiveTrackColor: Color = LumoTheme.colors.disabled, + disabledInactiveTickColor: Color = Color.Unspecified, + ) = SliderColors( + thumbColor = thumbColor, + activeTrackColor = activeTrackColor, + activeTickColor = activeTickColor, + inactiveTrackColor = inactiveTrackColor, + inactiveTickColor = inactiveTickColor, + disabledThumbColor = disabledThumbColor, + disabledActiveTrackColor = disabledActiveTrackColor, + disabledActiveTickColor = disabledActiveTickColor, + disabledInactiveTrackColor = disabledInactiveTrackColor, + disabledInactiveTickColor = disabledInactiveTickColor, + ) +} + +@Preview +@Composable +private fun SliderPreview() { + LumoTheme { + Column( + modifier = + Modifier + .background(Color.White) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + BasicText( + text = "Slider Components", + style = LumoTheme.typography.h3, + ) + + Column { + BasicText( + text = "Basic Slider", + style = LumoTheme.typography.h4, + ) + var value by remember { mutableFloatStateOf(0.5f) } + Slider( + value = value, + onValueChange = { value = it }, + modifier = Modifier.fillMaxWidth(), + ) + } + + Column { + BasicText( + text = "Stepped Slider (5 steps)", + style = LumoTheme.typography.h4, + ) + var value by remember { mutableFloatStateOf(0.4f) } + Slider( + value = value, + onValueChange = { value = it }, + steps = 4, + modifier = Modifier.fillMaxWidth(), + ) + } + + Column { + BasicText( + text = "Custom Range (0-100)", + style = LumoTheme.typography.h4, + ) + var value by remember { mutableFloatStateOf(30f) } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Slider( + value = value, + onValueChange = { value = it }, + valueRange = 0f..100f, + modifier = Modifier.weight(1f), + ) + BasicText( + text = "${value.toInt()}", + style = LumoTheme.typography.body1, + modifier = Modifier.width(40.dp), + ) + } + } + + Column { + BasicText( + text = "Disabled States", + style = LumoTheme.typography.h4, + ) + Slider( + value = 0.3f, + onValueChange = {}, + enabled = false, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Slider( + value = 0.7f, + onValueChange = {}, + enabled = false, + modifier = Modifier.fillMaxWidth(), + ) + } + + Column { + BasicText( + text = "Custom Colors", + style = LumoTheme.typography.h4, + ) + var value by remember { mutableFloatStateOf(0.5f) } + Slider( + value = value, + onValueChange = { value = it }, + colors = + SliderDefaults.colors( + thumbColor = LumoTheme.colors.error, + activeTrackColor = LumoTheme.colors.error, + inactiveTrackColor = LumoTheme.colors.error.copy(alpha = 0.3f), + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + Column { + BasicText( + text = "Interactive Slider", + style = LumoTheme.typography.h4, + ) + var value by remember { mutableFloatStateOf(50f) } + var isEditing by remember { mutableStateOf(false) } + BasicText( + text = if (isEditing) "Editing..." else "Value: ${value.toInt()}", + style = LumoTheme.typography.body1, + ) + Slider( + value = value, + onValueChange = { + value = it + isEditing = true + }, + valueRange = 0f..100f, + onValueChangeFinished = { isEditing = false }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Preview +@Composable +private fun RangeSliderPreview() { + LumoTheme { + Column( + modifier = + Modifier + .background(Color.White) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + BasicText( + text = "Range Slider Components", + style = LumoTheme.typography.h3, + ) + + Column { + BasicText( + text = "Basic Range Slider", + style = LumoTheme.typography.h4, + ) + var range by remember { mutableStateOf(0.2f..0.8f) } + RangeSlider( + value = range, + onValueChange = { range = it }, + modifier = Modifier.fillMaxWidth(), + ) + } + + Column { + BasicText( + text = "Stepped Range Slider (5 steps)", + style = LumoTheme.typography.h4, + ) + var range by remember { mutableStateOf(0.2f..0.6f) } + RangeSlider( + value = range, + onValueChange = { range = it }, + steps = 4, + modifier = Modifier.fillMaxWidth(), + ) + } + + Column { + BasicText( + text = "Custom Range (0-100)", + style = LumoTheme.typography.h4, + ) + var range by remember { mutableStateOf(20f..80f) } + Column { + RangeSlider( + value = range, + onValueChange = { range = it }, + valueRange = 0f..100f, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + BasicText( + text = "Start: ${range.start.toInt()}", + style = LumoTheme.typography.body1, + ) + BasicText( + text = "End: ${range.endInclusive.toInt()}", + style = LumoTheme.typography.body1, + ) + } + } + } + + Column { + BasicText( + text = "Disabled State", + style = LumoTheme.typography.h4, + ) + RangeSlider( + value = 0.3f..0.7f, + onValueChange = {}, + enabled = false, + modifier = Modifier.fillMaxWidth(), + ) + } + + Column { + BasicText( + text = "Custom Colors", + style = LumoTheme.typography.h4, + ) + var range by remember { mutableStateOf(0.3f..0.7f) } + RangeSlider( + value = range, + onValueChange = { range = it }, + colors = + SliderDefaults.colors( + thumbColor = LumoTheme.colors.error, + activeTrackColor = LumoTheme.colors.error, + inactiveTrackColor = LumoTheme.colors.error.copy(alpha = 0.3f), + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + Column { + BasicText( + text = "Interactive Range Slider", + style = LumoTheme.typography.h4, + ) + var range by remember { mutableStateOf(30f..70f) } + var isEditing by remember { mutableStateOf(false) } + BasicText( + text = if (isEditing) "Editing..." else "Range: ${range.start.toInt()} - ${range.endInclusive.toInt()}", + style = LumoTheme.typography.body1, + ) + RangeSlider( + value = range, + onValueChange = { + range = it + isEditing = true + }, + valueRange = 0f..100f, + onValueChangeFinished = { isEditing = false }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} From d7ee18855c434ca306dc9f3178c6f848f8880205 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Sun, 30 Mar 2025 14:34:41 +0800 Subject: [PATCH 203/213] =?UTF-8?q?refactor(lmedia):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=89=AB=E6=8F=8F=E9=80=BB=E8=BE=91=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=AD=8C=E6=9B=B2=E4=BF=A1=E6=81=AF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA-=20=E5=9C=A8=20Api30MediaStoreScanner=20=E4=B8=AD?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E9=9F=B3=E9=A2=91=E6=AF=94=E7=89=B9?= =?UTF-8?q?=E7=8E=87=E7=9A=84=E6=89=AB=E6=8F=8F=20-=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=20Audio=20=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84=EF=BC=8C?= =?UTF-8?q?=E5=B0=86=E6=AF=94=E7=89=B9=E7=8E=87=E9=BB=98=E8=AE=A4=E5=80=BC?= =?UTF-8?q?=E8=AE=BE=E4=B8=BA=20-1=20-=20=E4=BB=8E=20MediaStoreScanner=20?= =?UTF-8?q?=E4=B8=AD=E7=A7=BB=E9=99=A4=E5=AF=B9=E6=AF=94=E7=89=B9=E7=8E=87?= =?UTF-8?q?=E7=9A=84=E5=86=97=E4=BD=99=E6=89=AB=E6=8F=8F=20-=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20SongInformationCard=20=E4=B8=AD=E7=9A=84=E6=AF=94?= =?UTF-8?q?=E7=89=B9=E7=8E=87=E6=98=BE=E7=A4=BA=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E4=BB=85=E5=BD=93=E6=AF=94=E7=89=B9=E7=8E=87=E5=A4=A7=E4=BA=8E?= =?UTF-8?q?=200=20=E6=97=B6=E6=89=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/new_screen/detail/SongInformationCard.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt index e4de7ad35..b1b2935b6 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt @@ -64,10 +64,12 @@ fun SongInformationCard( }, ) - ColumnItem( - title = "平均码率", - content = remember(song) { "%.1f kbps".format(song.fileInfo.bitrate / 1000f) }, - ) + if (song.fileInfo.bitrate > 0) { + ColumnItem( + title = "平均码率", + content = remember(song) { "%.1f kbps".format(song.fileInfo.bitrate / 1000f) }, + ) + } song.metadata.dateAdded.let { date -> ColumnItem( From 6dd097546dd26363fdb0f2f4e771c5f2a68e93b5 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Sun, 30 Mar 2025 14:35:44 +0800 Subject: [PATCH 204/213] =?UTF-8?q?[modify]=E6=9B=B4=E6=96=B0lmedia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lmedia | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lmedia b/lmedia index 8ff82a4a4..f26d1b5d7 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 8ff82a4a411ca5b731353c0e7d9c11c23c1eee1d +Subproject commit f26d1b5d71f42c3b451bfcffe8765482906b0aa7 From 3575e79853961051fa02af2acf6f9dfc34b38ff2 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 31 Mar 2025 01:14:23 +0800 Subject: [PATCH 205/213] =?UTF-8?q?fix(lplayer):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=AD=8C=E6=9B=B2=E5=88=87=E6=8D=A2=E6=97=B6=E7=9A=84=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=9B=B4=E6=96=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 onMediaMetadataChanged 回调中添加了当前媒体项的更新逻辑 - 通过 browser.currentMediaItem 获取最新的媒体项信息 - 优化了歌曲时长的获取逻辑,但仍需解决可能获取到上一首歌曲时长的问题 --- lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index 7ded75b79..a0229d98f 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -197,6 +197,7 @@ object MPlayer : CoroutineScope { } override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + currentMediaItem = browser.currentMediaItem currentMediaMetadata = mediaMetadata currentDuration = mediaMetadata.durationMs ?: browser.duration // TODO 此处获取到的duration仍然可能是上一首歌曲的时长 From 18dd3ee18dfc4107ce22c2d10894bc80b5d6b571 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 31 Mar 2025 23:47:28 +0800 Subject: [PATCH 206/213] =?UTF-8?q?fix:=20=E5=85=B3=E9=97=AD=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=88=97=E8=A1=A8=E7=9A=84=E6=BB=9A=E5=8A=A8=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=E6=95=88=E6=9E=9C=EF=BC=8C=E8=A7=A3=E5=86=B3=E5=9C=A8?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E6=BB=9A=E5=8A=A8=E6=97=B6=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=8A=BD=E5=8A=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index b6f970a03..4fa8f1752 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -135,6 +135,7 @@ fun PlaylistLayout( state = listState, modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 200.dp), + overscrollEffect = null ) { items( items = actualItems, From b8636b3081c4180252c4776ecc0d75f15ba02b9a Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Mon, 7 Apr 2025 02:14:56 +0800 Subject: [PATCH 207/213] =?UTF-8?q?feat(lplayer):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=8C=87=E7=BA=B9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 SongInformationCard 中添加音频指纹信息显示 - 使用 Fpcalc 库计算音频指纹- 优化版本名称后缀格式 - 更新 Koin 依赖注入方式 - 调整日期格式化方法 --- app/build.gradle.kts | 4 +- .../new_screen/detail/SongInformationCard.kt | 39 ++++++++++++++++++- lmedia | 2 +- lplayer/build.gradle.kts | 1 + .../com/lalilu/lplayer/service/MService.kt | 9 +++-- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e4799771..84ed49a20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,7 +18,7 @@ val keystoreProps = rootProject.file("keystore.properties") .takeIf { it.exists() } ?.let { Properties().apply { load(FileInputStream(it)) } } -fun releaseTime(pattern: String = "yyyyMMdd_HHmmZ"): String = SimpleDateFormat(pattern).run { +fun releaseTime(pattern: String = "MMdd_HHmm"): String = SimpleDateFormat(pattern).run { timeZone = TimeZone.getTimeZone("Asia/Shanghai") format(Date()) } @@ -97,7 +97,7 @@ android { isMinifyEnabled = true isShrinkResources = true - versionNameSuffix = "-ALPHA_${releaseTime()}" + versionNameSuffix = "-Aplha-${releaseTime()}" applicationIdSuffix = ".alpha" proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), diff --git a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt index b1b2935b6..e75f08a6a 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt @@ -11,6 +11,8 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -19,13 +21,19 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.blankj.utilcode.util.ConvertUtils import com.blankj.utilcode.util.ToastUtils +import com.lalilu.fpcalc.Fpcalc +import com.lalilu.fpcalc.FpcalcParams import com.lalilu.lmedia.entity.LSong +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.text.DateFormat import java.text.SimpleDateFormat @@ -112,9 +120,35 @@ fun SongInformationCard( title = "文件位置", content = path, verticalAlignment = Alignment.Top, - showBorder = false ) } + + val chromaResult = remember { mutableStateOf("") } + val context = LocalContext.current + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + val fileDescriptor = context.contentResolver + .openFileDescriptor(song.uri, "r") + + fileDescriptor?.use { + val result = Fpcalc.calc( + FpcalcParams( + targetFd = it.fd, + targetFilePath = "" + ) + ) + chromaResult.value = result.fingerprint + } + } + } + + ColumnItem( + title = "音频指纹", + content = "{${chromaResult.value}}", + verticalAlignment = Alignment.Top, + showBorder = false + ) } } } @@ -124,6 +158,7 @@ fun ColumnItem( modifier: Modifier = Modifier, title: String, content: String, + maxLines: Int = 5, verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, showBorder: Boolean = true ) { @@ -166,6 +201,8 @@ fun ColumnItem( .alpha(0.9f), text = content, textAlign = TextAlign.End, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.caption, ) } diff --git a/lmedia b/lmedia index f26d1b5d7..b6a2944c1 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit f26d1b5d71f42c3b451bfcffe8765482906b0aa7 +Subproject commit b6a2944c10f419695e117e935bb64f19a1b79034 diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index 108c2ceb8..9703078da 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -31,4 +31,5 @@ dependencies { implementation(libs.startup.runtime) api(libs.bundles.media3) + api("com.github.cy745:fpcalc:1.2") } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index b1dbd3c32..19e7f629f 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -41,14 +41,14 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.koin.android.ext.android.inject +import org.koin.android.ext.android.getKoin import org.koin.core.qualifier.named import kotlin.coroutines.CoroutineContext @OptIn(UnstableApi::class) class MService : MediaLibraryService(), CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() - private val historyAnalyticsListener by inject(named("history_analytics_listener")) + private val historyAnalyticsListener by getKoin().injectOrNull(named("history_analytics_listener")) private var exoPlayer: Player? = null private var mediaSession: MediaLibrarySession? = null @@ -74,7 +74,10 @@ class MService : MediaLibraryService(), CoroutineScope { .setAudioAttributes(defaultAudioAttributes, MPlayerKV.handleAudioFocus.value != false) .setMaxSeekToPreviousPositionMs(Long.MAX_VALUE) // 避免播放上一首需要点两次 .build() - .apply { addAnalyticsListener(historyAnalyticsListener) } + .apply { + historyAnalyticsListener + ?.let { addAnalyticsListener(it) } + } .setUpQueueControl() mediaSession = MediaLibrarySession From ee365aed02774600c9c68ec3bb1a59f32cbef138 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 13 Apr 2025 15:09:32 +0800 Subject: [PATCH 208/213] =?UTF-8?q?feat(playback):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=A8=A1=E5=BC=8F=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91?= =?UTF-8?q?-=20=E5=B0=86=E6=92=AD=E6=94=BE=E6=A8=A1=E5=BC=8F=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=9C=A8=20MPlayerKV=20=E4=B8=AD=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=8C=81=E4=B9=85=E5=8C=96-=20=E5=9C=A8=20MService=20?= =?UTF-8?q?=E4=B8=AD=E6=B7=BB=E5=8A=A0=E6=92=AD=E6=94=BE=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E7=9B=91=E5=90=AC=E5=B9=B6=E6=9B=B4=E6=96=B0=20ExoPlayer=20?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE-=20=E4=BC=98=E5=8C=96=20MPlayer=20=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E6=92=AD=E6=94=BE=E6=A8=A1=E5=BC=8F=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E9=80=BB=E8=BE=91=20-=20=E9=87=8D=E6=9E=84=20PlayMode=20?= =?UTF-8?q?=E7=B1=BB=EF=BC=8C=E4=BD=BF=E7=94=A8=E6=9E=9A=E4=B8=BE=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=E6=8E=A5=E5=8F=A3=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/lalilu/lplayer/MPlayer.kt | 4 +-- .../main/java/com/lalilu/lplayer/MPlayerKV.kt | 1 + .../com/lalilu/lplayer/extensions/PlayMode.kt | 16 ++++++---- .../lplayer/extensions/QueueControlPlayer.kt | 3 ++ .../com/lalilu/lplayer/service/MService.kt | 30 +++++++++++++++++-- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index a0229d98f..4857fa774 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -99,7 +99,7 @@ object MPlayer : CoroutineScope { PlayerAction.Pause -> browser.pause() PlayerAction.SkipToNext -> { - if (browser.playMode is PlayMode.Shuffle) { + if (browser.playMode == PlayMode.Shuffle) { browser.sendCustomCommand( CustomCommand.SeekToNext.toSessionCommand(), Bundle.EMPTY @@ -110,7 +110,7 @@ object MPlayer : CoroutineScope { } PlayerAction.SkipToPrevious -> { - if (browser.playMode is PlayMode.Shuffle) { + if (browser.playMode == PlayMode.Shuffle) { browser.sendCustomCommand( CustomCommand.SeekToPrevious.toSessionCommand(), Bundle.EMPTY diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt index 4cc3175de..84e2ea63f 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt @@ -6,4 +6,5 @@ object MPlayerKV : BaseKV(prefix = "mplayer") { val historyPlaylistIds = obtainList("history_playlist_ids") val handleAudioFocus = obtain("handleAudioFocus") val handleBecomeNoisy = obtain("handleBecomeNoisy") + val playMode = obtain("play_mode") } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt index 831b1532d..ccef43f25 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt @@ -2,10 +2,10 @@ package com.lalilu.lplayer.extensions import androidx.media3.common.Player -sealed interface PlayMode { - data object ListRecycle : PlayMode - data object RepeatOne : PlayMode - data object Shuffle : PlayMode +enum class PlayMode { + ListRecycle, + RepeatOne, + Shuffle; companion object { fun of(repeatMode: Int, shuffleModeEnabled: Boolean): PlayMode { @@ -13,13 +13,17 @@ sealed interface PlayMode { if (shuffleModeEnabled) return Shuffle return ListRecycle } + + fun from(string: String?): PlayMode { + return string?.let { valueOf(it) } ?: ListRecycle + } } } var Player.playMode get() = PlayMode.of(repeatMode, shuffleModeEnabled) set(value) { - shuffleModeEnabled = value is PlayMode.Shuffle - repeatMode = if (value is PlayMode.RepeatOne) Player.REPEAT_MODE_ONE + shuffleModeEnabled = value == PlayMode.Shuffle + repeatMode = if (value == PlayMode.RepeatOne) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_ALL } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt index 2d93307f6..f800c2474 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt @@ -23,6 +23,7 @@ internal class QueueControlPlayer(player: ExoPlayer) : ForwardingPlayer(player), private fun tryMoveNext() { if (playMode == PlayMode.Shuffle) { val target = getRandomNextIndex() + if (target < 0) return val targetMediaItem = currentTimeline .getWindow(target, Timeline.Window()) @@ -36,6 +37,8 @@ internal class QueueControlPlayer(player: ExoPlayer) : ForwardingPlayer(player), } private fun getRandomNextIndex(): Int { + if (currentTimeline.windowCount <= 0) return -1 + val maxIndex = currentTimeline.windowCount - 1 val currentIndex = currentMediaItemIndex diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index 19e7f629f..3bac5580a 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -30,6 +30,8 @@ import com.google.common.util.concurrent.ListenableFuture import com.lalilu.lmedia.LMedia import com.lalilu.lplayer.MPlayerKV import com.lalilu.lplayer.extensions.FadeTransitionRenderersFactory +import com.lalilu.lplayer.extensions.PlayMode +import com.lalilu.lplayer.extensions.playMode import com.lalilu.lplayer.extensions.setUpQueueControl import com.lalilu.lplayer.service.CustomCommand.SeekToNext import com.lalilu.lplayer.service.CustomCommand.SeekToPrevious @@ -75,8 +77,8 @@ class MService : MediaLibraryService(), CoroutineScope { .setMaxSeekToPreviousPositionMs(Long.MAX_VALUE) // 避免播放上一首需要点两次 .build() .apply { - historyAnalyticsListener - ?.let { addAnalyticsListener(it) } + historyAnalyticsListener?.let { addAnalyticsListener(it) } + addListener(MPlayerListener(this)) } .setUpQueueControl() @@ -115,6 +117,30 @@ class MService : MediaLibraryService(), CoroutineScope { ?.setHandleAudioBecomingNoisy(it != false) } }.launchIn(this) + + MPlayerKV.playMode.flow().onEach { + withContext(Dispatchers.Main) { + exoPlayer?.playMode = PlayMode.from(it) + } + }.launchIn(this) + } +} + +private class MPlayerListener(val player: Player) : Player.Listener { + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + val playMode = PlayMode.of( + repeatMode = player.repeatMode, + shuffleModeEnabled = shuffleModeEnabled + ) + MPlayerKV.playMode.value = playMode.name + } + + override fun onRepeatModeChanged(repeatMode: Int) { + val playMode = PlayMode.of( + repeatMode = repeatMode, + shuffleModeEnabled = player.shuffleModeEnabled + ) + MPlayerKV.playMode.value = playMode.name } } From 51c053561926d2998697daa05c953ce49e2b0a19 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 13 Apr 2025 15:42:11 +0800 Subject: [PATCH 209/213] =?UTF-8?q?feat(lalbum):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=93=E8=BE=91=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=9B=BE=E7=89=87=E6=B7=A1?= =?UTF-8?q?=E5=85=A5=E6=95=88=E6=9E=9C-=20=E5=9C=A8=20AlbumDetailScreenCon?= =?UTF-8?q?tent=20=E4=B8=AD=E6=B7=BB=E5=8A=A0=20Box=20=E5=B8=83=E5=B1=80?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E4=BA=8E=E5=B1=95=E7=A4=BA=E4=B8=93=E8=BE=91?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=20-=20=E4=BD=BF=E7=94=A8=20Image=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E6=98=BE=E7=A4=BA=E9=BB=98=E8=AE=A4=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=20-=20=E4=BD=BF=E7=94=A8=20AsyncImage=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=BC=82=E6=AD=A5=E5=8A=A0=E8=BD=BD=E4=B8=93?= =?UTF-8?q?=E8=BE=91=E5=B0=81=E9=9D=A2=EF=BC=8C=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=A4=E5=8F=89=E6=B7=A1=E5=85=A5=E6=95=88=E6=9E=9C=20-=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20coil3=20=E7=89=88=E6=9C=AC=E8=87=B33.1.0?= =?UTF-8?q?=20=E4=BB=A5=E6=94=AF=E6=8C=81=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 2 +- .../lalbum/screen/AlbumDetailScreenContent.kt | 35 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5b7952d1..cb4293bd9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ accompanist_version = "0.32.0" voyager = "1.1.0-beta03" lottie-compose = "6.6.0" -coil3_version = "3.0.4" +coil3_version = "3.1.0" utilcodex_version = "1.31.1" # androidx diff --git a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt index 8000618c6..2e84fc329 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt @@ -1,10 +1,14 @@ package com.lalilu.lalbum.screen +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -24,10 +28,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade import com.gigamole.composefadingedges.FadingEdgesGravity import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig @@ -130,7 +138,7 @@ fun AlbumDetailScreenContent( .statusBarsPadding(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - AsyncImage( + Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) @@ -138,11 +146,26 @@ fun AlbumDetailScreenContent( width = 1.dp, color = Color.White.copy(0.3f), shape = RoundedCornerShape(8.dp) - ), - model = album, - contentScale = ContentScale.FillWidth, - contentDescription = "Album art" - ) + ) + .animateContentSize() + ) { + Image( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + painter = painterResource(com.lalilu.component.R.drawable.ic_music_2_line_100dp), + contentDescription = null + ) + AsyncImage( + modifier = Modifier.fillMaxWidth(), + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(album) + .crossfade(true) + .build(), + contentScale = ContentScale.FillWidth, + contentDescription = "Album art" + ) + } Text( text = album?.name ?: "Unknown", From 01032d394d27e68a5cd35c97cf669e44a08f720a Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 13 Apr 2025 17:10:12 +0800 Subject: [PATCH 210/213] =?UTF-8?q?build(lplayer):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E5=BA=93=E7=89=88=E6=9C=AC=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20FLAC=20=E8=A7=A3=E7=A0=81=E5=99=A8=E6=94=AF?= =?UTF-8?q?=E6=8C=81-=20=E5=9C=A8=20build.gradle.kts=20=E4=B8=AD=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20FLAC=20=E8=A7=A3=E7=A0=81=E5=99=A8=E5=BA=93=20-?= =?UTF-8?q?=E5=B0=86=20Media3=20=E5=BA=93=E7=89=88=E6=9C=AC=E4=BB=8E1.5.1?= =?UTF-8?q?=20=E5=8D=87=E7=BA=A7=E5=88=B0=201.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 2 +- lplayer/build.gradle.kts | 1 + lplayer/libs/lib-decoder-flac-release.aar | Bin 0 -> 624596 bytes 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 lplayer/libs/lib-decoder-flac-release.aar diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cb4293bd9..ebdcff21b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ startup-runtime = "1.2.0" activity-compose = "1.10.1" room_version = "2.6.1" media = "1.7.0" -media3 = "1.5.1" +media3 = "1.6.0" gson = "2.11.0" flyjingfish-aop = "1.9.7" paging_version = "3.3.6" diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index 9703078da..25397545a 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(project(":lmedia")) implementation(libs.startup.runtime) + api(files("libs/lib-decoder-flac-release.aar")) api(libs.bundles.media3) api("com.github.cy745:fpcalc:1.2") } \ No newline at end of file diff --git a/lplayer/libs/lib-decoder-flac-release.aar b/lplayer/libs/lib-decoder-flac-release.aar new file mode 100644 index 0000000000000000000000000000000000000000..acbdf134c2786c738f29361a3f41412bac2970a1 GIT binary patch literal 624596 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%0Ae8r3W9QKUCq?XRm|FG zVC%R)ZR)`gSVU9Zbn))KKlwqW?Eri&0MauN=RP9jxQ67E0q!O3c3ZaW2Yn0sv`JIJ zqn3=jI>80e(+3vvExAwC5`g@55a+M@dCnPJMl+tCq1#1@u?SXBuTJGf`G8=%umf0G z5@LBZTGy^NCfSKKq3VeZvyW;pJ8GIXwmWYapohF#p*@c#ZA~liCUP7G$vGsEe`2lU>w4uC27i{T2!EN==2W7*UzH!AA1|vV%CCQi0DG)aIGMK3k`=Ib@3P z;o!SDZ(_60aoxGz&MaDO20VZRwrJA@{Rl$p0Iwv@dG~~3ouD*sc~UFPI_lHJ#v{Ok zf?jZs9qcYcQqj$ufTE%LwJXC8nR{N$2-v}I*R`8DrmJDmIZM}<=Z;E=BHN2>1a6nti1PB6}q5DXwby~RQ=b*O&(+gkcM%6gY zg;rnGE`A@vC079fM1;4m(=I%CUEn;u z>6<(l2v@>`X?nlGZqzqbgLGt9fZ007kISL1Y~`prsA~~6)ak}La)w(OAUVd_IBCsH zn5aMfCqOFK6y(4)Cw>p6PW?LG`7~2-P6w{S6j(xJhMJzzNGZ_CA^-D!=kg{DiF}}q zv@D&=E(p(N#UWEwE^Po`JNq3$uU>%;lj?FQobBgbMzVIa=Guv|M()Rs4AL{V!;;jI z=m(P4cFI=wFSA`e&LV~FJ+Ba8yDml{`j2z;!0ToEsj$5m;YL*Eo_JFSit6mcQun}( zGpxtjvxki8=cNfW!83{ya0$A#da#r?v}Ckpg;%o?pk@~^AZEul* zn0khsLe<)kdbF6AL#b_BH80`sdt>>06Oi3uUU39Q3n=ooxah-~;)dRfz{2A%~udZyMpcC#E>at#sPbuP^Z88jCktZ_k61;E0DKTz2lt=S)56K5vA|^@0WL3 zUJUPNo7b{6mGd9i5aP%b5D;g6@>vmHndzLJnw;x@J>pVQnaThrqR@Yt3_@Md@MIB} zC`O0=&;5csK zQLe#Z3Fp}yLRf<2*<>^2Y~fuP=g8B#jHPbQY2|hGiaQLHkok$S$6_j+E|6@tA3x0S zSB%hhaN!uCW1-Kn3pN^*(7S`UGU+Wd#cW<3K8kFt{Y(tD{rvl0-TFb6zAA$h=+^2| zlB?*bheG$lY1#~B)AwIFa5opkYPW?9NLb|T{XJ&JRkavTNlG zv!3UOFg_U6in^bWWL%1pxDF*4dpKm22?omSNh;OT3u1e3hP*6@r!KJeFjpIG=GaVjgvh3ziLTwBaEQYx1Rw4it<7azMM& zT8ZlNxHb4utR0`x-jg(-LH8%pD$E)~?S8OU7;2`KCd+4y3BRAKU-YD|Jw=__qk0UT z-=Ia)egH+$Ju7~!e}7eR&u$xi1X@)z^w5>jqkUYge0$q8YP5Jk2lOPJEJhkI$Z)pH zcnnKEoR5MHHXn;%2wBub*;3)Xv8ZTv0lWD3Ub7Dte3;LVk58e_FfBtN1xu-1wQ(ZU z&ZIqXy#7aSY8-&BXR43|mC+N*c>@@zb>2Q<^hq2}m1yZikx?Q0G5Jn#F?mx#5+~33 z>he2zUDN|F+WU*wF4HFC>5!+I&89G`#3lB4_eL41On^Epa%hL>9wD6zZ%K)U`p{Q8 z?QbXjsjda72>Q4Wdm*s-kukj|Gm{H-ve{jqSW-psrgBV$ST^gHUSYmOlf$ddYJAIH zSI+3#{4S?;v@M?G0qjjQ$qnC4V}y>&t_SOU{G%p}5d2Ad!l9R##(BJ?0W*J%w7wy& zy~kH~&EqIgjfe#5tBY~rLCgKUh8)*mg78-Z+RjTkNx}YaBOZ~4W`5T96KLQ|RX5+z zo+)RDgJJdr1QF{51k-<*o`3H@&HCO8vGaW>JDf`A@<$;^u)qsJ*PQzMsYQfP$jBj! zxFzB9r?1E-iAklHwW#f#W>t99c#PHWYefa-nIg%_);FnZF)rS|J#VeT_iN4NJ>b{l z4#slK*UYan4!{3iyB_{Lb8mgp6>>li1p5M%!RExSp@AYy<4&}8SW-C{=nHS2dC%w1 zspz}|&R7qBW?q@vt;w@B^T{~Z=7K|*&2@z{SEskAZk2-C2gG`9mjx%ZidLsB_oy;% zELQq8*jkQb8Js^5r1e7QCO9SKY?HA2!i;vc@UTYZ&x@B<8du|yLXfl?)&-I#!_Odt&}5MH#ir))Claxa6sK#^DuFvJj!F-8jgj|i(U$v*`iFzlLyd6z zH1k>83xykv|50rOh-zsDMdq8L;LO4lliwqlY%FcQYp@XwkXyY^3cSoTJK!|_Dd-h!?O4W=fQfhQbYSw$J=+JUU1sG&qCeSQv_vnm!* zSWG9v$W)fn0C`CrUe!j#5&vCJY2)6rNV)NW;48xq(&XG~eB?T9a{~Y$bJdAHMPzSXYR#)HmS9A%(C)*3UBopR`Xv)+os>jb*)( z3$k0(UC#A~%iu!X+WUV?H2>^ul$L=v8~TF#H&-A{%mXALjWC9cUIvY};~iT|f9ef$ z4K$e0Li9USPt=W8nM^MDZcU|&=+V8cI4Zo<{ji}7(kr~?oy(Cm<|t0~mMVR?&6sGE zVK=U{>wOzX#79{koZ;7efi4sABSB;*kqP z>!tE6$p?{Dm$I9lgKO=KGywTr|9T|~dc$0}0v&GS^Mwx3!rUE`%)mFeK5{Az5O1Ms zGrJq6^Za^z-PmR4`SJp-Mh%K{P5nsBuJlvVWRc$IYBS29`Z$;3m}K z2cgEXlC5P)R8u%*5`H`wS|X25e8Pl|4jF4?QV{Ribr_h+5|~P-&k4Q^x60r=SKmCT zLvaul*dqIRTEfz%!<=Wyo#J3{|JiPz)4yg{ve|2r%G%XQ10q*Fv?eob5db`O1o3mI zzY_Iv&MDW8FV(E(_Ut*C>b(cgh@cdAC%4|y9kMUbcZc~^H>O&XZuMD# zvUN?Nuo0$|uj<0{#Fu0N=4hcAE@|4j;X?XprKZIdomiiiH*umksw*UhvzU<09q8}0 zdcc0;X$W2gMXKuz?JyM91L8pzIO>IprVyPAE1TfS#=EMj;%CmzfnJxsJ} zlz2Lg_e-@pP`BRD#T%yT|Bkv4qxMUba++313)vub>rxGl@eriZ9*rUS>Zp6%u(P_( z9)QW$IX{f&6FT(-Fkgb0&01b}!i>0zGX+>+n5nKn@6x)!wIvvS44_|0Sn`m!3=4k} zA_$}q_=JY_v?syG(vREt2GpxGt2utu*ev%v0d3jUUpu<-GZAo= zkpG6IYMk^7q-?TdYZm7fg!Bv2-h8l-z|<{R;f>$rxY!c~x0G?*-?Gaxuh(0CP0Tc{ zzJ{B~7@xO5PXFs*A`9FUk^(1MmaF8erSwoAzrRuj%nY}5U?3l48F)Pbg+T8Xb$y=) z^_Z1CoOQBF34bS+ZGJjNV-IDE$(~}SSHyDw$pVZz)Q|jLV(@6;_&Vired3ihh(CGI zgB{qaE)c0Me^wHX$8h7KRR?!7^-k2ZtN&PNyR1mUyT~C&q%do0hJlDy=I|*vaH1!s zDJY75?@wN%BoPYe%{y7?BCnP%hL%?&fPYu+PKcouE4Gt9i!B(MzLN%e+b_c0y3$(} z*||#k2I_UFQgG7!c9=aKPKcU50K>V=AWYFF#8DDz>}9**X|wGmL?8EKf|7f|s`2=q z?8HzSA5^;QZIpvwF>)CepNI$*U*GE!)9Pi;SlNlbN9$ZR&xWq{sUPAcK@#RMNEz-Pc_MH1ATiitebrk-zepL;&02n(GfxD( z9aZys2~M-q<#_blfG2D&a>NqasM@xWQwT8)-C?o(w6R6q)06{ehIns`S8%A3tRV5e`M0epye%3P@PWY7}QEwrfV^cQuf@7_<>)sG02Q+h=d;% z)b5)H@$1@+pYl^4W$e*)Vcckes`Svac3^~et=n1>{}rzAM2IFrDE>eRs-gj8e7cuR zqhE!X#v+j?WXU;&mwB9S?heUiKfrmOZm?ch9;L-0Rm8~d3F204E)uu(pp2p0_>K=uyvLq;Nv#1gAZ^+l!!sv<+O^qVIEe!NOpqh7UQDV%v70Z~?)*}X z^Qk!5e%AYAtxJdBgq5`Ly)aZ+P0ue@(bo#h@{91(N{#XSN&ZZme-*jV3O6SyJ!Q?XFHLF-fVU8GGNaWP^GK^= z>>24u3~uaBoV5i$Gv2PPOOWjG^l4yxt~zz3Y%X=xqahIX9;}#k=b?^XCWhT|ise~@ zv?c;AUk$$8`?L51Qimig&wjaYt`xSLA*B$5$hMq>PM0nDF-8v)1Fjvx@< z@AL%YmBhiaCNs1O0Cm-fc@jLu$erZ{F<#$e>MO8ye zEJ6{fx{kQHkTAD`!btj#|-JY6WEJ-u~jgYJxiqAZftdwyz z>c18@nmd5T(M%DuL#JRue|s9P+!6FttTG+2DKH|9aD?z5<|bY|_U8L3VU%2TKdq@Y z1&pc6@B`wrP@3DU&(wN|F!&&p`E`cGi#6a4JdY^N0$X)Jqo8-nev!7Kwz{Zqm z;RVl5iSE>4HAh+rREaz(_S={Ao2k~2U}hB+r{#z--#tSo#_|Z&pTKV*z?}I4SEr;C z@$)s1-V`pMr_*hEPS~UO(bg>3Gm~@;xrioLoZ0;~-?qhntjlAwSliN%LmL48<6oOR za+)O4c(j7%u5KHPb6*{gCDYuvQ2^Zqx9^{+6f}AK``}C~vH&a! zhuNqk)9?vICkdlFO=Q+0>)O){{|fw6&-QKGA@;Q^UF~Ut7BM)eN*$?6FBL== zgX8rZWU1z{Z9vXw!aY9|NGLPvv7KMn1#+f2+x`%~S|GfhP-i$5FH`&ERhcR9#-%Yw z^X}#7%_Mifr+#PmI(8YX_xSlCejn{)|JJ)`wSRBd943>P^ry|gRb5Y7F4=qo>{AwM zy@JMT$(hc~_d`m=2Rjvc=$@BVNvbuV{e1f&U&-^go7aSd2ymb{=A7+g{d(U&`8h*B z2pzx&G*Evk8@pADRXg;=^DRa3K9G}i+l^wmO2o)59rC%Du}}x_n(5e-6xAYsa=G!T z92Lb`%TmaV6q5?`ed?bOp)yyg{o>o{l8BxwKHjxrXB}kOWK~wQD51un5Mr>L?{h=- z?b0e3W>H~-!`JK|{!QcXtA?1=DEI7sKxo(j1`inI_ncv^6bKywvhK@#P>hBurSd|T zce%pc86TS?Ky-rnuFd^Z^@ygjp{o}cgX*-p)%C@_7JVm5kw+%YJ%?=B-TUQIMVfeo z{($|uWz)os)VGp`PLl(fvDM-+f+)ZZ@tgSfLBBK51W>xIgMxs7=81q%`Ummfy}SdY z@2wQq*b6c?0i&)f65iV-8rj@r5|D&MDQ3Y`uVA)6BW5X#GiYEY3u`uDT&=D*t;YNM zYNc$o%~);%Rtf8@x}{fL4QzG8)7h!%oI7_@_X?pY*(dwYtLc6`Jm*<=x^<8HGS^8j z@A1W_;j8ABkr8WP1)7%hgQ3$!d(3`n|!3g_lIQHq}J$RO#t9jOc!! zi{l*5AW+0?r=u1Nfp$|VkUb0R4EOFK@UoLTGBp9j;KFx6m5PxwL%mb^K3{lC?VSA` zo&K~S!_=G&xLm%}zDVjrt@YlBC6_WZadrucS0WSiH4VquWBZnewh2-p1qs!|@cLME zA@H95I1i74&fDGNAt}X6+r_PBA#K@y?3D)`4tES}21PwJ-ek}rbXtZe7~}K<-A3tc zAS8wK+v4CQBrdsOcZ7X|N5^t#HV~c!`Vv}TnyDsw@o1Gvg1TPaCFJOcGl}UgF0Lr0 z0B{7`d$gj3vX!%8c5pBTa4_~weOMWIu-!uCt2g;dl$ru#Od^8p3TmYLi2~woQtS-g zvb_&G*wxN=bsBm&DZPlYWLFckFY|tmz0EpDA}otyr~zEd$I@BV;n)9df5s7ccs z-3*i-MQn6yN4ftEDZx@IY};~sBdnt&5cP;LAR%SvdSk)mHA=-yY8e1AvLhHvM;`^c zn&0_n`MKm*@;6U?3*aJ{pbImdT(k)SpAcL=T};PntOm*3PMdmTKKU z9AtXC`wAX1m%$>dp0U6zK2H=JQ3$Y#u{370xfRZPCnt9j#@huXrCE=2wv8skq9BMM z%%QtEjD{^~X9Im}qJOq@3$4w%6s<{MQ=UXbd=pb>=$L!M(oOYgtLmxA01dwwspvQG zu2c5hTCJgMz6p^Wt!#P?oZN<1i}pYVB9e$K^0xkycPy{ zddn}EaV+`OPCKK)Eh!c|OZX?>T#?YsG$MBo_KysEe87yTl%>MkWZQJ^%NHjo@c3iC zO-I;I=rav=;_a*OGVV2L>pQP}>XDEpEmLy`m+HTr4g=nu%^6#JMWoWB8eBdkEmIQZ z`{U$29}r~P@UqrEkg8ej`uO;#(I*-8`pogP{VSUKZ=?j-impO;OJ|%vXJTbrBgo8# z+=hlFu_Taht#!Kpy)&>kzaj{m5slR$u-Cj|*z>|YX0j7j5lbxt4T8cZXD zrKfR1Tw)7fObIPztt;YM;UcKxN)&6H3D`m(V!)d>b(?8HfN@3r0BM8XTsKr1X_`mJ zv*}inlm_BPm9Jq(qV1?dKx*DHsTocb1;4d!U?Y6!Z5>fu8L1b#8&FX1WE%#$5Kr2C zSRM>Ju`oHy7b($c>c8V#9?GUhT5X9iqqKkfJanDYW8WflMzmM2Khw+WQv{e`f>t;_ ze1bH>usay$zJhva)%QkN=~glU1GO(NonoX^p4z_poZjdUR3;^^JWn;nC;RwcXi;$M zDUWgs7xZ7^DDA-#ih^GUxU?KVXmu-Sb|g_4UXLwvANMp|>NF{dcHK5Q=dFwW2YQ|i zS}u+*F_tVTGxoXK!!u#o7Q(uhx7GG69S`*`1kF~Hw*=X{vFa=rx%Ql(1Lrv?2^$|7 zU{l2S_MNvUm;oixQ;01|sh51+q@Zx8%{3=YP_+fojX7Xm?EwXm`}%JGj!);~L7{Hy zLPO_P;AQG5-PW+HJ%(exj5L~1RgkM!-0Oe$KcqCthushBp*>nP`c>+8tFtsk8u=cD z<9E#*S*1kS;}XC!ENzvIz?XMOy4@7p7e%6-!bSz`B$Y0MNxA7chvL{?g|NOo@{Qu&Ks(`R5-vdK%+y>4bQtc%ktPsBo^XSQxOel^F zXb*ztuHPfMoUSO*x>K|pxc8mS>2^_NH1;NvVYoZDB^aXQkBQR1Q91!DSXuGhd@f_3 zSgd)u+#0KAwH@ggt0IcIu3e}EUi1O=HX#7fMei-+ZE=)e3aFLf0wJ9VbcrV6iMS8i zOz^pcjF>f&0qy`fZc7VOVijTJK`>HjB4Q!jwH7KyOP}MYBVsz?q>DlDeqzKj@viz) zHSn5E2@*k8Ja&>HXRUsrSlzZL%%joO2{Ia7AtUn_7MMoU+78N-g z9Pyc4(1>o23No>#RW}#1qK~4jtXNh0mdG;`ey2Lr+Wc}{{`KKy|56u*(!Pp}y|Sjv z(o`wsEk>cc{Z}uqX@7L2)y~jVk3PR%4tUgnbv)`8JW4St^t70kY)?mH`Ce-JIVyuDGfqysRh~Yc%j^S1sPs^-C&m z+uzRP*4xgQnDQ~hqGi`U=^AQYI7KvOYFbxXP_Gm@HYKcLe+KCG;N3dKgaJUZ3($En zF0JkCpOn6J$g^d{L{;o*8IjhRHHHKk$>IRbl(X1b3boi9U_AnmFD?_pa;cw`t zktgvdr8<+r%fN%fm6@~D(Zt^;Y(A~(tm@Xv;q1H z;uIB{AABD>b-xW+x0f*1Sr8Rvt#cpQp6@&hnO%O+zh;xZZ9ofeu1C11JmsBZ#p!Hk zQDncJL0RG$QKV$RXyod8j;aQnjM8#11aIfS}lUr*b*gb-=Hjyu<}vOBqs|;;sd^f zV>pMBZYv=R*->7PLkp+e+X|Kl8q1whw$#FIE_L3Wj626mZtzAA(Q=EP9!~OUU1LC= zVKqVAJ=~zQ+;V#{qj$EYj=|lS@w9^k5#$ps4lQY5LnMIqvTjj?lA9?#P>65DlV^zM zqV0r^nZwm8_KnGQ*<>!JPlqcFnxI~fJy!N;co#Db>38>=5*2FcpLi!N6PK!8PfncL zPLpZ)>1;l0{uI0z3t6@*ts`#r?>SdkAP=lV1I{$|?*Tl=mQAJs_*tGJpeg!~jR}7)16xI}Cl(^b3UNTj(g=X2fH{(wJBC-gk|4qe-=1#Bq?yBH= z44Ao$_ISiN+WH#krZtGBt2M0EQu;@(sF7h-a(%rT&sTz4bF1;5=A=Qvr}6#sRM?Q} zVES3lZG@bZ9-Jbe%<9VVG4#0j=P=;2aMx%J;ftV=CR^fY2hP{X=!oPN+jHzWN`Pp- z@s4?pt(lSc!=}_&&(A`eR`#>M1}G)ndlvgSD{D3B)t2TYp8M@qY3T`s8 zS5(z^PKae!U}?`X-gcX1F&of2ddh3ALMGT7QK@C*#;=6R9114au$4^5TWE3(#1w>0 z<_6{d_!ZYQp+SP=nznfPBc@DS`|qeth{`X4Z3cd}&?}TD0O`4yo8rGSo}?d#5;$hX zExg^+>AT=S7Vhnzxxp!#_EQE}d&I%VDh%$zvHLpSL? zMm8Q>>f}0e`)AB4G-wdvtY+kE?m;^rq5mQ4yMv;Lo^>S%k|m>L5D<{88rtPK0p`o)lcD!E4{tR&%eYMK!yvanFJfahi3$-xB zbcHw%y?typv5+CroS`z%@;cP?^~?PlFa2luy0)pSwC^6gv~62$IXPou4c%a`(cek6 zE*4-o5A(ly!M~^YkThi#861E(k}lPJjaKx{t1#rEk_-H`!5a;Yy|;ck8*;|0=zwXD zSB^%rhN6P&+rIjdqczkn#&^L_)(()^V_$HPHtA;wkv=l0$EC87CBpZLjDxfV9LyQGV7Ghg8|!kwUC{_jwo z&#jrVXacOZv0Xd_Q=S!_30`WHb5|L*&|SXS`9>b*k)D|l9lxHecf*>g&^QA5FmwD% zWC9|>h`*pfGGow-kY;~SsQ)uHSL&AiD_7GxD!TK8@k2YIAq2OzN)(6+zI5Q^7!NO- zY41w-*)jsV0DN!dxHl<3i#iJiguJ6fXxx%?!S{>R@+t#dHhONrS&H8_Ui%}|X@nE6 zh;$fK6CW@3e;fL%+-O_#EDV?2s3k)$_4K5M-P5B-RY*a2%lhM1 zu9iDrgUa*^q&IE01BCALEv(evlJGO*j%ahBUNp8@%ygHI@hz$9dd4x z@4tvoq#J8huG&)9ZP9pgSq7(%*RT4!_<5mP*q-J--o8togY~ZF`RB&p_N4n=JU>}{ zpN#hKpT2w5F)$cyl>UvE`1Q+{`}*{Qr=Y*P5`e9&qvseweS)gVnOQ(Kzx$h>O68z; zzfw8I^kOJ0-XF7_68@DcW!B;mdsY>ZUPq*o5K3+o1pkBi6cQ_JkY>p{Ar&Xl}c!#O?sa?LJ#jdxLmjb>;z#0Bd znE{_TL>HD)W#AannOFYV!-Hi^TvG%X|UT9wXwmXwoF1~rRZ+#k)opvLZ zF#A>wa{)|c{9Y#6V~Th<#EjsaBJ;PTBMbdZVp1kidgZ6DdXmXN@6R#hMu=ZR5(S$-E!+PEmx@w+P?DC3j)5zRK|wro<8E=75OzbWDEt zYpCldsROD`ubBHqqGQQeM-KBeL4tv~<#xSeMtInWR=RS9-P?3M*Gb^_FLb|aR8j^L z^&a_jVHzkdsrcQ~xBlHQT{M`AymCop6c3*BNi#awdR1GY#P_af=HmnJkP<1fQ#(sbgV~Da*?D|Y#=p*++;}#A3A46A?%vgW(Qmn7 zeIz~3F^2_P?*C5Wm;R!##Uj-29)EUm@!55%BI-5jF6xW?JKMzOkkvR^`&;wb^I3o$ zeI#+ROI$Y_c!KX368j5s@oCbajAlb_KKPH+9)k*6bnQp`hw5?Ze48=GS0r3~tS170esK@@S zE2>Jw_9D9c^B=$#ZDP>cglx_e;(qfXBK?{MT0_zpx6F9L@wdpe@Hk~?ytV{2Usk#1 zW^}puSvURLEMuumVEPlE##gJCe;ad!o;@Oa#KhQ&`m!~?t@QyZ`NRXJ!uYPtKHODv3bVTSK;9WE!g!s0fC=9y;o#Ua;{YnXpt`chS%O+T~2{ zSWQ?_EUvzRY8_sZ5c^A!sW(@dAK-j#;up6CU~Uu*mSa*@kkT%n;~CSa4|y*&T;P;? zW09MJZehQSa&0>HPCvK3X-#5-k!nfOEhBm&HN)&bP19x^7o)lQ*67B$mUYSM)`?^b zt9ay1>T+B<>X-@;1X{MQf(d8BB^7N4ebWe;{b(J3ytwxx&kBarBZ7ZCjBb8_yI%7F z;)&`~1f7tfX#ZaKhl?k}Jb^<#?`Lge78HMuTWwo^#e-i3xJ7(ucSxiFqaEk$T*Cg42xyyc?1|QgM(po%>_*RAR18NH?jnax}7B$*b54Az-X*Q!>&DM=> zynF=nZ@YIc4l;(L$Al#EwcRsY*WI7_Aqocw!bjT}zNFYtQ6#mCa8q}bZk$@Ln3Xc@ z_frd!hfxAA2P#}N4vpk3Xfr{bMF2d`+^Bny$D`H@e;D-?RU`{=?cHmwtoGDIE>r;hBSs%4jYf%W8&SX8X zDJ`T?A@8m{mAgP}2fAF`?wnuc9kQd8@-B7*hc=To<&pk*Qj)c^pCheb6yz&ayx1o} z3;er%K5sTE_2R16+eR2n*85v8{v^|2@~MsE+Rf~*L)qTF$rn|+4V=^r#t9%RDaz4a zd6sU4h%W|{({a-}n(w!$S^@8VL#mWos27vK9ltkdqvAG%>GMW?2pb{J(_yt zvuD<0^NDk09l692Um`8#0}MvJcxTM(^uF`N!@R^}VUjb#8p9`Vp!|}w*I~QL(L^o< z2~XH-B`sc?YHayKdVjm+r|q)4GF#aFGZ)d~8H$oUqzlL%LeeFYj-L&Uu9$BtIn#Oo zOfBA&6-=90Ygfyilf;zG52hI4D=jHL$Xbcm{#Gf6dVA@PKC>l~t{c3Nr54pj=Fb_~ z)$6e_E$4jVl3q*rMW2`zv3c@{v#*Qm%yiWv^FR|>;lsdI&!l<3{8W%)=QGg)g8b?4 zi8GeUg>@~xUd97|ueha$$#e($>H@1X!cY1Taq4wRK1Hb^@heaPSJvtTW?_TX{)O}K z_^k*bQ1h2^wt%W1Z~_9dM%O21*@k)^lSv1DgT7;__N4V7;hKV;fhFr#Vyd=Bpw=X> zx12RkuKo~=wuhUVoS{qo2sMgA!zsg<2X(%RD0vU{F~TQV2@<1xJ`ZI4L?#pWv!^S4 z+)1~PT)2#U;`hn$oBq7Dq410f%`;h|<@3fO{&SDAuGq54dW-zNXUnZIi`@e1OkW>9 z@WF2lG^_-*ConoMWw*yD2toUBay1 z&i$`f!5N-XnHgtJi@?IDnzL=p$gr1yIZF;#{49N(*xz^it(%74Cb4q>w&q zmu2g8QRa^H$v+_ly-D^zDpUBCz^Hr^bMC+JZy2%Vk*nREswFBZ@Ul7CRVTaxV&V;X zJKc1mFLg2I3#HG1t&}*(XYxL5d>LNta3G#v=ab1|tRd-se{sWl(UY~f)nUu!x8}+F zOHRYW%Kl`1{j!5-y{L=g@Ne7}(j(&8oF@T<;yYKL=XEvfFNp*)=1ESDP{Kj8EXsAs z$R-{>#=SKIX#T;D@E*lbT>!#0RV7((cxim)%kaN}r~YgF59ytKib*}Jxx~szlsos*F*~Bxp z*YiL19hef>B&WG9`8hhbhJZEe4c#^?=1%&*82i28jP~sUA`PQ^M;I;=syw(ejGH)qaZ(c zV(LAlNIz{39*>NeXDtb_nBxyjrCnbTBefzsB&C`GVwDcZ<7@xeqI#yT>#ZLhZi&OLxbJgW`{s-h zCer2=DUGO2TfOM%7L7(fdQ%jCx^ar+jgNeLBR{)m6_L1BCT`gE+Bm^nI`TDOtLX@D zhUk%3^rvcugGwKk(iUn!naP7UlwB#vn#I$11thRMYsL%OjW7K0M2IrWV@bO zY5Z#6Q_{>J=Izuu5triqHR0fcR-3VZnEhwTjc-323`Lr6ZW{nc9sQ8}EXFm5npmVr5 zyk^+6&Mvq3X=1T@el*#y!AkpQdEA0fH6k4P85AZh+ zPEIB!lzq~bXxC2dq)5Np^>NYt(QCc3@10o0gQng9q0=rVNMHv=ItuQYA2aj%^rsS2 ztG^&?3xPvn-x`D7^EKEY$Dc6vOTG?9&{)U{EvikvB#mZ!+gtscIN`7Jw_54|#b=W9 zmmf67-ap^7j;<3qDbk!&P<9U-vY5`;_K?|l{hHl??*wFd#vgGMJ|A2(xYT+aVXsta ztVfcS!`$sBfidR&E~Nt~5tAu;B%-V|^v{UuIi#yZQ*K3Fa9E<7Q*diFgacyo*pYLP zk9c6(M6?ELX=EF8FoAhuEMb5ip%LJ-wOM;!p3gGZ8%!l?TyV$-=*=AMFa<%`13ofr z0Bu!^Kb*bBe?Z+>6DW-SXFoqlEKB|;`+H3LqW1*Mu$S31GLmlDT*3>E7J;kYnE+&~Z!b9BXAI}=$G%{hYBXYEh>93h(H|>50E}PuH>1)*4>wgQ>CH}mSLv9o_ zR=3{&D?_-Fwk~qAn5foIRWT?`lIZ+t?L$XFz`lg3nK`yG^dr?_|0uT}efrhva|7cv zWtShU-(*VvnKwnneD!(ataBr@E#X@F+bC~AUZ;bV<84#xTiK5_drT_YdXQT7_v+;d zTVHgIY2q8+2_Ewdunr?8KfIm2uu_Uzd%wT`=fE)b=fn@tukC4jCqh2P-pIq2uUPNm zWfoLG3Q$RFD_b-IjuyBE)lEx{G~nDwaD`mxgtseja~{*3!v(~x?0sEloX*uT&{cxt z!6*72BDBY?^{Obx<4^vbYQ4^wA|mORqQmhK6z6MKRFltMk`J#2Cy>xw>h==+kaSZ| zsuX&1KE@pQ@_}1eJVkyS?jKN`gYnOG*G93%O&2fSdp2pl{xopu5vrIsD)Nqk)*+wnr zla_|s^^bIV!YTRbtB?!#d@l7w#Rd19i^N_{03+;AKjxw^!slBA5 zXlioD+b+?Ek%z$8kT#)=IQ8z7J!NW!NyZFbaAOBAf68_<2dqUSJB;hW23_P8{OZ!z z>${hQF-yNVr(T{Fc1g&Qi&Z%)Mriq0^i0X8W*2p60rEMSaCFvGQD6S+vGe4 zD=R~Paqs(a2Lr4t_#*VL&zVbUom_!2?lDDO7a-tWctOhA!UUl-vazRwmsspoS@*aL zTVsdb**kv#D^BIRctj=d$5TFfH@}b?n{}Loil&R2x~n%eOQF|QF8cm|_e@6-5_Yb)mIkM&{h+%adj54`oENVBG%rHUu;DjJgjWL*ojzKc?db%d0E+7d07eH zS=7zW!^_dm(*RLTimyzrIs#HsqpQwQ@v7H-NO;}IOWC2tmHBZPZq#5hH?WNFe)RN{ zRHA^3W4*D@%bcc=VNs;&m(6vae?|et=nbW_!yd^Q4VQ$!5s7#oU|Sj8_E6ORK?mO> z{Nou=DqXZ^R*ZBYaKkh#LKV*Rabr9a2Kf5@J5d05!_|L=tuS5C&bgVp_ zC7%oVNL&3c?f2Z5I35-Jdwm*2_n7~e_7_KM`!7~DZ?!dqJze+O0?qWhaY`T`Rnt_u zF>+1kHvLZ~KT3`52MivDr>b`E*8Jsqn(q8jIE_B-@MjHI?a!LKQTJ0WjXsqjl|G9; z^}j|Yf9izm2I90mkI{iH;c)f%dbL(ER|1)Nnl&ZAd7C8@HDHsng5XX0i4Ay) ztzP~dbFdzz`m}8Zr=zEBApLlWG09?13sZhy`r=%FO)04?{oohL8WMd%l{5|*5Z3oH z!kERq>vAfa0(d@DD1_e1NaT^_K3R!i*o6-E8ASKO75Xy(GBfQqLQ-!`8{iDz#z+-KPQBcM_-vkjgiHMKE(sZQA`l|Atw0C(B3z0Qk}$1O9MQ|R2oOK$32(fqBim> zp@63yt4}VUk>v4qFurjsAw;Gv(bL8sza;^c6T)AG8fhLK@;+XTNQpn*L`w9q=UBx{ zX&mK~^jrqg1R4JnQ&gB>V$XGnPfA&0e{(c^)pO}hb0bH>R@=@5Hw|U~zSK{~bEA0# z=DEC4?YT^eOK=~cu?dPK)Ja={I=ua79t!5@fG013-?-_eJps@J1rsW%hC(wM`WagFkd~L+#HlPjo+AziZ=OCx!MDu4~~;= zYQrdm`~ITVQL>myq6a)#FVf%SuXKp(7n+xXe#ZL`F;j<($Jv~lBlKKwyDKiZ;Zjmy zc+N-?DV87vAa360xG21~cW+U`XzS{*-#$0bAi37LHV-EEl}qS3!C$W#ZvCexdfL^T$H5AyQ;P&yOnf2whst)||rrJar&v#8mLys1M$Q zu*XHqEX^pIF6F}?w2~YTEU4f-8i$pbE=}2NuzZYhmkf|-cKjRm^5K$@Eze)(h1vBw zg{EZl&02kq%FWd6+36%QBHvB|*jxSEvwJ*MUB}DQ?;jArq}BVbzV}tDkpV-QBNXY| z%SSB4hb{Kn-wj0MXcW$)E=aK1acA(Mq50|0BDc zdE+aqaiIr&BNbM7jWG+6@V+oLs8wVmruD}NQRX$XwXkn;6aBngNJ54WYyhNCN@ z-?b51VCVhOHXeIv()KHdPysT!3fH3EZU{hcXAy-E7nUDOT9z(ZQI~BJ41?6U;gU;$ z%?DSc zl_Y^P4Yq1ik8p#wUtK8w`3fCrBtiXvP{Kgul^q z+DF4`XY-*Rj@;|w^jIs)LQ7*O-`{4`N?b+uu!^rO14E?V+KV|ZQ%sjCQ1UidydH=u!R@--~sNNJv9XqPVBVxIBtnPAjpqo zL$@B|Zna=}M&+-9V?a1CSS#vjjJ7%c>~o!^#dtg-2Ab5Mw=F1u9?^>H(E#P0CIYMu zJ%-(RzVU}*)VJF!zPM7OBn;VGYrxNtt^oSjO znj$d*5{zb{Jrm)>me8)Y(63%gFBw_o%-$5k+RS1=t`gf9+OOMwajnD1u0C_hv%mI_ zP~=|=$H$Sa>I-Eaz6-fw;6f$dNPfKm`8@97{-EQMmxR^~eo6U*RUy>Fm1})5-lsVm zVQQZ%yyp7k1VGYp`60po!5UmreN76R+&=aY3@2Od8^4!(qq~JNoH@gOCfn$gDO7~x zVZ%JnRs#k6$at|-?88mB9k#771qSpEL50o>wknGU%D4vROS+Qw6pK3tglbP+A|`iN ziSo4lEbc3KM(07TSj=MG-5wm(ilUeUeuH2a_?Rex55h9Wu4xKC0Im$mkQ%!Hg}{Qv zw67rXw>1{KNmn-^A1VODfI&IUK9bnU0hx!9K97rBlc++YWdC-)-Wq#()tg3hU9oqU4Twrn`JDMuz3Uf;H~@1CG9m+?cS0?t-%-<+LN zOPpcbX){ofeA}A|Uq4N06b4OMDZug&;FV!mRsp`3hgoKZ(GS#Ih))*z;_MqxBY`WI z{9IhsA{E2-XLJ?$@u~)#kplGifoswo;V0K3<5eXAh;he(pbP>r4|gwl0{}0|LyEBIl6|WWjBe*7E}; z(~%^D^+%K#_#gDV1T;?ytCa(cHX zHuk(#*lep7t%$iWiZhHP!uY9u*k~z=l!z8X0M7R6ZaP-hT z{8rvADH@6<*nEj(5r_YJBRT2$+^?JyLl@j=`FDhDR4v z5X;-D1~oYBn)Ypck!n%36OSEc}I90*$4zsm<|@)+59w) z^cqjme6zJ=cg!d0;9%~O9B$D)07BQBRe z0Rl;I++rf62NbuQC9(=rS zlG9$H--C)tubd!^$5RAw+v4^C_%Jl=_ZxNCE%hItyd9q{TBWxT01xx2tTp>n0vwoX z^f2tX)k5{b=P=1I zIIGyBb-%A5R(G+pdAFK8$aWR7k^1_^@2EEWXpD*H3~-tUQ*@2G0ftA>^al*b`bV6E zFaCDwzSXzPePdM12aiU7VLhwEhZF9eJrqxBqg)LfOu+=rtXjI_-Yk7=S5m>wA2-3+ zKPfj=@SL*n`H^MZyJo(;xsQE$pC5{URvsvp`00Fl(U={Zc_C2rj4vkO!@He+g$&p4)PWn%rgaG#NP*7-XUmm8 z`Jzb8Y6Hom^1La(x^N%SRAifov9po3;_m_lC$DFNzR>-%!#ZHj#ixbVzN5HLmjiHe0>E0XnDo~Iw7eFUD$UGM0y+w<;@&QaYN=@!uhe%c5)!oyaM7Rs6 zi{s|wyn%yc7_p>)5Z)d+^e-jRjYr)@1wY1r$)uuyKm&Iwy%)l) zIC)6b^P{zAIVjX#=MSY%6Z$pebOin}CdlsFvHZRmf}qn0ecHaA(YV&0IQ}!HLz?X^ zCM2qoYa!q#QlUz3;-Q=Y$zCHd<<9j)lo$lcb$aC>mQ=}o4w7mOwWmOw<-tauKW|vYb^W; zLEXfIz_r9f3De+7=+qE%C`j)9b^LG%`)EDa!eBIbI?{b#2n(CY;jvayy&5PSD;aT= zM@@%%U;L_Gzt?$q1=tK%?52eqvG3`a_wE0<+OA z8QS>T^1R^-Dk&?A=GZ%!+LP%67H6F)!&Dcmc z>c1A>zn4s%l6QfVy1+#H2a=-)@(N;*`Jbrtk0dex8kvErTazSox}gP?P3s%(t9t82 z@$civsVbCF1Xi)K6{HTYv#wHHz$xQ)qi-cdT?;o4daM?+!+p#tg9v&6n&mLKEb9sI z$wFg3@HN?F7ok0)ZOD{o6W4x~OoL5iMA|FFa+C{gI*Q7h=CjqxhWClLf|B-@1&mTnNJ^9JlelSAqc|{dM1mffkM9y>vLY zVNN~%=s+06VLWybU=pM5v0mu`lX*+XW}fW zqEO#qcCu1rpFaWGnV`edJ@I+lOQppGRfR7BZ@Z=@W3bgO3i1Ayf^H&vkV(cfEbjH5 zTt$b!xARSI$W6~fpjx~38zkOh$ZZfxoeS0=lJnqJs&Ho=soHX689chUX-3>qojRQ$ z5~^4mt*B#}dwt6v&@wE+L@Xh1?64dHly;a?8=7&60`mXK%W3Hea@|eHFj@jm(DIy* zwp6oEy*cmxYpL%1Za9f?c{^h>`O1Rp+!MfwZMW)gZecie*7>z4d?AI&>~THY|M_6E zM!;QIbFNV#{B~1RV=DZXFKU_-+@-iQ2HiS!QpO?!bm7`b?M=n@wV(L2%z=cns&^xIm1ZbWh8yd z51|Asgum@+epYM&7kN)lB8Q*^DqK;lzX;hn<^NY6Gmc{xMOCGoF1p$PK%MvTWEG?G z+aNk?A_P=U4{o0f0R}lYlext###>7d`G|jQIubvf_!+8na_m5XZm^G-3q+jo25?tDcE!HwE9 zdzSX_9PxqCoK4j?Z4Aymxz;Wj#P0CbRzbW4UcDeq?L$-a5ug+nV9CycE;JjX_31O2 z=B&rgsE^P*Tu9ZuEo2D4HQ0c!~ zlVMUf(FKb?zk1sHwqn3K7)&JRwy2M775*SD++vl)`ucqRT~**SE_tO;AVe}=WENH; z^zalf-j~yl1OGG=n6$O80=CJ`IFE>i?(3l<&bM9#j{E&#I!7dApPiPEiN{|6zKwt} zRf&`Tc7@L%r+wFkMp+4)A%M<2`%X}uBnTKEART=q@f;sZa=u#+tgg(6I_!`)TivJ4 z+5C7K)?R-#u~3z665V!2aCUm}5ZikfQokA?Th&yn1LXxNT+Y9}j1N_CdpxPPfBK;! zU7hwSP83qt+v##Ih-gUSODo~^3#Mcc8KcK-ILm&Uv-Y6}W*eE^M$RVwrdfM0Y`U*0{o$4; z)1i*zrkil^ZTdqQs(bLvIz}hz^Ys2etsuFufh0d|IN(NM$MeW%%VDRyqmiRYF8VU;0{uGzu4p3?uZO95e-{xN9RO%l((_E(KpOS zFl)ir@2U@AE*UZ@Sa~$e>#i;44=AD?DZTc;7<*SV8cNo-}7Ge{9y>ia_j>)+p74C za-STLKRjD{z2DjlvN&csdKQW!4_x|b2)yE5p8xT}6avlG74CEdhk7!e-AuS8AVq=A z+0{_1q|rl>oUqey=Em(!$J#3Gi-&=ial@O>e(Eq`mY<&}gtvXW8nrP7KvCroaAFjz zh*Qk4gTfxwW`R-+*EVHG;7do_Jcbx|`QY!av>SY;$9bysCwJjc$ysX=^EU4{uU#>! zl1<`23!2Vw*WK$(*Q(!UwXc+*w=OZqpxw_&Vy+s-Nnb7*PAU5QY0&;G{jOvyQjJ@^ zGltVVK4&yy^}tQXDkxE>_B4z=${*&zxA&XlZ>-Pkc?1Y}%#G<((g6FV*kWmjT4WW{ z(E^j`degJhy4oKOyQ={TP!%w2lQg7&*Id|U20K;S6_%dopvF)Z0d!pBA!hQ7j zhj&r2o{ZGC@I&iq)Xp1!^8~MpZ(bKmIm@PfhYyyJR^~Rn0m`*Vs;;0KAtP6)U_{#| zlV311whqN^X}TXiE<%qnL2u6XLfor@V&w)UI1_uRLjIUmwt(IC7H(%43M~@%>?dLMDx!Jt}D&Qw+hJ)0W{Nj)!qy zu^7%O&93B=*xH{!>KncsXoHOv>pixO0vH9y6_E0@ndl^!f@(4>e+}3pVRhkht*nIl zLvy0Ta{~B#lP{p+dWd1%B>rL>^e7Uo@}l1elu_k0V>LB;q+V@N`hX_d2ZT=c(Z19V zaY70C(*-1Q1suG@!6~kTvvO}}w)9>h7xHmX%v%(yCkz)mrmE!(#E4)*1U!Z|xwj+| zw>(+*RL|rQcDhU#sP30{3FWQLvHt6ip2INvhgZ-aZLX?;sIZGv?aFV| zv>|9+f@;Vq(bL<^rNmn(@%2=y9}Ww+_xu_T#$6oZZm}ph?)n0ZOFV>B>Q8?Q9nYUG zL~z|U6|puHBWSOT7hlDQpf89Y!kZS5;Cb!rSod3f;l>i{Mw3(y%~Dxa5u+}xP>S9! z%5+`!op|dj9UR>ZJxiGxoK;ugdtkvadmDb^!P&D{Vy9Q3mGg!!E1_Edwm4uXq`E_T zD$ZtKk}-cIcQawnlRRv<-Ro}P!sD&3>)M#DKB=&Ui*Sj<4 zSQ*1*sOtSxK|I6mFNU&0)NX5#B9=V}A~l{n zoj%4;LJn#(lnsA-%0RqlzKcDpr{)Y=E>Ij~5P$pWAyP3ecsnlj^x?xN5A>@o$+E?I zA-_Ht3KOQ6i+GkP8GcJsHX{&h%qH%hMl?9t2nfY@I}tonURS_>F406oThl{RP+{Lh zDXSrN|C8O{5>|z<3f@!_yRHYeF^=zli!a1YrCxHho4J=4`98S@zwf1N`MaCk|Aj3- z)ai0_abUs`)RbG~MDpSB^%*REbAwbh=7U3xq=cZgprSQ<`?dseIgd}T#@A(|WA#Df z!~3P{`Qb!0C(@mT#`?DemjhLkKYqNiu!xljUtTZgGtrXNdm{MQuE0Q_sMds@dywzA z@qz$xpu3J2Yr+=$xT8yu;*;p{w_3s>#8OhF;TJr6&+ydjX8oVz$ZI&w4>j^n4GnY8 zjH5sHSD64p9)+peG*N6R{C!N{m-n0`9uOLu5!)IxATb{E>Dq|syGQvuwCIBMzxs6# z)$8MrXopqnE^a)TJ)gmcgAab%KY5wzs|vZt0F8C{R~+s(A!IZ&;2)Dvcj8@yX`Kne z(l$IUm-%~!%r72${Q(ctH6H6!lV#8?cV_EYHi7?`$V}$LHf-B>dADv4#&oT{EDu03^DoNgZx-2q$rhGsu z0JWds4=$T)nYSe!ecYT`G@gP5zppQwheO6qWiCZVdzuw2!i-%St7X~R0|lJk>nOMN zn<3wj*#Wb+hIKRjdDWjPKf)8B7K4@u>KbYWo9Ki-Kb9w!6g0ACodSC&Td(H^JRmDI z(liqL7+3&q5Ga3VuZ_3M_{@YPrm*fdVttR4O6TQ1%`0ga>Mz-X5OISt@3%P@OG#*3 z1+vqRep7}n9}FCj)sKDfPd_rN{Lzp5xgK3lrT1P(4|#_tB}^r*jv{ianUj-Rb}_sN~$At-EJL_JH60}%6{ zHioQSSUhPnv;WO|ZCGb&ipIDy6^U+Iy8PA%dSy-RDZG&;p5AXJ=_!~YX!$$5DK4|+ z`&7WjZ|j54DSjouT#dc%c@iI~s$Fy55xYg?{40dQ-+A5*wuvvEdzdGlIub^`^JIv& zzWD(}ThIJ1qHS|B0+yaA?cBUB|A2h8w2`|XbvxA8X^#5SLk z1p+BOuMqy3r((Whp6L=7N*e+hEg?;BH(cyp6g(TneThApGg_jWK5rb@xo~?@iC;Wu zuI8IYYiwK!?gyEkz1X`%e`rf;0%Xdy&qEMs z+kMfhs4aDEdzS~ECE`-?O}dEOK^9bKM%VlmLRG9c$?a&0Y*Vx`xTzZ&f3-EOBI#HfSUM-UbThxYkv90bCfnI4GK6&SsEjwm1O}q2|DM z@2L}1!U_8>r+{B*)w)-z{py?0dYdEFp0 z=B?gd_hzHmO=>+P@YZ^&l$A{JQ4_9R9Hb=Vx-sndsz?1dq+an_^pw4H+G}ZT<4-Dc z6tFJv-QV4j0!E~O!qI6m8@RR#oEdmjmp^xFHD%`j*Jr`KTAY};{;lzu3AV#JW<<3% z(dypMoNDT8=B0bPtH3B91m13Lk0cu;zkrF~rj0eA&L?jt0Es5=0mH6q36LDbu{ZyO z5$_^>5awX}Rpb8dgeTx$CmV2UKFz>;FNW^9)KBh zDi=##0~QXJi+Z;eG<7c6q>` z>i5-x7dGpKQ7+rS5rHoZy#`aqMgOy@vB|)#ZOJ|Bmtd0~hx+Vz)&Cx9Xmw zN_(R2t?KYGic3_$?a-!|{;LktPG1y?gko!~xzlr^BwP&7(3KA&4 zS2>$RgsQfX8AIGiWTFN}Wddi$3MT7?D1kM-E2Sij87qqZd#@d+7}##l+wJi9$sjCr zPKitka=WO7L$%+OmVRP^_Pt#)>4$lD#5v1Vc-)XHZ&f%m!Zm18Wb+&9Lk41?VG@#y z_)Wz+?+gW>c(rVHIYdq?N&8xwaC4ir$FWH%hc{nmPskjG0ptB z+F?bD%(ryO7NDX34y9m<*S}WKGVB73`V=3Zukmi6a3w80@*KDIrM@83vwjpKYOC$6 zvu&3Nb`wT@L2l-|KA>wuM5yhClU^<)+s6Vv3A~H<$Mottf&<_R{_PtCu#P}BcFd)! z%~b7WT%fC2zzn*)SHZMi4(!p9=iMk?fy>D|&`VSFH2C;8)P<*BkJeO6wL$Ga ztJug&!Z!tEc)9dV>hv0kfwi5jchb^AosC#7F`J4l=OEvm;W_r5+M*F)p3TvWQof9)u?4N>BoRZ$W1_=?{FL+xA-U;M zA38OrBzJBl@OGjp&L~5oST(_{b|ZBEI8bt-YA1dfN`>Xyxc(ZJ4)MJvk%L#}EZ*qf z&d-J2#suPi?4rx|0_tAL?i;P*Y<(~RjEezDQcA*PVyzZ+w6q!Fe6r%P90N?w}y{b>>mI8w)QjF-m z=JHNj6{Z&JHek*z#gxtKm8a?N0GFWs9XS}-_ZTR*q1EE4iJJN71TFn2w~|!a&Nemk zTWOo_bh%O!B;6|Y2Q?fFPni;b#B%N6g)~p6dhq!4O32Dr(eK|{8P8$ilP=a9w<(He ztw%FR7s`bjO^XF6ABq`lIPah{&tjrGcXqOqm9bs)%3c-_Bz_#?Fe&z^;5XZ!(Ux5V zRtMCI3DI$Bb3B;7h8hE_*N4CiF#kEzKt(6An|58G-ODqbv`TlLi4e#<aJts-l>Gb;bTGjFsVN$x^Wi6mMcIrYik~37igYct1568?7VU5~(n^ zR>8`E>xj17E{H4cRl?dy^xp0nxi2@HeulgQiq~0!fmm9gk&!Ia?lSfo={z&N8J?5R z{zUSy95~jTW)+n=k5h$TY!53V0Zjr{=i^5fQWuZR;oY7JWV_zWj{)Z-2jgMai+ezV zQ(Wf?8)kiBtQG~z-}==ZHdaFS%(==SQOMbN!NG`Uw`I=5^}2x{n<$3D{U5r%JRYj{ z|NE{KqimyyGIovfY*lWnTtkrZi>CE^ADoB(lpkk+EejOLk-IyJ0Ld zjG5=?zJK4}^L$^g=a12Kow?5SS>Nx^$I&^bdbIFV?ON9a+FH%)Nwd{F&eYL7U_LCA zefL0^{(M{sct$8a1lDubtUkcx`+K^QgL;{l!3v=x$f^R_6|h?{cF^)Dn?NDTk1o)5 zTfTn4JXN<$Eq+fYg>dWbsWT|C#&o@dI>MQJ(U2e{{X6McA!`4`Hvio+DiM}7cWZPX zYPDV$tT`7s>OZDX;N`04JJ+IUBk?fv)ryK7|CdUpx*;@QBtQ|s=@1&HmD%gS&K zrO#8YJQ~E0l`Xj^kc?(=-cyPKIv1jAUA6J}iUO?FcCUHQ>-~Z1nd^k5j55Ec{2u0O zw+W^;6m`W}^*( zznuG?!t(7K{f+&u3_RcuHu}qHp>^FuW-$|!d(;g_+^JYW2_|fJI-#w7p^+oaRDP z=u=3f=JPKayR% zJFj?x_SLUT$D1+{Cpx(PmWDsU^EKmTvx!Azp}@{Y5K;YpU2DnHQ|I{AE~)Lbx3-s} zGb-jke>ZZH9iE`g&tRHux&_EXKYCixR-Q_cyC)+3cSn=4m({=g-P-dc8kW+fq&ydp zZEM_>AqRWNXg=`$a?1VhAx<90^vh)**Je}IM@npqaQjQMzWy?VME~*8&iX4m`*FX9 z{bg9?MmWh^wOX!9YBZ~cBRsnTJYV;h!8k;I}oJAKJ8MU8oj#PM#RV6x!_(@I~tg50U8sQ~w zm1X6qzXu;7SD*3_dV{c3m`T(A@F*X>LS%xv&(^c)yC92X>%fn79^X%`aZERzE~#;w zTmGgrynS>oEAA@#&H5HZV53@n8a|dg=_2p{_DCPfDXlrcg`O)w38cGe4PD;b+*Yfd z=pyMPT+@&4;m%)PN`crN(4w&g^i$K?r?4g}1%7?Bstv}fd^ouod0>I5xEjrna-uc| zHBSzw50x=Wt8yu}v>)rjS_KExb-TGa4{J`kdL`F&MuMkR*O!DUn;(Vbu)^&VHDurY zV`FviSuEHX)jYAd^A^J9=-nnU*Gf;RT^Wy?=xR&Grui>};TV1Ne0t6A&kts4(w z!pa7UP$aA$N{ahy{C@ zi?{pz$wcL@ig0U7Uu7jz04c!yR$6ZjXn!QT0=pLYg%%Oa zmb1)YsU3$;1XYckZGmxYN(Q+J_s(Iz%&%(%x8fc5)EjM7kw2fEqdO^Ud@mb2(JDy1 zdfUwck9EAaYBhHIRrcfdjN)ur(W z|J~7x8rl6^_0&~(twOzz(@&s_l;1UU%&~qTqBtX?@e)dG)iM5RB`9fs!;5oJ^VGQ! z%^?f)O^PvUgW_~H`@zutAJaQZD8B%qCn0>D!E4D$erso2TNABikl>XU%e1M3i2X8; z+18oy&J6g7Vx_6~IqT9J>VlC~BefPvmy(%OC)MU!JTy`)+Q;N4?x#r4l%`C#zUfP~ z0A88hsX#s3v43SwtysV-eRnZdU6*U8_0~+}XckrVC!=&($x>-y>P6>dvC3eHnTyFpq+Aie}kU}2aUJj z9evsFecV80v<1Ik7~;G)xm90pp|xDKBOyQTdVQL zYWF5VApq?H-kEJ%QQvK3y(Y^bx- z!$0pHAOZvf*r7BM!K7@cV-m(Owejs$tEb>!qhvMMgk#A*6O3=9@Tc89rLu$G+T@oT z>UZmQJ&#;^YGW%OS$S{yMas&oV+jZOJywkY+w5ZW2dYITG+7G9JF@i71RY6uufRA5 zrPYp`#h1!8l;uBjby9|t`qJy8dl)(Wnsd?aYh(h#uP0`uE+@>r`qdO)oxdkf?R?QJ zqic;f{ea!J7#m3?Y$r7C#!#&BdKwo?_d%T-@VSf^*O2gM>!StsgHFTS>SdJQ(hONr z2M0lWN_&_(&rZ$!i(pUbm$BBIFFMQkKGxH@+NvP>;5=P&9o5OU)v*rdaeIWKtNdIy zkYKFHqK~K5fBj)|=No@uaL?H3iA)0l!cB6TN?oYa7CA-5{u|%UTium6Bmz&p+QB29 zeO}t`vm3P;Yyl-Q-O?))FRPBDJPnVx5CrG;l_}mr?J}=&gppH8It zUZ^$4wKl?jb?iE3g+WFtBkM1PtLGpyp=CpwQ}rEprh-?=r%UQhj^lWH87VWXS0)?J zqEx&^12XKQ>+M>0aT1r5URIy1x`+UxDIvdGY>u?Jx4#x30eI`gTxxUOX#!d5(VF~R z;rB9R%oDNRx&4<&xebw58)j^wHJO+AMvj2JINH zCPLFRvT)D{-xmW$l`_G$ulZcwvm!Jjq+`I4Wq)jBzF**vHQwJg%A4yT%ab4tQzKYg z(f$Vc$PK32?sZhQ9j#V>L)Fg{V*|4s)WK7g>UnkX(K|uo>-3Z0kKd*n>zxlG1?h!^ z;M=^tRfKiAyq0=gK)>L@C=r^{M18%sx4MJQstS=L1*&76n9VCUk)_aUgBsIA)i zzU$o;RT_4jJ8uZVS+|mppYrF&u>JKFTYXvkM=LVm?*2M(br64$s@Ljox)_7)CFas@ zm+X%|b6)Sy=q_wD5LkchQh&XE|9qBHj!2!bCYl!f*^6hFg97{h23t+t*Wqhe7u0%N z_6udELkR#QxM~eIBgyiGY+`hO)NWUm3!!+-F1AHI0x=keB@mr_m(F+=-rlhYU51~q z6)cj~_%Nb~~6j`ID<}`cx_IsPsD#zf4?v_-7#A~5V zDxaWq13rae^I`fN5AA|=+H>paH__|-23i!M7YNwCtMTo(8*vos#x$@}eM5 zPUF|EhL_!2zB6@YM*uN_AvxZ8#p?G1-PylkrGS!Hwg8OoMP)`?%&NU2gcTpbaM0IC zav!7JZ$5_6*{FK3dhS7j*tR0?H5k@UVC|fTfJnecs88zOV8ZJMM_{mAU&%~j>pd#9 z*lhT~K7t^8y`FFfu?DI4x%d3hbf93N?TQMtv_c-3W~-GTjIyQkZhdRC_7S& z>Xcd*y1UN9a|bi0Na=rVkWuyN%>k_s%YYka(XdU8E}3q|h9FFPD6$%YNMfvh6Dn^h%9=bixya;t&(+~Ysq^?4h$CF}yANAW^#CyIpi0*WT z3Js(3-KINZxpchcy$6N9{jHY%Zn(G_nX6FNIosJS>oGfPz4B^s);oE2FEO}L}@ zE8E#6ZP~j z<%`%3qX#Qp4w5oD>?fo2BVK>4#c2U|?mj3`mob4rGCO9PlkQ%VFla8_ToG^0{YdKe z+P5^9c`BtLqo&V$@pQ`6+tjz57I)w=6z>#Xk{uF%rP z=%(WnnZ}Pbo13f(Ry5%g9v8+^CsS4&Dt~d?8FLG88KQ&nA8(a0XR)3nj}?ZPe`YV) zVVU2&Y1(@6F%M4*U+KK#Uc@)I;JxVLXp=_s&j!9`bF5B|-KRENIVw!7qS*R|IVzd!(%#v6lE0Sjzl~+W*Sr4{Pb6;*;Q(;km|{}8#{YZ)h)bjt<)`P z!K2n}=%n{aq*jJh!_1?H#qY{5T$QWH#v6KV*xmV|-_GH*y+P^QEir0&|5Y?#IB;C+ zWq|ma%)~Nf?6F^~^2IYp!nT@u96mUx_V>@|=1W3s?s*0@YbhrV=*q~s**uxp2sfDJ zI47O{<)h=-9EkuNsI(!6rO~sMRN!ml^B@MK*a9WfF_$x2Rk<0$t|Ee^U{++Sg5@Lr zQ%9EmQv8)SQ%hD*m!|fMHvGL5H+Sj8HaF+ag(GwFU`qd|&WbCQVlq!yYv*6o^;Pyx z`_1t<@V59xH@Bu_S-uGCe)Q;(Va%w7$q%Si-J9|zj&xUl17p3}V<`Cwn;Gg6aCYx5(Xh!0-F;j&%bE~LRm-upWZ zCtP|cn7rk&L5iD!d+<66S|OZ%*{)RkRiBVd4)iu(_cdt3%I#~s%>tEUb25UYlLIa5-ZFhW#v!Mxt6)g`l&~wx!cOL1 zQ4)6Ipv-^lQ1Hr-S{02ge3@4g^DqwjivL*&%}C>%UKe?_`)g4I?q#am)mJ>c z6fg4IwGbKT@(0CXZB$O!BZ;E%&%#p0ql*_ie1iHbjtVXd)D?s1zJ}!|)FnFEKA*0L zcX8w{vXF~{oo!)F8t^o7f3N{{{Oo;f+M)b|JL;5q95<&X+M18r>8ww ze(!^;&yCGqOB)SW;%n2SW)PFZ$ELbZ4ox&`c%nf+^F`)|&*$o;2ReSY$Tj-j<{6u3RIG*zjw=r%qXx7qYu`=V zzHo}@9{^#q0~8u1{3D82el(m7i5PYmtM>U2Qz>d+VI;bmAfZtNxkUxpQ|Hedl?Ts>Gj5wP#m7xLoefeMPPE8Cznt`NbC^rji>qdKt}6 zcoaNC)MytPXd7)rZKNfS%st1?d&npB?|})HX7egW@0z^k`%2Ntr%3hc}#N?^pd*F2qrG=IVTZ@pjb(LXcLd`aik%Y6zz~KDQoJ3z*Sazu2)7wUcCS^ak&=1 z<9PY4O%}v^l1>LE&+%+Hi!HO8TkMpaF#f*xB>Y15!qQv%*{O4_oet8G=atq`Y%kR9 zkZ&-XZmI-R3I4Ly+3mdBN>EphC7}l?ga)!F?80YR=^zNU>-vFIIlq-$ic`hUviN~! z84Wn^@094%V+)8O(v-bXB#n7?I&l&fBACmfbZ)s{Z``nNY{*3Wre0}ZTvA%BTDE{e zw65-eeJA%h<&MP^?0H$XOJQlJ(+jknnm=l_-1?}6$Htydo^oLL-xmLH3#uXjI%!u+ zF>u%`fi?Dq>qosk6ZCrV{IWvy5A&Uz2Tm>y4|xT{qGJpUqwje6$oiSwOlx$Gxiiqa z*sJL*=^Uqv$ckL-j7XhHQCMd26kXlP!7Ti^V*MO!7cF+>>eXMF@v(0|=Dk&Ox_`Rk zdU7VGu%xGF^icUl_2*M6f%iwT#bu-+N@7mRkcsYCwPc@cD2Jm>+;nOE544{wjSY#uvDxQ#&WkH( z_O%k_s$b?0r+}jSMYqq3`Hdj=<3|1pe+rNCZRsv;)m8!5n4Fl(v!4%3R-9H6-U~A= zK6r0lav9XwQJj;=*LwZIy(bQ*^mFw;bV=DI%X`SA&JLR>Aag87xU>4^E=|PTyZ5l% z98{(<1*v}S9MjaKl4Z=j;sWoV4V@Ej@{zPgKEc1Hh??%Q%dS{~&t}9Y#*duAoAelw z8>PK+rgc~$FDZtSVUR1C1F{hUzs`OG{#*LHD8T*)TSGH->Uq0NuH!(P|lU0UwIj+;rhC|%cq-K<4*4*?~B@9 zJ6CXP%GmC)C!cny_Q#LnB-Wk>hL6TyD~K!J9(3~AxP0Q$c5n3bFA#%he&3oLeIWe1 zU33*CC74}P+S)9m@-X98WY_QVH;{vtT6@F93oT+n<=%er86VD^<89SWSYTof6fif@ zM&7hcGh%w-(hs?vUht~NUN+;A&X-Rzk6&#KFC;o>KNQPfG9sIIgc;Z=V%3$Td*dFT z&5&iv8ZvOZ&zs#Up5rZBb2^zU6lIPxQ55zQQB!F(y*t?*X;N8EnYzdOd2LvncV5mn zNL4*=HPiQ^=J;G1H;`xPWXLh&Wk?%r7Cj6r19xsldm{rS$C5&|HqqE z$AOz{j>>+97q+gc>wRRsaMX;paOZpBb&TivHBshW-m5jYwwS6*wl>QB!`S%bv;_ zy!y0W2O?|y`nz@v(Wio=ce83WBc>25uVOM}%K;0yVz;Uo76LR;8kG%Ku||%YluuJ? zd#|e9V$|g-|89=s-{BHD+pcQYHGj^y|$xz&A0+z!*#m*u0z z_lCuk9<`j(_!V{4MH9&c4%Is)5g3-x?xXzhJSu+ov1e}zT{|)%2kcS)fK0hjo~^a1 zdg+DEYvH=}0?Qn?&6zV7r`3`J5}$12EWXk)@qMYw(mq<(GMc%e#py${I|k;MHlo_i zI{pUYG_z8(aQ25nnac}AWK+MN&_kR#zUQUSvnyb0m@OE(!Fq4Yo?RCJuOAp^)m%EjkLWr6daz@ zJk(%&=|v&0JXON&Ym6@6e)s-!hTxU!Q3}qaPaSvvcIr=w>yA-D8gq6~ei*t;#ZBmd z6jfevk1U>#_FHJR`MS4v3by@&fW4970@LXCe2`nF!Jc~~tb-^h|EmMIJ8_qhZn^NG zBmT5ghS-SCm4W%2$>d_Uju+1t*sWAQNKTgrZ7+&hwp)i9wjxtXWAAVe{aE%rBja7U z>uSqQvt7ld!(P!>>+4b!o0oTHs^!N^s8ktm>bOvxQ&8&2^CFYV5>@ad>{1FvkUMo| zw(OWSggX9SK`@%4|9V^;Uj9+H+fDABTR)roN&0RnOL{?ff4^oxL7$`IX|>Hf)9A`` zCcpQ_83l`{P5YkELz1iq+&2dK(K zFNEWJr$+_fg7yz+G`D4|#)0RBK|`7ytCAeo`Du~;}l{%-ICZeo1{u)!I5f%lJ6;EBC3@>Bm(U=6sSM!KP!)V$n5HYo9;A%Aa!k zthbs!)$11fJHXU3A~>CKF`W%O{yqymx)_aDUMfZx9RFn%;cG4650Bcg5%AZ4Uf$zh z^GIxAWT~p7#j@|!v{QfqmNmc3;3JDd?%M7JLLnOmHjI9bH8Q3L={HY z=H8;+ZyoRZU97T}q-KA=@5`NR3)V{g{Av4~f^(wS^qFehG478c!Y-Er>VwsPuU?B2{hC$}M_AEufV& zAiVc0{)*GXPYO|J?WS&+Qqv54!0b6m#KyL;<_ii(P%W9CcL_ax>T5+zngOrmP5Hp^ zfbn+-Gcia}bV5qgl-u2SLCp&nW&~HJHm`hzPOAzUCi|P01ZseDPrg1HA4M|EJr(_q zRO0cSXYH-Y1{_eG`vBG?r zD{C@J`4(GUOIc&~14{YcAC@66$u%<62c{D7xqGa_l2^@mVxcE5BpjDXK0ecN^!`$7 z=UtwAE}xPwUVuYPBsd;k`#LnwhHJgUYV{+D>z`r@h+5 zm9qo#<)?jD^EWc0O-mQ=TtmY@R$UJg?Sh!y14q6f2z>H0uvC+egZ0t}ZZn`G4bjXd zY8@HtoBKJ!Xs!;p%E3?Dr1YZ1{uo|gWKQvP$IH#XQJ;d%t%>){thwYX4U&}|0;c5A z2l_OK8}Gsv1()$srOZmJZb#-(;5b!9_N}$QpQEkka~|=sq2&SXNt?g5-VLrYbX&$k zF6!H>TWc_y_`B4Y^co{i%3C#ec&U{AR(=--&m>0zj|^AaEi91v>UQkI`;3LRCOPQj z@AWq+4Jl6kZ_k_Ql@E%QsyEjed}uOds^9rybaAMn{AY*FjFgmD$p9DnNOZlUQF&>Y zO@A_(62-4?WOK>{#`Fnd;-B<5o1MKdQUSP{9%e}BUBO8BA3do1xJZ z(I6GwaIDCN+~=pA^L>5WpO5i+n)T=z-cLes8%C_4FLIk%4R;Mm*4^oM`%yEE zWpnv~K$opp?N!KDmcZrXC*pETf6z4d-Q=RIdzG)Q+n1VaxfIRHH$y3rvNN+4<=#iF zCPh=x*X|$dw}SmnXU)!h*fG+0ib=2T#e{N_u({QvynNcV=u~XARi4~T{u=kdR+%L( zhPa+^J})f#?4YNN*Ff4-aU`qgMeo{ntU6Z=QA#SWF}mGlkQUDW>|E=;fm#y`Ym7kM zEkyHx(dRuqtrXG_Q6FPwDX`S)!7cy4Q{~Tc+8EHkD~b1lFLFt!`BA{cGiKUxOVVIl zynUz~m$j#F*!^|x|4cs3*o&0}J z45g4k(*LVU7XBUm-Ol3*-FyRlk)I$ai@W6Hzlw2*vMQZ;#8zZQhPwRac7FJ?lEr__ zi~F!w`oHp~imr#j*ll?ss>pCsM819Ml7$2w)5>lmf}2_jljNaF-L8v>E3%WdX?|G8 z1QwNYIJE@D`E_AG9jWqc3Tx+iQN#SN7!`)@$?YgcHha?T5*2?6@PiR>N>_WL+QN_b zwBVz&O40oRf2n;ITg0wj;tv<&(lu-xVv-_UbG(^suTpcq%)5=Bt=>Dvx7^_Ow}P`H zD78<=PRVt7Qb%m;#JuaGnRUX}B(oR494NqoIw&rAsY)kvAjJ7<8`{nQ7?3jxFUH&QKdg!Sfkg|j7 zP4*Wsq|o(J3+P1Bj%c-B1hW)}am(-}+NDFtq?bUzcA zKoCESi}H;f4m)fNIF0Y;q z?~T)8?*GMCZolVteYFmOuQo^NQW`Tv_hwrJx0eyLzYoHQMYRvRwm^B)uTbW0e=T6H zy)NI*mM>6vlObH6MTRzUnoheoraf}AiEGVBP0vUljy}WVP&2Bob%N`Dm6;3rY?ENS zga0Wz5v%4$;-7zgdi*G^C!+_ZJnb12EFIuA_R8pc)Y;*wqG@L^?!%bLjLh~@r_Oa- z?e_LY!Dug4b!BsF{*LqyE$#OiQ%Uj3wO1H#vu=t@3QMI0m2OtWj5|!Ro;;5%IGB!R zM14zb1C6z!Gk;hwRcJrUA}evKt)AJ?etr&wI!Ryf;BHv9;Xx=gnfB=iT)_SiY?#DQILTtjl zKq8d+pRklkju%SO4TGA7EDa2;gHz%ZzpVr=CdT#rd8VT01VfS{Z)Cbph}qE|A_t4`I%)C&(LIs$ zTa5U{9B&bcsl4BQZgW6_cqd$Ot_$qiX~EeLYAxkfz(hEPy7>vYz;%PfN_kq+LThT} zC)>v;&Oz0Gr(jQoxYt6qW80ZIAR)PgXMq=pxo;>t(oXLnta@Pj~i3Xdnp22Iv5?X7DWuHwYes$%0WpX2UcoZ}?hh z4;;^uxvw;)U?&eJpozZ@G(RCpoGQSPFyP1#i#1&tIIW%uwrHY3X%j{W&>$e#BQUC6 zoMxg!$rD2y-zthH8OsuLZ{q=}!Cjw1f!s-+%)Nn!b2n&Cy{tFK!y_@+`ykj;Fe(ud zzn1(7yU{{pYCcEiKJVTc^JenBO*RCq#zVVxmpTc}oFa`*M^SZi_gtraahGArz*+2Bm{O06~g_R3KB-}(ST#VmzD+to>G;R9i6mojVs6@`=6f_-!@1{!dX&5bk};f=Lb%HTs)PIyD~!06MXLPQ zOkg+ND|67WN!>#T?Hv1r4F^Pu@sqjrNV&qTw@JYB^fKF-fk2`u3g9Se8?d@nl$dLP zhbuSi(xUftJ$i2R!$F4${5g*vYphSA^9-SZq~%n*rb{{ZkEKbZJ`rN>n@QLA=yPw1 zhSBk_sU>23WbRv}IZeP2_+Kz#lR8I+Fyc7vs~BKp0`NVQkDQR{&7|ped8eBnu}%vV2Dh_NO@zPv9tE<^2Ug`0NX2)d8C_#^JgblU*aYxT(oQ zJCDENK!3nsr9iL&Adq6TelYI%-XIXoWKy3HF*j~36 zl7l@eBtD7?GQ2w1sz=&`++=V{0sD&qIUM(-GbU;>J&2X+7Xm-@?jP&_8zz*#pY?ip zcm-f42v)%)IAZam2ujiWBQ|_eSQ1Z=A?9k~;rW=u{Lzbm2(lf>N&@-w0l?>5I{+pv z>!LHEVB87Xt)G;%gHQk_#;^Z{Ndy2B6Tzn%x+Iqc8wp~rB_93~gVhVkJA8Q3`kl(>;EG&BBK*+czW{%%S1NE3e3+Qb(6*V1p3o>3YMkz zGVChw*#Hi%FvYAxx8S)z3^@)xJQPLU;KY5ckF_x*c{d!2l?ti+Zu1Wv{L6q$MK9=w z6!cF5eWY=4ZUV*gG_710h~)3-MG|w@)j25Q>t)#QVIX5Wfl1=zv?14?Hb4jsx2KsS zDH~-`)DrHoR}?;`dJt^}Sd#mCl8u!74>4n21*XDzhY(eR-uh9=MdKxL z!)IN01V6)Qe8BOUy9X5;5ApC+4Av_o=pFjdzyBZw1cBZDQ6x?osw-ybP@IJj#hU+( z6~6_71X0^_VdGs%3S0%CAQ8&rrb;jpL;-mM;r&x%*a55v0E7#Ca#UYa+k`0B7OMP|^PYG^hnc)k}jA*ikahKpt_cF{51503(43 z<|6=`ILE`h4SJ7OJB0BkdfEZB>#(%NXqf%j<)mGrNCI?oW46)eAMgGrbD0m80T=62 zoMTCSI;71@LueM1pU}T}foW6TC2Fcf?&R2)Dj4CSpbMd z<+H=mDQnV=B)Y#L^#S>s3KZu@b%!Xayup~H9tIM^8BU!+0@MJ4L{qMYV0$@su)r5N z#s|cn6!%Uz`+&{4B5_)xE=3Ybxw2pb$c$eC0suJJm}+$BFUVmP>jV0*<9Nb9MwVf) zhkj*>c}_oU5RL628-u|VB|xQ6_^P-De&_j00Uqdj|Dildh(Fx z&oxZ*g6d0uRM8*s0L|Mwt(I*lBF^)m1dQa^;y8?3kBB`=golp|G=T5S|M5hQ@u4Rs zeMvz;F9i7bpQy$kwg`XXsw9LujB7h=Y7qF<;}1e_kz$+w-`dh{r(VaqXTbyH&Mi`I z!ey)_P{7_yvV{rikq#!Ib|nDOpAIF|4W(dxKG$EhmILV62{;G|QM&mDFedOn{*Ipv z5~@#|bUNN}=+fxPB=P_1!piB_|1ZSovoIt9E7XqBnB*@1ix)5sv?-bdbYY?t>!uhR zpvq-TehI@GdaC{&H$ivNHMjc#w{z&e0E||8j%5W1m&yHkePFof!67l!HdWF4f#MBC zT-b7d_fJ=G*#4Pdl>tdK-@`0!o$5a+9*~-E0jR1T zq6Q~&Zaa3e{yFyiVP)sU9ie;uLqpt09o1`&kj@IWP_+YrvA-j zzEJ%gd`$d}_LCd3d;sTiArBbZW24^{&DYSh!EZ;{-^!m!06&$InCnCMoRwm^aQ<%c zJEMn}hGJ*`_CA%Nr3jI_C#5GPb@-9ilQJ|n3XEtsPR!)aRhz%-Y4|oF_rjNJ&Ebss zk+p{85{o>-GK1m%x@T>-a6gb@O<49ERlkh<#LFQK9vly-Tjh?MAJyQOn<&>DV{A;B zMeOL3qX}h<=?wJ7HMyQj#_3t=*BC`$a7}DwdTY*(?uwhWUoyDYLcnl_ZPi&=ceHCg)XBJy&+^Sp6{<;4(rajVC{}ya( zN+2s(fHAqZw8NQrvrz>t<`RKqSO3onFoUFv>=T? zYXc^zm6520=E|@gPr&XLRn$gRcVuXC__C{9Ay+^GCio#>=Tb9w6Jq3BmEnv4V1HsU zJee0(t$*lLrHGxygyNoOyl`m`!Lp-8DCf#+*1VJ*LDT2%`xqX|*O-yW%J2{rK*@9+=2G%dN^L=0KCH&Tg;(=pvIi7$4C0x$Q9PI z0a>slFaZR;dBE@&$2SVid5RwMN22>h$TALrIg5^Ns0`jbRkv#FKzb^e&)dT@?T^@a zuID)}YYQ~1`~3l)niWIvz>Th~Kh9WdK6885Gn7|wf&nO*3C+bwT zMEZv>Ue(PRooW`atN8+6{HmWbN^FKlEbi#%s7^MgH~XYE``55WuKSo;(_ZJIz9E(q z><$7qid&oZwx^)$JcH9b1S*_7Wo1YA1yg(CexNgN(IatH)1Y5)Tvdhn7B}EU?E;%{ zT4mtIjmwNxFm*ll2Ravc>M{<|K5Yk^Y>v4aPQL9P6hSpJ51xnhM{0*He*RKN7PQ^Y zKu7Toj!FM6x>chBr1!^k(KxrvTQyqNIDLab$vC$tUjK;2-+=l;ac&8`907M{{EBEk zVf5+dTCItt=Ac^>Ys~}hhjZ?U(+91Q^XIvMpXpi?tIZu+6VuJES`+ill>%q>#pNc% zHjl`m?#>iEx#G@SjgQ=ou$*mXCV&rqM^>t>0x#UO#u?6VStBjdQy9^|)E?5*LxAdJrhhc41LUuAdy1fkI7S?0Sr^(^0GSOV$s(W$k%oOSgz+yHIU!ueOzU+7 z3}mB&ux?|iU+FwQ|gJElm#QNhvZtVrYq@?G(d&p!nSD8glvOoCwM1$ zG6Squ{s2!0nZ=i)V0v2ICPLS#O6{?bFb93^%-mjvDqC zsY!z&q!zc8Z^M-dZQygRC-hRp5dtNReE2hBFc{~=ttmbsoMuR6JSrX}iqj8S)s~$BTYq#BTU-5yQcjS|etsE6SL9MAf24 zj6(*wV3vl<`XLH-PO`++9IW}B4y`HO_f#P3a;$&%k`7-`0mS^yd-p;>^;&U+^oDeM zMUKFiY^vC*(u)?+h9DLj_^SJ67k3+_HhfQ^4LTd1Q&;*aBRu%3F(YnLrPt=^`vV-4 z8P~q$?M7}!eaOh25dVZtw+}mFG`*4a)vYeku;2^pLW|Z~%UU6-I@Mm1Q1W-Rkr=Lh zUQTOG+Haj{VJa@|x7?zWOgTzdwZd36fzt7~{?-)XP9yi)JCxx{b!bu~CKvZ2ahHE& zLU+F=j#S4k@18p=va$YwetoJ480|Q}LuoDL$L$>}8PzPuiH(Qm!4gO@chREco?{pe zPBbEVFVE030Eh4@DuV= zy^UZGpO3-pCXm*R8PL_5$UP7U9>88QuU*c7E<&KVmrZ0|7c`iD(nSzVw^)MZV1a8q zFg`R|2p87h5!27^fTX4FG>XyWt}w)~Q}D~x)52=cJeU`mB{%{6p1DuJz39#dt&34j zI486zxA-|q(&Ig#d9AuvQ1MvFD~ueQG(ALCmlA+ac2KuoF!0Lg9w;t)h;bCJ=zzp( zO$+-~#gZhZI9&K)d`|Pdq^52nAXUBenTJ3)ETjdFm-|Q+qvJ3nzk70Vm|Y!@WY8-?E7Fh@$HHTFEEO2n;Sp)i^jR1!&NAYiL`KZh{s}aZZNWVr{7HsrqqXejj7kVJ;AH9 z(KVnN?429Az#GN0rnw?OHM%fsHB8fYH4DKO)%wY)qIIJlvQ%v2(bw1~5V`{n>bz>(9n@ z=&S##TIY1A^ceYD#Gj3Oe#HPAo1G2zu3#F}^T=TO6SxVXH`tp5wUS#|6XI3C9pG=09K3ZB(TR*A8@pxrDvN2 zt+U7mrFB86q>J(}bV4_@jYei+gYO-PksfRJy0SB zAn&YdPBC72Ft-7B8USbdV+LcX3^-;t4sims4)sfF(?&>#+Za|wQ^G?? zB2%G>3Z)aL#MEM%xNiYf6_!fUC}n4rP>buIzH(SF?xqQ*yJw0v{Gsjj_xl(?lUifr zAaGqQi5)K|VW~xXDp|q7$14IVDx=H+F9FKv7X?`Jo3@c^EGe-v9(kL4LTi&xv+5Nl zCKq*cWSZ5*sdqw7SS@PC`@`GUgJ9eZfRqo*?$BDgt2iQ>2>^>M7-tc}p+gE2A*kI6 zQ58eHU|LTEGD59i%vK3xE!N;FhNxv)PnwL#7hjQR@D{5AYSm#FG=v&7{dM}Y4+F_| zSh9C@Jzkn;LH>JRJ5a5zM<|nfEKuHYaG<;$6lNVj+ZZnR0f#YN5nOmv!tK_RV7Q6^ zvo7lDUBeM)o>jB9s;@7jPv!{B{`5@PA}9B4gkcD`t3qCjnsGt&db>Uxz`}E%kyyYJ zI<35Rg4`9*jd?p2Yi>7j+{?O{>wRZn{<4}icos)~d|P>>Gss;8Dh8%sAv$B~4X1u_ z0e2|&U|_fBqcHGXvJzhDXMiG6F3v{)gMCkSz-KSw5p!)hdQF(*SXj z7=@_>)76Qq7})idu^SY!7!pc!T|mAfF=7!lAi1n%c06waDHtQYh)f}obtp&hwV&** zRPm}T_c_{4?y?QO{v#buv0Y+dLi@H)5<3?55=neO8PB9o^a6_~= z3A$Awm~{~x2C!L`WnGx2a&JNdU!MbrTLsn=3=SAIT$y?YSW=W2ObXA?#)pFG;-Df4 zTofio2zhdRPy}da^2BLIDhy#HI0M~F%BfPpJM~dHh^%0Z7}9loBnyWK@H}8sj4F#6 zcrAwt#l0f6VFtO}-T}IJ9jak4_apAqr@<+ed{Fd4UE4siAA8NzuZ%f^4oMM2K9NT&}uU4}mTYqB2HK zL@kEYjN#>~!+1y&^+EnXmnAh;5Aox47 zARd-YMnAj=1b}8EMhBW*(7N!L0bm87*#QQg1!P`w0;y9OUjZl;1GUtmumb5{kMU3> zf({cb-h+*p&oT(EOC#OFbA#ZbZ&5`zv=LwsR+GYyC$peKb{LqLy-Y4*n;3+dD2V~y zm?{ngu>uHUT{P(gzK_LSL@k5FgGU22OXR~;a%buSzFG#_9^^5i6~KK7YE-@1Vf!N-Y1I1qGfS6x5zHr)T>i z!gAG7fwc(=JUu+}+7#~K3Cbp9#m~yN<$3rtjiOp$TGV6g2WIPKioOY~ zQN|aSEpkQg1lB6whP9@jqGtkY^HZLfz&iZ+j%Sa*(y3qr{>p&~>}UXgwPON5wQ~YL zscS-xC%wR&P=vR-tvJ*;Meq*^Zxw_QCq)>g%XW$c7QR@_JO6T2hoEefvtvT9Fj6Kr zSJnBkEPJQ}W4& zT9L>ic=;P=2`V;#m7p4<3b_KW9VL)&@Cu?WJ0w}aZ)DK%C>ps<$Pfh zD?$gPY7hiRIDL_qV=}*rNx8CF5Nf!ed8K`>*XIl8xpuwU1ixCDa9B{*$%IZJ(`Ps| z3_MhAGU24)*D4cwgq$(qcwP-ysHy4&!S0n;9tZy>U8msJA`?ys%7Z)(ozJxRH{7}e zKUZ4HcA3y8Wcx^KBH9+iL)AIDqJ|om2y+7M8^}>CKf1|QdP@WKk7D3~q-lS~7)8pULY(UL>r#JGN>ZFg? zoG6kYRC^o5<+HVdqJE6eBA*Uby-Yahs@--WZ%jTt>G}0}r8gwZ6sv4;zYNPcJMU%{ zc-k)u6JJlsYnGwg#1g+%P@EDFG!(r_4^L%0uLq_mVE4>zp7gwrqx*!A6LL1lBdC!n z&|5XhLhI$MUB>Y-8F05uQ71=^LTlx$MqVVIXRi}Z%2_oJP$yF~%UO#o6lZtHLV!{=ZJ|ln~M_XHFiVR;Fl@vu0T+f*qD|+{H2a7o2fM7K&7BhtG4= z2|Y-k2f&EZB4-C>p*Z`fECjfl<6c+x2VK$C51-d7gmfVnynNNJ3^lqUZj*7`$ zQ=nw0WT7=E&+vKHI^j3v^Z)DgdW4W;pgcXND?o#*qFQAfALZwD$rMPbPZnB@_(#&K z6@E*-PuZqKsCt>Qj-OFCGLA~|93@V#lPPQD;v7Kiuhe`Nq*M0Fg*={Oqp-NoF9g;gv2jTNbxfWX=?J? z6d@?ouTc=f_~38@?FY{j`)fb+t4)HUhxghEqr&ibk#>wCTS)ZF5mG`^#Va&mM z$#Sdb1u=i+LWtQbf2{)V^ZXiRLYv_IS|S!N3bT;uXBBkeqsywqoJXbhX5oS=bW|J) zgvk8%m83gD|7(b&}KXFVat5z75e@;(F}-5}@VL#I3hAqM4sHGYEAPdRq3Z~_1`rZc|g3hu_Gtd@;> zZEa>!a&B%`_NLUN^|>jj$s1ErH|1ufCT&Vi%U!#1Q|{VLN$XQ{vo7C|>^tbjb?b6d zH*MOu$@k0Y8`87ElCn2vuPLtR`}YF8KOcNx`syV`8j_!73EJbLe+H5J%+f9}db)m(kE;L*8Jgx1fQk=*f zky>VmWd<#~TE9!btjumQFH5r7^m{V#ICl%(vWbDP{%Gr1desTU!( zB&(R&g7Oy>>21+bxprnY<(HN0hNV}ii}hxdyciZqxhyw%ZBnkM^f*r1v{vm7x0o9x z3Wm3+^G(}>&Kkiky*bxXSXgSb=av?Zjb$VfpC)meLA1&G^sFqGHMnQ9%+$58R(eV* z+mya8O?;{=%Qu_gR<>nX_A-OXf(qSk;pQOzR;=HnF0pu=O?1DKvf_M{QZvRc-45t} zxzz2Yrji2GcBxUFzegR(ma=8cUSzZEX1lpXVNZ{xuOEIkcdnY1+6^mKr|gUw>WGOQnTppfpRHG>vm!8`XHfFdxjgmr(x&+yC^?xJwFEQ?B z>aE5SeZJY4YbspfcEyr1v)O8Qd3^rW1-bZ#?0fv&kPEN16pS3YJ0)(#RNey{4LatC zieiRn7Lo40{Vvl-$9joRxNwWBOgdM|SFG^ZIT>l@bEhmTF*>Zs4eEN!#!YL|vywNi zU!S@mo2^ezarYGIW;1-+=IZU4Pe-T2klWQG>_x_5_g^@sk&J2&u82?pkxSy^)fQL# z5Uas#Eyxwen_TYk=pdt`^G)^@>Wy1cH?7Uwc-dmLY32Mf?un%)1G<8h^Wm1IWyLG! zoAHDfls^k%XhOra=BhB8u@LoLQh*E_i_sqRwmt3_hT9hJqS#2yUNkZ;T)c8@7nQ(+ zBWcdvotsT>vX{E>Dv`UY*)_1ZsCw0KLs`qmPtsbVN7b2Y;PO#nqXO|!bxm0zO64Bt z)CQB=wF@l8R^BHpRcG1t_Oj9yyg%{j=Gm#4nI1Raydi6|PPcJWw$ub>)OfQ7lbJNk zXj0K1ZrlI}|3Q)2vXyMfO7G5T>y>WvdHVJ^6gZA0m`Y6cRSfM%U8pyq7iS!B>l*dN zB2v#cjOxJP3Z-g9t}fN{GOFj7jhQ_~u}obudmD_Ha}CJIh@61 z!_Uf@(Xpq*QW~#~)Qof3_R%{=%r}=URP!MKow-e4vfUUIgvK1l)~2MQ@sz5sG1@Tn z8cVj@i(Jf9it|v_doX+?r>C+JpUGY|>_NteQqwiYT)Vntp?X2d0@sjXG+@ltJ50r8 z#p)3Y@o+5JQer6Mz3hlH4?mkvWDj5nD}Xg$?i_yl!#;Kz;QL&n6__$XGxS&*BXtKGcNh33- z;qFl5>Iq%V@(AZ8uoLPE>x;V9|?bQ~+^7pl?fuv|h#z?Ia9dF%YLE5R{*7P7_c;+3POIq1echjZw7C|3Si z2Sf~gxw)=IBcCpE6d8xfk)e!_2x`|e+j=bE~~eXOpD z+hTP-`dF-9(U#ojNAXaeF>pBCS-ZD&8#d>b7`MX}cNsk^G0aenXq|?$b$A91Mld|0 z`6F^ve`U1UEVkjHyVNxR!{V&?GTwWop*O>H)ZOr1(?0dem1=jzj`f4Ar1iSYVMmbm z?X$a{tA9eiG z*0oxJ4L6Nvf$=NM?Lcp^IW;RSH8mSU8(do*QE0Q2V4At!vdd&#whJAjMO})Or;)D+ zt$ZbM?qwSM#yt%4k$QMW8>KGAilW3&Iwk_IlRUk)ahI_K{iwmT3k?Zt0KW2#)oR5V zN-0~V9x-u*zQj_pr`S?f>RL&ZTJ;6S5o@np$;7#v12cvgte@f;W(3@cZ18N0vDD?- z#d>=|k?RFmg122hh2Sql^6>*p2CoIc$aCgd3#eVICf@SInU^j-IeYV_RF8GJ?=ED! z?0ik*o+NL{&UQa>4e>Bo1e-J5$K${?Xq1g?q!4epc*I@!bYpn3z+1S@Xg=3uf%wp7 zGLkGc7UdT5)naau!RE09T4t06YbN))$c)(+){nf0xnwNitE#PQylgdQwt;eC3Wphu zo~LDZ?Q@t6OWZvkh9$!?*PnXHs^RBnX{heq8Ht^+)QsOY6?gX=$(xe-CeZq%?Bq1e za0}UrQIk+^#)3tQ^!X-c*aP1~x9w`JSFL1lyH(6RKNctGqegRsr3_wxfjOEP;>jus z5%RpLeNTMInq_;L-eyp{wkL<(Y3ap$5;(GJv6{J7#e5Bm4taO3I6o$Jj|spM{0m~% z0^}uj+)G@y<l^leZlO}6-d0egcD2BzC@`NHMX?Ct z#v5H*3YRRr!gojIu6)-bWxPGr$ozu$LS>jSdghjBX_n&Loo1`ogu{C-dbR6+fom#i z@JvW8?s@1)ot$GL>e~6TmAOZM+6S#1WQZyr64xd$_~saU@q^JUwfvyNUP3zoAETv4%@D4-{gj2|@(=DkV@y1oKSS&3b8 z{X$pg8zojEG|X96H={a?%#B&u?l(U++A&4Kk^~ku!R%}kh6S6;2Q$S9y2M7#uk@@H z=+jZy60w_RSF!SyY!Ta)Ix_Z+EXY@~e2c}*jC|RN`b`~K8Wvj7zek_D|8yH-4ZLg_ zMou)!k*)58gpri4Vy^Xw%~W9L`^~6$b6m_>YrPCJl|oa=Ic6(S%kj`M1F!exOm7&y zZF4;tkCSw|%yjqKl)3lfay^@IDejrE%~K;RNu6&qZ7<^6J7xPY0+<76s+2b z1pr2@RljOc;BGy+m~!o2v7)prAIIa~&~|M?VfM3RY{!ZTC03bqMPVA>ieh-#X*D^} znsyoamd94KH0~_9o88;sn~dm#j1dblF)Uf&d3)?PzOOS<$>?kLSnVYvyDBTi&b~08 zhvQQi6ZidMR08@m2RZ;lR1|T9bMHH+Z`guWZAxx(QfB6wq~we&cXMUy`4+f3!nkz% zQngv1kAkCH@reI0zE4yJr)P6Un(dM$BSY?}eOc3Z z7owv)V?6JIoa5~|2cN5>u}4YGCAm8oODrgf+BN6rYgP=<7-O-Db%mIJO_@4@|7X!r zj_4@0%cD!x3x@mV1?mXhhIMKSY`>796NAGoaBYDt5eKscBL-rlT9LR#BOZ9Bb5TC_ z9Y(J=O(naAcWFF}aXt>aQspIJe0A%YNDl87VKGozFBW{_xHg=Vw`s~$7zi!Ng2Onje* z%t2PN6)T2!O_&i&{c~<6k8d(jYGsaS=7>kuqgj%>g%3Aw=I;Dxvtk#q>oH$e%a-CTFnTiGLfXl$3oWMHmcFwca8 zJF?BPlu!20F($clGoI}dw2^E+Yfde(+xBGfg@%K_`NA=pQT#PHa9)jZu{sK zNn;xST18^>+`UasH_k`4(_NeAMckX0@a<}|dbdq)3h3@2$p=p`q~+$8m6;5d!U#6{?ZS~aftY(KG_FBvJoA=h*CZ(4wZQQ#GWjyg zvpN?yw~X$$piah(gQ-sTV-fVKsQ1oi?l{NeWE~rZlbKnnid_&~$N2!M2%oy(m zg?oNn#)?g)yqi4dhQ;XCK(~rq3!aUrQNF0rv-xcFw1}y9S+HP67tC~6Kb+&UG<-j1 z#E$N{t<_{K@G+Xk!Uh@^8S^`I3MnXM7M3TNQ$}jY0Y*#Jgp25R#`(8vJfGS7rE-yR z-Si(<&nWSK;f417Yu(3RuKp(er(f1A{pV_SzEXAJ?AiRUiWODa9M{D%c|hpGGU2#T zBHlm5oq6B3SxR?blXqP~q0>}dU0uv(s%A|My&!}s&r^lXoXP(t&JGTpH+L@n7@HI_ zeLDY3Pl;v|CI^PioH|{*IxaGN-o;C-yRYA4-jQ!i+qgCLxZ&zzL&2(e&GNbP7cUAB6u}qF3YqrI6HSjl z|H9!{?!Kepp}n_OSN-iTT_3;shhy*k^RvEhe)`{;(~S8$gPlQargJt6VN;yZ&N!C9 z;++v}iF1K-E-P>rVk%*BI@op2-A)_RvCYn0=M^l&xq+pyBipRG z5$k8)JBOSm4!WrS)isKc37ETI( zspu5`Sn+nnn?jcf!_+tAuRf(u&fGT|&9Ar=mzGuGn6YFU+gBNLVZ^tk4Q8 zE0$M82`XWF#f23Y2oozN3x0x7A+HLME9HSzq4HT(GpeS_HS*Z1)m1Cxi>j8&!{u|T z^5n)UOV!o#Ypbr8+vPi}vZ}Vox5+QBN|UdzT35A3-XZ@()dyAY%0I38oBV|Q!>ZF& zKgb8=|E=ni|EKE9s?X&IsveUcls{3`E`PP^rK;!Ub@IEa9;&)ezOU*wd9}Qv$|<|C z^7hKTvimC^l-0}bs615pqU^Bj_mwTOrz;j<{Ex$^h&ENp?UGj4Fle~z* z0p$6tNq&)hF?&+3WpDs_lw8H&0P+i1jeN4)k5vXZ{R8}!0ek&J10D>h_n+#o33xH! zu>XpH7XPLG;Q^2M=LP&T;FSN>0R#Tm``ZJ)3+VFS;=e866aTb;_XCdlukr5)SR0V( z|878z|KI#i1Z)bh`v2fR7;ue$UqDHK!T<9Bwf|%O2LmqlZx2`%5aa*6e_gIhsLm>KkLU{28Af=&c(3bY3O5HuKgO;BH8 zNuVL<^FVdbV?hT4FAi!CToo7-^n6fV;CX>zLH7kJf^G|{4wMBmo~@a*6a z<(8!G*zQ<-da+%3qXcf^ES%<)4)&gRf9_25$&XQNF26 z41O#4nDTeQ-O8(!*})$L_bYcP%Y%PX76pGD+^ftFZc<*PTpawQQXBkQaGNqpsS3V3 zxKVjQaE)@Z(l5A@u^G-q&IFd_OkoaYaUO8Cu^RS}v!8wG{M31jC0Aq%MZyggDq%v! zB^8T>9^uD|_bN^bhbo$d2I2OKs>*sfY4Y4EjeJ4X%qpc^eq)G#g#YRrb^hyay!J-3f0O@9 zH}1Q!(f{;~z5d<)AKaMecUkq0>Tn`0&@CR}*4b;9cR1ZUlz66^@J+l={_^%uZ$B2Cd|US9qRBVhrkXtAwo7hXJh^A` zkGH*d+sVm?Zfl<0F!}b|s&1{H)H3PyTe~K`ee2J+o|>edwEWhIw}wqBz11)&XVS)7 zJCq;SepNfD++SO*Y*#*8TTp9PrYUo4HOd9GGi#MfwEuc}3tD^^x^sv8lw6H&x<(#` zc5IO6uwwy<{wL6u%F%B80<``I(H_qPbfN{e`iG;<)+(LBA@J41@XMphfnYQIEk>z; zhgpMr;ggwYAK8ILXc4NQYP5mHfjvQXw4IY^XU%~PLCRUq*&(wdW*wfbn{{;7z-;sE zrdctw6tf#=S!ego?w*x7vm-P+v}opFsA^{Q%=Xa5p*=J0Gt)v(&eVi9hc?VqPGM8) zr?yNbEv_o@BqmFKiuF-@=_iGHA zoake*iE$_5`eMuD>SFz3wQ&dI@?y`#cE)XsZH)_$3yrOv%jVS2X_=car)zG?T*sVK zbJTO&=GM#!o7+FfFehj3vGBwNCl>UDmoKOb_Y2o9IJh7${7iV~f^Ffg3&IzKhSx52 zE(uu@vGni~-O{5=2bP$ZG%bx;s#wyv)Vid1N%zvsh>k_si;5x!7pWquBia`&Ueps| zk4Rf|GD5Scc~L_I>i=5jX^h5SU<6hPJ1UM~q|(SUs?@TrmB%p(Roy%=!8&2$&3OSc zZ`yxTUqGVYCDn(jJN*va*zK?LUv#7Vc1Li3@V?u7Cf|SCwA+d%cPL+}jjl~o&bu{d zQrD!XZgnbtt|_fiC<<-~4Xg|N>Xzhv+KH_b-`m#|^y%KLy%9n32OYEfXYYHk=fe9R zn)Xo9g&nhAc_8|Mv{~~SbHcj9o@#WS|MR`2_bSdWXb26h3;n7g`9AFhtrxs^U(?J_ z@6WnFVkVn%?H#A@sG0IbU4u%c+EI7p&i1LAsTp^wr)}MTe1FTds=Eiyvz|Bc?z|Z@ z@7aG(-;BiRm)v#euFmNP>bpa9A&ctehaCz13HuKBti1o#X|EQo>{$KE>(Q^Lt)BN< z&Z@3ePrc^6h-cB)9y)!fCi;u# z8=_QEJDxw%+OE-PGFsJ&bXqcy(x8uN(hui09=43pqR&Q-O-qfP5dURmEb^gRh^TKB~ z?{Dr4PrT@o#|}N#dC`H#yBFvdEP7o2v|~yCl6_D2EWZEXw1Y*9JC?rkO!PBpOXoeC zv#e{`Q_ngt{`tw$ClwbLJQcdAZqZjyB|o8E*t+n&Cz>KYZOLkhh(P-vkXuqwpcgUWcBDmb#+-q+Y)ozb2ZyK zuV}hF=kl&AA_|#tz-TS3$?GWyEvU;YvLu?@%^FLmsp;yRtGi4QcIR%#?tZ(%mbbHE zr^?oMUB~s>>szl&yH@S!cId8c+2^cr)b#ID+?sb=!)>Zt`zkvsv=yzDY1QhRyKmN2 zx7^F_8@SJUZ_PtJjiHTo4;9rV?r-0(sq4JE>8_l+y6%p6l07l-g!RdqgFVlNK3jLN z=s;pqdz0os=c7%Jd5KnIuh|W=U*Lv?f;viJMYtmPgUK0 zA9tM4o@o6z?L+lP-5=>bZ26D#Ysc69|51FE*VoXe`l_$D<4f(At-WcVt3T`hO!s-q z5E~pAv<}t$x96wOpX&Zw^h4tJ?cZyD=1~{N&vTx?zv00Btrr}5pewBB!qX4RA5qOy ztD_%IyeRAOf@bS{=f(S92n@P6yYBb{C8J-<8s9@{+a#JrC+ zTarK2>GHBlKdSm`-L|IOLx1bI{CM{lpY~tD3a5QJuUBJC{#;j(msk2()z@{FCi9{H zbXt1tTcP4XXAyVPaTXA(=ZzAO0FYIWYQ|Ca;*Yb`(WbJsOJdrl9?6{=g*d!r{N z)@DsAm~6e>>D(_IkhNAF@$0&==cdyW*uB$2=grjImpn_?nDPFplDLi0rOQr)w82TPY!&8<7o)O2W0$79DAd=cLNsQePu%j(}pYZG73 zT2-*pdf0hrf7F4<*487-yI$;h;dBf;Hf{a94Vn*<({%6Ty<3`5l~VVIrne5Q>*zS1 z^u?O~Bl0U%C)IzCzAEuASvdt;t)Dpmv_Jd6Wvzcca%opr&+*fno;*9$I`Cwp1J)-XF;8*$5YQsh#gvbl&mi!$+U{^BY|se)8GL zZ?lC>Q*!296`7gzyGs-GYj+wd^6w~mxa7I=H?H~6`q{2;BP*8QvGU(XOY z@9yt7@YbOdM?Qjs{_XU=RS)gI>%jg)6-RDHgW7lc3Hh_r9-Vg}`nlwnvR*BCrL;x% z%+%(&k3_Yud2!R>{FiMFl@H!o|Hz%sJKwzNquS5+e)q@9_wIb-k++}!)0@XX`pf5k z|L%XXpQnC1_s6JztodTozw*DaZLiedx#N-I=Wlp(&qt-7mwk7c?9!=Q=Uy4LaZUQB zyKDa1U= z$ZP)MgI{y;Lc)0X05-DEB@@q0bRAvK&J$|!Xu{)VewL6ZOvGav9t#A18$$U7JQJ@`o#q$lb-vYn{jEHaH!R=X#IKaM_ew`<0TMAhe(1%XNr>PU zzj<%(#S|@46QzmP#AsqQahl~CttLKF6B!v96&W2F6B!#B7r8uA8yO#^iHeMhii(bk ziHePii&`F~jf#)fL`OzPMMp=+M8`(QMK6!mM#sl!Vj^RrVxnVWVq#1nu&%Ez5 z@XCd955?_L{x38S z4|)YX&uzJI?)w6-P>UqbT838=(JO3YMQk^oSGYpn%}%k$*-PwyY_f2X(8;C`U;FH+ z%l+Rc``IH`+5JZNuaErFU%PVO{xgPnZv0BVycN@5VwxtupR=QsLd#x$I_F%VxRGn{y6Z zNZ&4Uow-P+k-a^R&`+JkVy(gpLafhMQsnMm=%Bxf!Ps^=z0PKt{AL{ABi}jZ?4Qum zF!GHm;Vga?FY)&LS<3GIUAotNgrL7P*GZo9UzD4Idy4D7^!*bd+Xvl&#|`X8R?ij* z$A$ZF^fMELKw%16Pl)hO;gsMGw~C$bdiI@gp)g-y71L%esEDe-EkRds^{$Fr+}9P> zF{#_-uK%0a+_|Q?XSrQFcWyC@^S<3a>d5Y3HryVw(c63U;3@V=$0whBl7Po2aB!Q@ z!M3vF(9iHF!NVrVh4X~1!a^a9-Nv3~5iYh|;J>%wx&N0uAMgDa@?0)_Erk7_?u?yw z>CfU3COUzEfo}!I1(Mr)fq};vzdqsy_Gj1s-*Ug!_5TVMfK>G7yax$x{hN>1g5foc z1qx&D*NZxR6n)f3$fpJNee+MvuG zsN8j#8`mUd=H_n5jf{*l+4Uu+vf^BWu~^SPP7@iKWi=L<^yUrvVq+>hXZV;67Q=YL zMMWmtEw(ML_v3$E3R)g6dyjQIVIwsgjHPy?VVqG%#jLg1cI$11Z1H2?n>=4c7*AR; z=_PjKcB3uR{ZYB`6lIOYV&)$`9#8afu5WXTk*6bn>4mAk?L5P8JkL0;^P=5feHp(M zB4am~@GmMEc%_cFrsA^pl-P@mc2j|;u5|p%GvhrylBYH5%oJn6crqIW2QT@}C&zlu z`b}p?jyg-HFW528Gb3Y@&6d(KoAFmzI8u|TFU~jU$LpT4qW353OYP%5Cwi^;sSsCz z#?xTdTFi!V#vT==^L(9(n+j*?OZZ0#$5r+&?HmNRgWLM*hXx@!Cx5x@byPS-$%d>*KDOH8y>LalFnR8S4(diGK}myyrxv zmzeDQYA)kFAyQ{4wU6J?mv1oc9=B(umK30m!oV@^67%a%n0W7@A9o9h&vJb}a=8B- zckcMC##N8woZiG|^9xdqyRJ8HPEXlbINpwJxvMWT7!BgYiTjq_WG}Oivv-T)Q?Fmq zFe5cd`KE0B_9VM~T=|R4a($eXe|-r3DF3AQI1`HJUkDrTJP?)n+ln7OdR!Qf!8`+R zl5KnGcr2jdWw^{_FZz`yM#g2?4ZpTSiqeo&%J}Cc$6c;e*9E203&&Zrm=lTvcB=Rh z{_#|5ip8E)R>-@J@nmU(_^DZ5x#KE)iu;?!sh*FKj`PIGC{J0&JH|wB%)dHWXS3KX z^D?D3VU&|Qxn&?*rk9T!@ zV`xYN{v-4uaHg1cKFh!@?n2i#+kp5!iAR4CbF}44&vr8%{xWv60;a=X zMgc2gI{al6F+J1aFGJ5tnGSy$rOeKB_{*>}6Vu@@!^Ey;I{al^&CE=PzYH@gW;*<3 z6f+0Y;V;9%_Anj(GWIZ}&66)-7N)~rhJ~ddCH~03ze1+NUq&G_FdhCf49v)M_{%V| z?M#QijO{F?4B^Xo_%fD)Jn=^c{-q!{{E>lwV=B{q1qI2!LS@Eww0CGO~r)7c)lV%f@1l1zDKk8 z;WFbuJAC0A1lEv?IWn2&ro>~KZ0gvn>S&6te<^T!v6zt`+!?%y`!zyH~Y zM?2cnVLV##;5+~P{dm4-%fs^bSk8#Y{7q}VL;4-xC1sP?6m~v~Vk_AucyA@Ul{K(t z#QQvZk-f{lXVZmcLb|Y9sQ(rB7lb3iI|7pj1fS&=GK0y3FOV~x;(~c>#_Y4*B9ZIs}TC~z11 z6Z@3)uwM3W*3W)m|6_7NAxsjc3FixQgayJ9AzFwR)}Wj^;WD9`T_x-hrnCRs{gVIV zJ@o&0zd;C+UB%9qZ5R4b+WUnG;=8H5_l*+f|K|I}0{^{b9)m_;vs7ohg-%C+nXE?O zziInh?+wVaJB9~^NisGe3^%Tfy>oPEs0-u#l^E}zdJbdzk)feuU&HZ)X*$9u014Ly(ZJbBpu?*D&}>^77QT8MJLf;{g*{!A##olg%9 zy?{KL@jL^0ybR%XJ%QH{uKD25kP~In!bW!?Tsh8}fald1 z8l0E>4(x_U0*+sXO|~Mw+&2D83Gz#HGZ{0<8B0KCWXAJMXf-q&+6dhSZG&2&$Dp;) zGv$os+<`vnI)uHOv0iZXJvax)r=WgejMX$S77kV1i!k85&=efE--mRdZTB-)&wm37 z9bg+Y0eTFY2JMAzgAPLbpy9LdcmU@^)1U@uHnbd?2d(GF4>EQb+5$ZZJqSGmZG|dl zGp2oru^8wHXf`yvi7`90AKHfe9efz+LUoUz+~7x{hxvI25Fa!RdIq`;s=N?ofQCcM zp$X7xXg0JKYKGQBtD%k1M(7~)Ff^|j@j;8AA=hIJXhFO(dR#bd_vVvAuXsK znu%~R7%<9loDFS+=0NS>;ZGy~IMzUqL1Ugl9Y7V&BA;^@Yl0>~4bR~LwLg#Zao$mA zIgZajkK(ww4IIbH7r=0=c?ogffViNC!5!_$H?-zu)bCuz8lYiN-7AP6n)oX63oU|{ zL)%}2O`%=TqtJ*B#Km8S`tgtZyvf*NXfreudJt-Wwn59G-Ozfd@(5mo4nmJX9Y;}5 z&=%+*wBZlPM>uTv7IXdXgBqF%)j|!>L})oQ6Iu@~f*yq0p-02{$BC# zK(nDKKcS4AL+hd4(8Eye|4p_`)xEP>TSJ7x>4 z4LW!s&Vwe-!FkZWc}RZ=(zyufLVFh?|Im<&k$>nQ^f2_)BIF-hw^(4Opp8(!r6?aX z464MS8UwXMGoeS9B7JBNv>F<+4EcvvLk~lpk%$jE06hcM#lRlRkRRw`Xjm-D0Zo8z zgQh|4&}wKsG&2tIKn>6n(3s_j2dYX!el#dgCh`G23N=9cq1Diw^(Ys#19}u1u|Z(H z&^oAJB=Q3dhlX!NeL$62NEdo$i@**-o34QUpt`G&-zeBckMPhMBf>)yOt2#~=W3K6 zYTto$c{nr54{a(&JwUgWz^`~XE7FG+*-#(QgV!OxX#V@2C_gl$N?_&Ch-#z{J$NJ1 zhlXQHaR%DB7x{>RKkvhN&=b%EXmu^}0d2Sy=|jVAN4n6#I;029z60frMSh^+(8fCj z{xccuD6|}U3R({h-;eX4bR2=o|^Q=o@&oC!Sv&4!+W=0W|I!%omJs1+ImEr*`Lc_DYB z99U2^K{LU-p=M~zJ;*osF=#b3rvd&AUI#r0Jq0}m^}848K*OMI2xo;VwKyLW&IG8v z5%EE_4MMC zQ0D=hw*q#Bra*g}ksfs0qeu^0`xwrH>K;e^BY(9mP#otx0lQoRdp(JE13e7Qgw{L- zzl9D!>-q6P*cEyL+6&Eo8s$qseLVv^LVKaxpqgiK9yG5N^}>&zgMGL@kMI?Iyo9}R zoOuZSApgPGHi2LBhowQcK|@|Zexbe4gV4qoaXz%=C7cJXX-EC7Lj6D!pof1C`#{w% z<2Gqe$U;#K4aueZDgd*e9eb(Fgj^#?r+p4fqM;J6Rki(|(d$nR>@ zGc+8!_)VmP*AtJx9+iCjgq^?-LXY7%yZTkr6f-8>0-Z#R(p|v<(d;<1`_CilUHGhJAp=M}E66)vAup?CY zG5iVVg?FQVaGVD%hqgiOJifmm|2X#h1o?&LK%2pPKSh4|d4ENIZ$dx&H{=(p{5$dq zZG)CWn?FN7pp9Q3Jhb*7u=~yEf4)TeP{+TJf2jQ{qyx?S59|ib{s#7fCVq=}YtYV4 zBY)7a?@>Oe@<+sn{G<&a{WXa1zX%8I_zCGl8-7OmQ0pM_3r+hU;)S021$J1&_Y`C- zEE)02WGn@0m&=$L+U6%?_0Ti^GIkj0G*6JRGtlfn8C!e{>>ng!+n@|fw0f?=GIk6) zph9?@rlqsmk3lI)^2AU03&Xh4b zG!$A3O@KDz{5t5dy?l*uls}AD^QmUA(>xT94y2HPXki9eNbUXP~E`A#-tlE%FUbfwn`<&>m=gDxa&$ zSQ|6~dIFjNt;g$5=qVgGLX~TA9(3_qK1YSU@VY7-@vY@^QW-Nq)1c*0^L&&OS_?f2 z?SS?|&p`dwp&eg@W1OG21op$RW+}=6T^uK4)zG8RMyPo?&WDDrLq4EQm!ceLuxFOc z^&7L<*(e9J7g`TJycyx4+qNJ)bPyVnj{IK+J3-A8OwzF?L_*}5NItl4BE`qhV!5r zXfIR?9fS@-!`Gudl_FiFr?MkGXbrR(%F1vaGzWSH+5!!`4SosD>HPm^pj2(oA-3L1&-r7d^7mgDiK>nb; z(D04$y93A{vHgk3RORf{6VuHNB*FF&@dfePa-~O%Tut2j;|BpFHpayQUB0` z&{NRtXJG$J`S=I>LmQt(d7z71Q6A_?XdBf09LfV#Jdg5hLcalB42?O2d_j*w%c1HQ zkS}OE^cXblMdS$sjfauHEX4mR@(1mNnxW&?F7mJ?SO`C!Rye)(DFYaAJBfN9XbH5g%181=|S^8Mn0fL z&|YXwH_pEd_5Bz415^XegleG%XzeFBA6gG>hK8R+eev*r#rgagI>=xD8~h>%_Jk%t ztN)Jkp|#L*Xg#zZn)nZt1DXas0X6><@xTr_eTWCgdC-u{QJ$|553~!K4NdtE;(=yD z>-h(-zd<}u1@r_otRLq?!=WKppx=TfK(~E|d_WD*a?Ve~@1TRwHt69q@FQsL_o#=h zsFxpMM`+rAkuJ0wTF&(+lplHudKh{J8g@JC*-y?+Y~|}zIXeS&K$Y9j-UH+;2HFbU z22GqGXVuV?(1TF30_Q;$fpRtoZT^3Fdmp%{>a2hKbMGDG&H(-$5EUIzD;TjypD?j- z8UD1P(xymdEhVPAfzqahm4ylD76w~RQJGTGuKRr$R$JS}*4eD6Xv0L)%`JD=eMDn> zKvHCv&-wg2=X}oReEwd*>34(9B(Z!zbCkr& z022VW0QLj!1{?%D2zW4BVy6I)0rmr)1dP51<%>bSfc=2^fHk8eRt~rgunzFp7>Tt2 zo&@XwOf@4uU>acba_~16@c|0~^8wr95Pv!Kqlgb^o{0E>34k4dizgvI;4;AIVw&?p ze82|4e87`a5FfA;unw>faCb4yS)m?)Z5Gr6umi9kuq#nw<`pzoB(YS$ebbN*U?Yf&D+O2B%+8o)h(t^Wr80JZ~m z0hZ4Ke^wzJa5~^Nzzo3R+0YNba=>!H3cxy|1Kdq?fCmZx@8A>Q7QlYO14jP@d z@H&YcU3FNK@7JeW1|p!eqEZ5i3X&s5MVShUei4QuB{3-(FgB19K{}*MKyspl)J8~1 zNJ&U-^vI3eSibwc*RK8X+}CzJ=Q-!x=RWm(&MI5pPnlIVJ;rPF?d$h!r-iy+#YSB8_cB|w(bAosH_y00QMerqb62g zRuD@K=XNoH?TZzk?zb}=Ed8P9LT6cRSTBoTkZyn4F4msXc;3K4fys<2-G_{cZ@!`B zp}j2ptRU8O)_3e)tWq2vELBZbuRKF%SwO6%^PKRiFe+0cmSYov4nPNCtek_k!>0ZR zz-O*$<3pxIZJ!#(he{KRq?{5Z9hBv4dExCf+jQO)?ptJoK2Z|1{Q~p0s-Zifr$T2f z9;vmja2Es=fNZ1v_7)E1#@b|$LU@?UGEf-JGYkgMv9w3+IM0#U=p?{`JIfYpBkL~f zoZvLqG!K;T8#`0ZELRs@T)_zisL)u}SQZuLC!0l*1;irrrK=&~f`NlFR*tD(5!`a- z?Khaxf-$A~B=l3L=pl-ApPQ-Bcl;8J;GtzQ<~oP=VSbaw49!A#Xn7iwb`=NdHk)uv zsJoWDj61#qkuEEHB1CcsgV9+P{sEWWIg zOj@#823P?sldLlAbElxE333ivCER^^kqfN-p;6ItpmvV-dZsLTSz?)dO=mCl4A*1X z+K|UGiIWZ;Wa>&RYw^G%aJzN;ul06wrb248Tej;M&K@vX%{|Tf^XxbNZyZbps1^k3 zZ*x0HU}cyOr^$Rc=}^%vH+xPm z6$L#9X@|pig!PwM7^V_AZyScN%(4{AvdRV&Nri@lMux_m`oQ3+?vgWbP{Zz;yVAM$ zSQ}aL*m92e`dNQ+zaG-L&pP*{)DQ2`ao2yPLac$s$4z~?;#z~cXX{zoyiTSP`8B9@|UUXZzUbh zIi#>N^_gXtRqtP%ABzZrKZL!GCcFxjItt+r;`&V2Tw(c)`MA%1xitZrFiz##=hX8) zvx*GOlQL;yjitxIlwA$j_StSQID?)o`qJsOY?rO?3tt|6C2TcyxL2273j^oB?0w_8~kZrc_Ro)5WSIv zKX$4HcI)r{;C^cKl*b3eVf|Kk!GN483K6K-D!ZtlrR4U#75BRsEDF9xT7ig-s`>Y) z@lk78^h%QWVY8PbkxU@I^hyE0S-I5C^Gy@M_ik@YJ<>_*pXWxMyyE$N%!yS33yeNv zC)|9wzf&~)!-i{0{O9KOXa$zcW|N#-CGk=ANwMRsZ zOTBgo4ljRsVq3SfE=LH|IdokbEDguA8-&C*gh&X~c@=gmu*f3ALG1Q|?FVFb;J4>Z zHu4XVP}-?`^h&?ZK-%8p!jxSv?3VjqU3EXwOcWtqZt)^oEh68LR!OJeR@-N0l5~!sR+f(zTXtKW??+1Rb?c z-K+>&OIeV?2551CknD@ANVoAVHu{SD4BbPB6hB2>?b?D^6SBzch=&TfFR;Y6HGJ>e z6Lw6I30M%8B8Ufa8Mv(eEx<+RLauZp|n zh!U;&(BlL`1elGFyy$e<37p)E-%pdRN9v?}$A6%*Jgu5S^=Lx>JKKmT03RdTB?F;{ zAq7A~%BR1_Ew>W$8l_efa3V!=l_810e-bth$l<61c7g#9qF*R)fooy&Z+_>NB0PIW z7T4zq=$th$JqiDXHzYLxm4f}*y8;=^TPIMDvSzAS-^X*&&-}wD3mwJvWuL8;JOAos zQD?ruuh{_8Hr`7DK;HB))Paa61I0;lZ11h4IXJYvlW(Pc-qgY!F|6s!6}f-+beo0p zJ`$XaR$hHDn(1wFFWs|Hg6VY5CTvj#<``zsUhn!g&W*+oV5frXjOVKvJznldNY=w9 z*DLEK1hV-mj-_q!;^z791vFh*MraKQQb=Q|VaSilFa&2_u7D0t3PAeRB=@dDBumJ$ zxZ?2D8Qnc60L~_)yxzjIU7hmyt`!Jn_olVgVP7_~fml)j)}i(LMaWVf&*Gg}5Std& zV0#|wU92xV!Kq!6K9HZc1+;s_MOA)eYv9EY&YLYhS_iP@j}{LxI0zg1Gkeamga#Q; z;48yUHysiOHYM1Pioh-h*ZK5u8%U6L<#v7wEGqojD+h*9q6vQ)f>Id4NW2m0U4+&~ zgfGn;x`bwjkE=4)~9b}PBheQ1P^ zU_i@c0PJ^xkJvA9u;$Z$!+{1YXim~7rV?HOK(Ugd@by9e*r*NoiR0aHJPVvioOw6J zNv*|Cc*g+H++iTUN_wkhi^W;iOz|@bJNcfVpvJ8R9#+}Lrb0@ zjGGX2aJQ9V0@dma)aAYArP77Y5`LeL9@;`KC7g82j#1`$KnZ82m6{W3-+n2bopXEY zSIGVi5Iz}1(q1In>@t=yOB@J;hk=8&9|U3F=ogtVTtfCmR`z`ArldTf#Z?|WK-fNk z5cj*I%T@?=>CUZ2Mr|GX4#8da3wua=02`*d!Pky%y3%cS+ zR_!974V4@XE}!Wpc2YYhV$-OXBL{TA;kj})cS2KB{1h;L@_9YU0PBksd1k?~a8(A@ zWdHC_)d^N46rWYuUB-&ZjX#zM9p*N`bA=6qC`&evblPPF+HF|CzQt&L>@pZTu*To7 z_krBI10+;xaHd<3rKb~2SG^u2!O*bE{ypF}Blpf=cY z!b>5y_OEm%_?(?m?J8Jb22)+ZY$)>I>{&GQzs89r02|o1rC@M?+%HSQ>DG}9lz{{a zj9_iq#qkC0K{~p}9&(u5RVhA-%re~SVB3=7(uXh#WbH00E{o>w-RJK-5XxhMFKLoF zItPUROsU@Uqj7-TbI;Z)p1**&_e(X#0Hy)@Li2gA0ayzMaW063uG0a8*f5ZS>F#}C z?A`y#;XOek_w52ho@uee;LhxXsNg!17?$FX(|!MCoRi8cO_d{f>Oc=Jt_X*g9cS=S zcUvZob9Jw`k4t_8--?+$sNWb1tiQ+fB*3i;!h`6%KP{`9*o{`(8T}b} zNaCMiRbu}TC`D0^mGSg^>m6R=!Bs^)cE-%d@Lu&!v{iSpV2exm{QvFw=sxO zWB1z+8PPJbSU-2yo5J1fHPQ_*v210qGY2dn&}mb$O!aAu{3?!T;96go7sL_S52F$o zLiUd;j<<~ZhgKm*EibCctDCY+?0LE{>G?gX+;xJ9 z9L&}*gK^#HIpkEbOg@9#j>^6Xz1Y6{SSQ@C=71FfZ}wTEBQB6z>O01Nn?XlsMtsgU zYmC%aKcQ3$!M<|a5oboo15*ei7u)svUL)qYX2*@Q-1s|gv*1Us+c&C*8SFE6>_B)| zM!@+|;Fow8==(e^TRiCgHe{lcAiq*AQI*`{FxRmiLH8liS$a!#sIg+*nR; z0EL{!Q4f7Pwo;7N%kzoHDH1d_V^Z;O@DcDU%CqU7c0zeCWP!e+7tp4|D4L=Rqkh_> zLr4w12L0=tVfC9|x~9;`o}VXXG#H7DnW#%pzXjaiXeC*6%$~|sY4n^*7+y^vtkB+T zfckf;P?%A+<~Xgc@zKDeZUhg=b!}3^%aJFcHPi&QS?N+( zi%|f*n&2m0pc1UBC!O1chxcxAbC*~YgxR}8n_rA{qs6o02W0)+%ny5GVATBkTaNU1 zDG7`)AQ#mrKk)PdgB=WKBby$uUAoThycsp)Esm1_@*DiQ`sHea{+(N3h7qY@xNiBI z0Jf0@s4s^$#;y*x?c7PR*X}Gs)JTITSt0bfEh5K})qjRI$4!jZ8+b-2@TBQK8EN|Q zdFtQw8SjFQ{%I6?ljt(XJOO$!dIyCwgclL(^sa2=WteE^!TX4VJ@xs4P(r(ZEf;o+ zUN~u`VAqT+ynN?YKBJT(?Kf5uEInhK+N;M7ziX(sKzI4N*SgGw$-F6h5?*rzoCtKv z^~tDQbTjJQ5{6&6bL)2j=i)qL`;_qiUPxL?QC(R`Br@0KHz)F&1IBv z)$Z@$GUxuM-OTdLnl$ctT`O%nb9y_H)8Oq3+0R~_xc!>bHz``1VZ&|rveo9@Q{Fv+ zw)nI8^%_nImD-2@_}w_R=131{4(C1Lm|i#39@p3TF=jSQy8P9kip@?1xBsbd^$~Y- z&6Onl6d+xOX*J<_FOvMv9v+>4YY?@&^ZS|HW_%ay=o(q4&UfTydIXqW1u}k9=KI z*}KedxJj|-;f&o%9c_VT>ydv?KU69HYG!3BZW^X? zg3*4cY&hi)A;AK|)euf3@)+$|LV6SkDeIS8vUrjc8WyzpCMh4ycQfHLf3u_04EzfZ zX;Pya#d&Pv2bx-<)bB}#HqPUBdc$iP8^Zn&LcjjAf$@>(`7s7tP9_RyC^HJU_fa9r zGJls>#lB1x%*8z6kvt;#yP~tS#0HpX`xHCm;pV!Q+VU2f2T}JTTYcVQ^4rU^WDqe+ z|FSZL*JKE>z%mbikqu%a@FqkPwQw?Z>;)4+VU-86#xx zZm7VXx=^`cYJ^LVT?3E8yas%AmeI0?m)kRydhY#_k56i8)c~xJ^TV(R-WtG+K6Q zjP{|trS3Rt*ZlrMi6bK{L%L{JMCInT=zUWNA^5(3^}|l=5XB9mr+Sbq3x>q8$QX3??vFX76+>!OriFBM z70))0)AHX0=mlSgA$0V`_o?Ck<0-cRi0^;@Os736qo=A7++MDj6FtOGC02<41^zr; zXtjQZXD-=_-tH1{4t&2XSQ|4+X7|f?q3g~BzHIItKaP*5vjexJ9|cH9Z2>!V{g-x7 zF}o$i{^wha3#~LeX$G@=(%u+D-s38_$H!(J=&s3jE})RB;`8Foo3xkHh!H0kvlhw) zQzi^_{z7uwJ<4t{2o4>F@OvS3g#H-(?T^`2(Z^gS?@-qExlwCfgahkwNECwjk`%Na zzB`>?w`%WyecmdL8r~PSd)E$jM}@mtt9~W1!9cZLogHRo&26)F& z2wE}&Av04dXdp@o(SbIj>$e;|+hRk3tgs-VwrWEt=4Ex>2n|^#7@HMR6Gk=QXpAXaEm%7P!3TgPt(NSK$_*^wn#>6~P+GH(Y?q;3hJXdg6X zo2MGqK4|8Lx7%=FPv|z-DVe>^N@uDWaY@a9ujvkX^{RTrg7lOdqg`EB2A*3k{a$@{RJ22>JWtnJu1}nY=IT5f>#=5A$3>81*$(TaP z8UN|)v%>Z>7LQVQpQusPb59a%9e|-A9uXkBUu;V~Op#-O3-*QuB|tKkX6BqM*a)q3 zKag)@z`T_$A|ii#v=-5xj~mTo6j1VmI}^+YfyTl5T5%_ck%@f%dBi>);1{)k4Z1_L z032@)GT1QO)TOIFuI#xdDxOrURJ={$K<_#bVn(6ft8CC&P5yagE-N)m?j0JyOhm-yg%D=w3A@Xv0@W!$ ztV&qEXb)(Z+iqxT=J*>Q$sB#Gu1;ixA=%;0@gc-{t)JOrZ6CaEUuB3dQqy_1lt+S>Si5~J5BoH@# zD%k~Y`9rqq)S|y|a_=x-wmlHHWq!xtV1bo}U?oT1x!a}HY##D3{RZq0Ckn(3=AbG` z!vb07CoZ~?b9JzQ{x>KcT8B(TV>C}$@EjT+H{s#h_)_3fTjnKK}DNT3fc4 z1{_II)3`l}HV8-g zCKh-?B9#rU=4xPvbT&L#4gOQe)qM3*Z==+N)4Gf#DM^z$!wwM>b z1^Jd3h@vG=o|hV1L-d^t6!Gn21v@g`#5@uDzwF%M3U=|Z?E_V4u?(jxa=vq;<+Swq zPLbU&5bw?CgJTdTm*@1Z3i1gPQqxSuoa|-RB$$VCtbOzGsqFDrQak*|jwSiFD*;}} zzu0Y67i5mUcsss)9czl#80G~OU;UMRJ}`Dg zTQ-@Qls;IhoW?`)o;8`YVN>XpGt z>g`RD*L9^Aexg@DN6R$ zfl>kzm>xzr9ig!GOF>%2nDk!+L~@PB;eR-v?tXD{H#eUTg-lCeC?P5*;W;euOhHt_ zrehj)H)VAn&ArND(BT4G4Pcz_9B{@(`!Zw~FO?@d%M{^cR)dwo-bJHytx_xMMGmF2 zjIq&^drfl^Y%~k9<&YfChkdLBKY4NZFZqWZapbw6C*wK~6_;rrT&kV$`P+*aaakNl zlavtbn=$^6)e~jbejQsz$tlJz1B>!6)wT*96v!Z$5JKVbpsZh~i*L~?-Y&O@9O~!Y zzuUwxGtWD;%R4wzBM&lQSP9rW8|9s!Jo-rw-!-gRIfo~gbR97X1*SgB_J$sP!LyuN zP*x%u35MsPME?FKH!9rya+DYb_dY)}b#+7J>D9@Q>_+&@-ibr~qAcCkW%&oXN+zTw zry2TtdPJ|-`hogY)0^9E|J^h?RE^rGxZm)}k@^Cqr?oJ|VEba+kUi-%HrTJfx_?u7 z{_3v6%=$QOhFW0femG} zRs!HR#DFe#7s64Ku`iKk-z&4rYF88O75J9vP-Tl&j;Wrq)Z)>}j^1?+(XmHN7xq6} zZ7qZqXQ<)m@EhKf(v>*_848hQWk-=I-fzem%bjE6H1CGJH{|^2(sfFLAy^8T6P$s- zmrUh!#c7V53tVk&ZXLG}KeW#k7*QB}x_ds^-6SJ?hBpLp-aoJ1@-Z0@xY6}TYa|3E zt);l`w94JOM_n=97!S;OH!@1sIq5ANC)WSqny!o&SVi%jOcJOd_7E~`HK!cr2n{0B z-!F12)8Yt+M+J}}>)r4ag~)XFi5AuO$6tBp@-uo*B_LCW<(6^>A0JursRPM$W4i_) zz$Ys586e~iFlnjUSkI)U!lo=*JfVLo6uSSQKjiAZLT%PQ=P~~wEpon~jApuTTYLVb zXAAiL*jaI%7J>_JkSEhS%d^ucpH~=!F?VWw=bf3#f;qGt`U_YP9Nfft7|3s}&3uzy zs+Be#a868#*w+V$1fls48(^z&!N5S$;Rc0&{a|8Woeu=UfN?Ems*E|p;c5ZIu4u0J zl5OXJzZPyhgAb;6DS=PbW~@;}V`zd8CVij&N6H$*HtcyjB_G=2lMq3T{8#pZzTAFe zq#IK>M$^9~9fEi{sZl?VkjU}k4?%aB5Yr8$_g(@@0?OKlGX~K6)fp)Gcu4&^{`54< zDC4qT$c>Qvf;$fw#Kn#4h76qk8#06`UcCDBN6uYDpKL@Kgjo-y_7 z5e{?=s8Q1QWs49u$!ST#fyIFo?vQ!V-+ijSUWjrJFcxn$&bXi#B8+3=-W>*ajgJ$> zarRm>HmZnXFc|YLMCkCQQ$;I^{{d*{Jqmr*y_jiGA6J0_Z&2gLGtkiex&PmPh$=OO)Nf`_w}9L_?t!&ymdnmMsQ2)mgD{$_9Q*w3*M-LHg&UO zLxLGXwfZ2+^d11gD7s$qpUAJYE~vWQNQf1!08$}N(ka-M5x+6dp@X2mZyz@L-Dclh zKsPeL4WkR@)Gs3p)R<-27GCv=<@hdpol_V|TnoRtGI72i!DDH@z9MO4e(-1?+3E*B zn1V5e6!*=oo7a!F9t62?6?1{3@=fSA)fs9ltpHhuynT-gTV;VaA!Gsia##ye2nAQb zCx*zYOUvld!(4$huiMNGUcAwAR(bv_)-Sxkf~LN@aPuU))@Y+Np^)~hpQ+{R9UJ>b zjotsRF+_x6z?FNi-GAHf?2mEQ>$VtyldpVdzk&Sjw*8HCk6CE3-#T?c7TL$-o$&&) zYZWNAb0D}nR|%_R28F%FL962df|DP%^{9f{R%m+^&4v?4gjM9=(~x^(+((YY5#nhB ztK*525pHQ@jzr~la9ru3!l?uZjP5N(nlH%#9F|AFqe(NXLQ4A-m(?xm2HVtPIw*ra zcF#tx{`NHZ)}KMD-GB6R9jkaHeD~X(VT-EFnDk-2mo7NzHEM_qF(_Db31UW7NkH%X zJlp-k=Attm{lxET?RRYXaur>&NXuHxqXR+`rjR$Jy^+oqJYwcqUf5rr_d$`g-wEyFHTU?=cD$o_8^ylJyqL*@psZM=Lm0q%Ko?Ko_KqpPC=^0 zv}wxOArf;}BF~R?N4op=zt`Lz2dP1xzj~DZb4^`H-Ch+i z0p`tacgxnqT>mDQ{BL~M`CY&H&n2}%+uz%&WBTlBZcNnqKa}P3QrJ*|fote>q6XqsP^49pVAHp|Ov zYH~VA4g1l12NXp*zO*ndFPb+X`+9gFJm$>Pk2$h6cL)73m!*9}R6bp5E*o&w4YW$D0+S zIo0?QH-nKE=w* zvb;%0-RXy`Z%{v6jJm_dpz}5fbZQ=&UUO;um7^Z6048mKU-)*_8U}(qjtQ+L%AB(K7yzjHLfmW_Yqa6-wLm4bqP~-R*@-Lx_7iDZ%$FX^ z$psCg2Y1b!)hsZ_|B!W{WTJr@{xwF^dF?2vXFm1xwT)<{q;W4xZ3|D;-nAQc*G+x* z>acR~Ja%wQSyLZzXea=X_`t`*-oQaPuI=tu5TiQQp;4?8t{N&m;45J8_PWO+K>C~a zyYJCJQ#}FQ4UvQ!L$BgrQ+>;vzq^0wvdt{Q)2ctJD$K#^Pf_`A5uMpRR$Y5Ve>=#w z`o}HX443Ijfq8lnZN7QMQx8w(5FUBAMxqT0uP5(32>kSKIR5nU?OY(ue+udi$(5Ty z*WC%u=*&wmm>WXB>P4ii7#zEg&W%TgR(34%1?r|WkWVk zBwlMnw@&|cuVr6dJd*gN@i^aaL$G6UBjt+dpppN#zQMF)SKq6@s#Bjajc+G@r6XtE z0usEq_489t?mSez1K&FP2Nohh_Ivs|Qv6ck$vEg)+m$C`1sI=1fmyRamzbEQh1U7C zTdFl~zcVR~dH>4ftA1XjbTFT{S{GN6bP?eqa?n@qz#}zjb8Ib*Vq&yH z7kRR~Y#Y-!(>&kN7j{j*Pf(O~3eN56+-PzN5|^jZ_AI(+LUJu`^|+GB;O__WtTj zL{z2xvn`W=|8SLOSo=-luIT1zn@^U~pLT(zU) zJKBSlfwi<@;SkWpqxE~)t^3gQAr;jBUAvd*;=AUV z8lbUUC~ewxz_xHO`vLQPeC__3mH+Jj46*eqY1hCEH8c!w3(w*8+0Q+aHPIm7)_9AV ziXyp`U89tlHyPNDs?{ja{aDw#T(+E_K9^H}L|UE&qms%5J zBZEj7e|Qq6@1er5@f3rNr()vGX;JpUKWh{To^-Amih=l?nQ-CtqIy27tC$=&kIgK4<6i z_Saw=`)mx$Ii9grI3toQH{Zt`4&I5E79CLe2`+S>p?Z<{Rd@%d}7cU=uUFx;& z@)AK)AWt?QXqF05U5);`IG|*n=Kc7B+`o-`*m4X(8gYcV=M;o?)9W`qD(jVs%jZhY z=bK=xBDVn|FeaMFs)vxNzEM07HYUQ`QqCYQ*?`u*bc235vro(^9 zU*0BXBlYMulrfVoyaoE!KEn#97e)t;CC4tQbwY@BDW zo!>l_p4uZZycpnq#C@*Yijxuy{aC5x(i&ij&Nl{XC3wI@9DUuSY>WN?2jSe!3EmU? z@~yI;ZP@Z8{~F{hv^+w;!tF}GLD{qw<70zhDYtO!)HvfMeD+eiEcp>&Uu8|E5QOfX zNCp_Yr68Z*oLIZc-??ZfKOKpAt3C4hoa2j;UGX7iFCyvlK)hl@^30P{kO3cGURR`k zQQdk+qp0qd8?pNhVs3c0w0%~r=Ut8H*9;HqnX}<4uSHy|e!aL9A^uFYXMXbZ^lb_A zX$O43`8a6GSGro%(BU+ClhZj}ft@q8n)UJ|Mq^Y3`y?{-(ilS!wl zuT?{ep5p6ie}CQ5qX`d*v^V}suY1#=PkENyCc1YzVZc zGwFctz*G?5zQYs$bH-0Rqc#$64gb99CzHe)=w~b_KO#7v?PhTQHoPc=z$)L9bU99; zxWI8V(JpS;a=LS6PgDF~yW8N}+?nA?fAeph5{;Cz8{b_5NjKiRtbEWI?{Nmkv$E+=>8S#qvE((Q}ZeIf)aokxVbM-ToQJ=tbuMIK&u|E!_^iQtu5zRo|}I$OqjOk!epmRQ%gd#HT`NG?}2g{^u2w@xhZ zu*!U4;dK4B5^SC?>&Qx6N4XL+Jgqai2FqA0{gUr;c>mR(+vn)N)y*i+SBwqniyPh^)j{VALcubxaeG_Im?13)!ca(HWnz1!pC|A4De@X z$U=!S4Z9(P&`reitiN$O4{7VrEwp7hqp8c_D8tm8sK_9zn+ASGZI!KQLz|M;GSmLL z{L(ak^!%E=`tPUP(eJgiFg=0J*%?&3m80B|gX?|E*AH!PVy)6X*Ea^4Zc&rnGgZz7 zJ$})4$&r(s8eCa<{T}w9d+c~I*i5&?|N7YZtUp^GHK$2oe{J6VajKEOgOuD?eB|`@ z!$RTpor)URhX4ur$8)DiiGL?s;T-wPO`M+BWIcU^XV#2+vF!XKe-5xVioSZeBiVli zzaI=*?w1^v%RU1a{I#-f_^j-^sJQ;5AjTSVNwkI=^nqJ(`Nh)jneaP+Rp`;TF+{5L zAs&cYT)x$@In@BrSV+eXKc@&GsZzdNc>buR$8`E$?{m<3`au?nyMz4_su%d-F-7Eg z>0Z{z{dc%`m!)=7i?Ti$|6Lp}suka<5TaXITqAc){0ffa)za^f%69cqaK-{|PnCim zcwn3ElXjx%AzOm64x4HJPS(d)|eA?po z(JFD(k*dJyofV1u?;MD)|A+W6!eVqN28gNC;?!JM z9D73zTw7>+axW8=e1FIqxHP4$RQm73_atPC+*765Hw2aUGpFS?E{@LOuQzIZ(;HaS zZA^YUjhO2TiQT~Y-u?4*O^0!fTKJ}9C`oYRS7M_zVmA)~|5=*{an!55EUdC7mnZa{0kcgHL8LWicNtb|O9I;n)F$ zgKx*Ty42U>dfivo83s>#uK;?%%D801*PJ>8!08P{!;!nT7!MBd;CWu7GGhv zNmMgAVOzKVYL7Y4xoYFcG1T;|3RCHSA?SUC$X|@gLl+P<4Mf9q;aRC(;?pNYLVM#a z25X+Cp6t?zNL~U#sCv84`_jbf&+m<^Kc7yn|7`FW_NFzT?>#E(k(xs6{PFh3^h@dA z;ivAC4Zsd@1I!`X%3cinlV`QVaE-aLh_*f_uiB3l49QzsU))5##r7%*!tU7j5;-aJ zt)A^#>|Uz?k3Y#99x z)z z*8eZQ&XX(s>Av@|o;DkdQ9RTX$gzNo1P-m-{4M`%5rkpIU^8-if<+hb!I-rdoF_js zUp^39umCn_+k`h}zIPkSyz_M0?)-zS=12d%Sx~f`xzuy)^+_NRY|}iG-=i5&_$Ns* zJk8_EA+r)nHGC2UhLSOw;rjob!s~q*EFR6#Y0H-?!mO2mZ>E(YSYNIg1`7UJdATlh z+dyg}d~;a|19}K3D%NpojC(wwWIJl3%jg)dgvHO0MyPMn*{N$f3zy9PV@Q54JaOH( zv0=8Rvh=Py#LwiU^E9aK1vf^E&uRvfu91T%{jjk0i7371q^O)IO_d*Fj?F_&&g&@% z1K36axVJ#|x!*P(Y|gJ)=3o5Hcc_0CiID7whr~J@n@NiA+*qABlOrM_OZ}Jjem%kd zxwWL|OW$0vS3$tQ<$u!hCH?Qu#?7mYHu7C=c>T0{OLr=*M*3C){faL~u)OznHRiTS zV@*hB-E}LeiQp@1LJ7W}n|-;l*N)GOdDax)!GD$eOYdXBhs|`{pnJ?({y%( z{>pLi55)p63)OnxW^|x2B>6yB{48MS>iO-BOp@w#@y+G`KKQ{eo^}77e#M2)t3%E= zj^iSw_N*VU^VYu$-}rNrS}t>uU0cy2D*S@dk0-g@1O>^D@U>yE>R^D!JW5J@Z~Vv9 zQkTicVdzZxPB8}fYX^5N5D_1H9GU7s-JPABmlYVY0O$3*$k_d07K!; zXU*D^W`3x5rMmj|5kM;{+QYqGaZ6kJbv5gT*~4d67B0)HY!_==)OCS>y*_nV2o6>E zB~?PE@nRSLEUf)OieFf`Knl&qLdphf=4++C=kR0j8f1N~9-*DTlegoLPU4zyvUkgO zWCYpjKb1N4eqHtDjn;5&wN9<0s>~d2yO@c!w+^kfQmb;>s>ZUemE_?#jI}M7ShYj1 zWF>fL8lkR>f;pBtinylRJUDlY8-1jY^E$E4UVe4aS1I_}YXkM4^fznJ2(m-iTC!fq z&d;?6Z@{*j<5RotHtXpVf%E!MVr~j@Y5AjUk>~uc^<{NXv4QEgU}~l2hY(_WoM4pPB*rhpwLKrX4>wSUXXWmh4{SD(NgL9kbN7D8dO;!9(ax5 z_@Mvu;!D!x%JQ*y?$3=A{mDN_bt6z+knp^P<9k-}#rliHbkjm`ER${e35cvMW|MRp z=g~+ElsBk0Rhw*+Y!96jcFZP*AHVDiQ0W=cfAp8W6cqbXJ?>h7bOFY;JBB;%+KtBX ztlIjZF+-=Lc!6aO(sL$Q*Y$=!(ILK`q6z@(kEhO6ZH5aBh>nKAC+oLj?)TPgDit6u zbwSgH>P69?ltPP~+Ha*SsIsT4a78;^Uf!~Bd(ycF$32@YXn9Ms(=;FMzY>khJ@~Yv z8jaHDJGt0#T(ur_W>!zw34ffso*s4R>d@5OP`o*MsV2L&?RBi}MAPz9W&VvKoq##z z1$`kS{6s(rqT-Kq<5LDSv|h!xcdmzoTl$TkSO2s(+O0J!YUiO_fKJ!RIa&U2TzKMG zYp09QYoQ@D5!4x0Cpsx5!FX5}ahTLhM;N-;VI#)U@5pQwmaTxU_Z9N$6$L^if`itU zh=>)SIHSkB{{<{h2Y_aFIzL`tyr$-{chtfdHvbQ(_f{>Ns>w=gzgRDelZx46Srka) z^p1AT<)K*%jG3M7#r!C7V9vU4)6J@X*v8+{bvw@Y^q%E$K3?4<%Lf!cuxBxPsTi3y zIyv3^as7f&qjxl};zq5QgzQa}`acX!(zh+x`Ynng2Rp$x4nnA6!4#RidBn{> z5^{vV)q3bPeK~lPa`Weh+USkpYsaRBz0*nWB$)^F9o=4_*5eMw^X>%c5-k61CDJ zIpBBZ6>G5@#$d^7?>wq%I;}Em-T8bc3j{BFWiMr^m+Lel?KX^GT07CEdz`qyjjll~q1L))=GDZJ4JcB#`^*7AoiJj9o@gR8q zwdo8f+{J7fv2sHDFCrvqz42;i-ug+~s|ecV?*I}F+Vb7G$*4M|7x@fRQ)NXyegab! z)`|8WZWhJqmV0fvCY-5G1KQd-RMe@e>0A1y z{C?P#R&Y)JJ&JfFf3@omQ^zxd^-4cHYh)xWTxyqNCvLpPs{Y6vILOX@j(y+e@%!Ku zSmTVBwxi$CSCqPKS=I0#VycU@@3raR0q{9+mqEn4^!?Zu8Ik9L&!==s>ia3-d?Sq& zs%@%z&W@Z(TK$-X9uf5%^yz-B>Zf|9$e>2_l>wKBy(MaqWGAA({J?TUBwI z1`^GCqnEvRe|n(e@=|7m0_*T=JuAYq#642YNa(Oq{q9o*#1w&%t(Q8)N zsSdGtSNLf{H8akUWk0q^IxBzMVeXA<&*%EgV`;`|jZ0)Fn>khAy<4bnnMrw z;Pv`Ru#~Gc1{NU~ug_ZLXulBuwAJ%dpy2MW>Cd<~*XV9}mD{RyFEUpY7xI0(eeS#+ zgM&m~pZGcXcl<(Vm{Wlq=KZg;ukWfe(K?c)(oZe7Tm2Y6?fCBd<1K6m!Wg9}jzM17 z&LSCX6y9mJd;U;BZ;#=Ljl(_{S+HtvyifSl9n>Q>b7H%Md{k0=P4#H1=zd64VUz0P z=i+5SBC?c@G7Ib|3+ealrNe0*0sGcypT<{%4`_lRGZKziPbG`f+x+VH>xSDQ=R98z zMe-h7Q!mR*RDWpnWe9HMrQ|J)%6ra_s!Z(4Eo8z^$vmQpT5bR8D(kR)=47r4Jxdh- z{gN7ulS9q4J6^9BAtXX82yc7j_O5G&COoQ;spVNqow#X9esFLlj5?7GI9fjM=chb_ zGI|K@r7WLudN7_6?P526K65 z9Qo#CoLwq_ahLn#tn^NiUqq3~Z>9ccshe0``?4k-FwXXmay?k|vP#fe;cqFS$+Ufc zk(==qWtkebE-wKg401DV)oJu*66J2~&?9BNa5_xCQ(IQZ ziyf4bgyP+J2%nrX9qSFb(O?m#ZlMX;Ov?df%I&tUnOw3{&KIsz5+3w24T1TGLJM%A zXuv`>c`SzOoeC!S2tCpm1eLZl$V>!@b$0dNHq{llkV2G8*=5PRe_7BS&B*yYj5bgTv@j=CDZSdKKAJ5`GJzL zo534#CT`!)=3^XT@Ln=C&%u|A{P)G~HBB2`YDT$D;Bg`!45`+W>t?M6W>noi%2hWM z(`?ioI)K+c_&4ynDlvWkCVL0;^BV8}0cAj%zc0YI@T_lf{)6@$Xc_1{ds;aDDw`|N zei{>%@idiz^uQER=!9>(?i_quvQ@o+@OMonk5UE{k)j z^LK6ZxempylaA1tnq;A7BF&%XL0A0OE&k{ETV*Bx&Pe_IpF{o&)cj4zpZmeW)`_I2 zwh=6i+JY5nqaJDGqbFj#l8OE-?ZcrlOX*Y__w%R>G;Xo{gVFfJxsc*Sn4NKG1Bank zR*WUqpbuCsMDe}wR-?_e8onsWRyn6)X|_SO7K7p+P~B+#$1{HNjGeBiZOy3SNWRpD zJnhf&*Qrjbo26%}lf6+h-LpDtw-&OVi)K<9?6aPBTDcWkk({szfsCqgX0ASKc3Py`QtBwhZHs% zVU0%ru*+(g5n-*ef7pN;CL#=NBdvQP8{0$jm^k4&8WRdPja@2Cns8mx1a_Uxlm2D* zoH?lP{t;{p@#@{NY~2T!+=cM@&6X?Dx--yOBlyVud`o44hbN}elyuLy{!7TI(xC8% z$6?BNz6bQi(s}7LhBkt?q;tj%ma&;O5BZ|}=-3_hT)m#OoxDX8OW zwXNl$Zlz4I8)P%SLfd+qpNWltE{?=lS%NN(LOP?7-Wa4i7P>gjQ3O5Y?QFc#=g^r# z+Vj*TSq4g;u??i$nalY#747V0@T_w_OC!2(Ct3eE=X#`Jn};-}*_;%I=#$R~PD(>QqQs9?0ci}-=ot1MWG+>r5|ryLg}Xk(0`@U|5c%n_tM8(Z=&?C3!uNC(*HxD z7d`Z&m~$SbKii*Pj$mVwFRF3Q1jLDA&Q!!noTbF^(z{gpfBMrW39|icBy(Ovvbk2V zucD~5c_B%*&!PNhva}j8HmYZo%_$3UQL-=|PdpxDR8JJ)03#JB3*s1gqqHZIxyZM3b9ILMKIXDjZOyP%)!=z5 zqjPf&9)eHe`8l2~cs6Hr-(g7UpTV54H-*IC9;7)ZOW99J^Wm-6D*aU)ctZL``P-(k zw0bda&9qUhkjl~iYPO5~=T2!7-+Sg3*}BVUQ@=1N{ZTjkiEdF|*iCCJ*Yfid*274r zbqbpkiMl)je~a){4^CeC3h=yrOR z@%0FAQ)!Gy=TL-V6F$a6z6kB>7{HVARgP=ng&fzKi#e_|@aQ%?4+~1)K)OzCzKZHO zLD3t+LH4{|n^3j|rE>>}_EFF@#;Y_BG5fi3N?SJGLb^4+)*8XM4*nE$#p4yac=W61 zEHp$7gB#MlsM|_`zeDIs*4hT={9IL4< z%}En6GIyClSZg%P}pbHN;)F?ZdRhCkxyxSq@>{~%grRiY0B?h`h6Xr@88e;+iMj3 z4}f0}-hYeycpHr2XC_)q%eEAdpPmN)Td@(!x${m{PL>s8xSkRJ6V-VruYW=Nk0syr z-KFpY3g8bcfnTr~{>82EFMi~J&%pf)ivNu|7ff>72)T6*y3;)GRqrY1DQP@MW4RiP z<<5YfY>XTF{4>vQ=<6E%ewm+#9`cO?=j3lweUT>hT%~6$xE%S|1U85KEb~m|4Cp<; zot2nX@#oFW58}PpNc^%`&G3sZ!H>HGFr9oN#B)g#tw#9GJ#_6}=_Bbhf7^q;E)%{V z(k`U^aNO3YekrBjiE$V8Wkj<=UGMRP$CajeeaWSut&C8{5#&q0ruvd`rE}Bc1AIu@ zt9Ap*wBftRxkN+1c^mlF&3#9Y-#AhA8;^r;9zTzKyw#wmah{ddX^^-4o-$A3)sH)U z^<(H>0@^3&9hYIu+2nf`mHV3hYaQIrRL^pH>Ujn7>%=(8^Dg+v-!q;k9iVlrDf|rd zAE8sq973S1-J5SXzKq|;!kT61CCLo^fjS-%t>k}ZGUiNv);4e^l8<9+t%7adn)h;C zmLBFziLlLEy?I$@=Jffkv=Qa!T(h!f@M*ma7 zJUaK#1$x`_kex-59myvJ@rlo#G`eerdE_6R1N}+Rx7vs3t72Fo<+l*!C%f1Sy^DrC zqe`vmG=At%Z6@FE;A<~MrHxYiLvx9g_C<-A zm!mv6&>iDCKK|N|vHf^YStjlmxD1Il?hV2D7<^eO!zJ>2l9=6`UdhM&JreZ%wws*e zN!Bzbf~+%*DpdQ`(<9ggvgfBocHwf|OA|f*5$Wk{(q;J7-@@0=n`9Z=J&ieUkl6LS zJs>}#%>zxMm8E3>*S!*aOqBj|=t1*N8W&s^ko;USxg%40&gJb9R4>)E~>K&UK9Eb1iJ0 z9Hr(eh}=M@y30`Fw3M=>_yJ z#|Zn3G@~G+ucmST&z@hp=0IK2k%{@GGY-t)`V-YFp-!|`Non2)UOgSj(qqg5CSLN2?&Z<Xse*rqekdaWt7i+DC&LYqPsAP%V2oz*c%sihx`H3rO%*KM%XPnUzGEA zcOlIIlfGC{_ddzgTztfKOBCu`T$Q1lB)Z#`_J{VH#&ujX7E$V@*SFHxsoMm*omW|~ z2D*_-c26={G48b1E{Jg65W~juF-4Na{;dUa=i`dUyyFVeLF=I`=eca?*@Z0U6MQT) z$?99LtR>3a1|R=`e@SVI|3~%QgYu*OZlia)^MHJcl%EXEgSEY*u8H7xBYb#@PyHH= zM-Tsija@E8t*L^pyfB-k@v@+QSdPAcUBwEQQy*~+Amsht45d9h4f{{+cE%i=i}vSJ zTW|a~RtW#qd<)vZwb0{FO7B0=Cqzeel+HYmN^K~D_A*EJ{xXFXmTg#lU}lfax%Yc_ z+Dz#F-VLh<>V%l6nTWS%gY`f^WXj7pQ}L6mkcA1pxfQZVR%MYz>&iN^__09ygiY38 zA&nV`8y#O+Kyn~l8D#Ma`MSWH#?%&AS>uD#P=0z}*!DEWGRkjLdO9eEp zu-+2ky!33=yhBeImTnc}=gvTzq;T-tI%7$M(|TKkvkba)c%-njZiU58wv5(tdZAap zfL=XQx+Z`Uv_Z0jbiOo$$-hBPwOqSF7 zPx9MLYiNAK*AJkdXVLb)Sjp^J)b?&reccFH=}1N9nRlaqS`pzKr=D&70Qpf}dHPo( zuTS*M*{JDUhjbFtk%G=2Qdg$}CoD{M< z?kQ~D0`R;9zBT#owARdho25f*6X)Mn=3vkLHamsN-G_Lkh?AEjTc3u%*E3B@G0_|- z;w{gztusOIB-9;!+Y*cq_!+2f_=xn|2prKZf^WZYL1{Yq^?V)(>5y;V_Zr4*SKQ<2 zYE<8He}XbJ(wyrxO1YO`2mb?kmX;=`(-?x}C~vGRNZBY);`R)B)Q1DuY{4cd#_}DdVgu^0sKBpzbWq{ zc=yed=4$TW(0m}D%7%Q8UUAP+%2;;byfn(XZ-eE!7;#k8C~?%Hy&Hvb|H4CQeu9V2 zDp}D#Hlr-$%a3RKV2}2VrM6JSly*t>@nh&m0_-2rQlENSH1&#-Vy{=b-Dxx6@6Uv< z(I-VM+Kc-1T017@U(+%1b@U0RVgFlS%YUo`97kA;2EOc477CNtgBil7zFx8$G$M-H6 zgErLzKQ6$gzvpAq`Fz7K3Z2$lZsNA%wNZ#;FXZ*3_au6M8Si(h;Un>md@Nb;$M9RW zSHqshT9XC2);(_W2AX#ljJCbd^}U@@mGe8F68FkuY_7AG%K1HO(C_Ck*H+aBzDRR} z@C9h@@mRn6rEY{b!vAfJV=d$Z(>;>p%k@mlPgytu_Ea3dNH)q*DH~0b;ODK8XV*@o zbvj;ZxTqbi>!w2k!@706n z68sk0C-9kQT(n>UbDje|?L}J%dMelIGZ+`chSOLtGlh}wc_DKGb7h{Pb{omhPj^CA z-}3uODho=#SK1-9O$W_W{qID?|JrC9KY;-$tL>mtw^Tv0PK`Sor55{)dTLAI=^?%p*0`U z$z=G}Tc58iK)qHss`FTZ`w0g_?Dtsjb|3TZ_o%k`?f2M2WeV8u(If`n?~#MD(SDDZ zfc+l)eEAin%!Ioe@5Olc?D-z<9*_U}qyPCh?_Tm0W$YB}tikMq>V6pk?Hh8Xbx+g$ zg{~-LmMc1IA+3FWyjqC4=Iq=0%(77Ce8oelI2P}7=HiH zYNqV}iPYJ{6MfKs50ByC&>o(^{X4Y&aJmH{vU$94xXafs^$no0C ziX3CrxEEaRmlE;ri7yM5f2TAm(Kp5h$a)22+dAl;GeV8~4(Sly_2euF`f{eS(0Ro* zf24gB!>H@u{Pp%j*HByHZJ)|)WJ@ebs5jkX^u15LQD;y2>CVO3D1RkW))eILmAy&B z(M{VQm2>vWISKJN{LL#p(+u}tc{~DTr+d*yz{W(v*2u6qM(D7KpTVUwq}}70%d5+< z>99S8p8M;JMn13ELVG=Fzad{o5wd7b=s2DGyjR^<`k0{D0~7jhzLuAx)Ja*-H*^1? z8{=T2ZJNpEtO0LI1m$eEgVy(G&kpQHD)N}OVu`E6!1voUU#0TYaS3f0V_a`M$Ukp} z);?ulR_s%upvarblmZ*|G;CC+U|QXpqU6QL_SCnaF7U06FGpR@s&)Ax;!;^Ce<};f zmEP(19MESR$#PzsWwOuwD$7}B{J-qIe_T{m{y%>1y$sx$J2U+LH7WzB#SCgvm{Ph9 zprwP6jA&|YfLZ|)B)IKrrhuYiqZMkoX&d;XT_H)`)Szrdeb+amb#raqw%S#*AL3+6 zWr9KTdA{zwBLkwj@5g)JpFh5zKhERcbMHB?^E&6eUa#{y=e*AA*qUktR^kjMzuZr6 z(1X7d5$>U9Yig4db7vAM2}}gBIbVvJcnZZ9*VTXFjnv9j85v` zRf`P$T#KZB&PBqMCpa#c%9rE!L?UjwbwAJA&VI$!t>>THKx;m!m{(X&VNAvtOZ!1o zmsVQK6_hbo@rxAd-7g&BU87j>hPuzI#$AY?2?U%cg(=iVv_DU0Zbt$(`DJCTx$&uj!ge~lO=pW^Zw=bWf^1FA;E5bA+MNgFZus9Op6f(qX)MAp+8 zWZI*-BuMq`g08-G3s*-zDoDosjVb&4UOk8Oi0w@AV?k|CG%5xa$8=mO`(uBM;jMwJ zDXbv4;|w3w!noNlRpF__QyZNdL$+j>p@-2rhAmW~<%DoWuZkP2_}r82MbPddcc+f# zB>BP09R#%2WEsG+Xm4RQ^nJpw;73pP**~P#Wk9wFKhhdrwEJDm?L4UW(#y2xqOJdt zv5~nbKan%F*tEGZOe}~I+5DICq3>c|c$DnFN&gjht=d2`if|dRt7=1`R=E>l7Vske zKY7VBLLRHGV=w!jUa^-g*Y|BN`%aT$uP{-yd0Qzcwz5pMSCnNA#;;XP-Ro;(2e6Xb zA3Vv;T9x-f&xJpDcGDcqYJd#Dd?*p~l>0f!`s|S5_2Ulf!5#vM-7v&zLtb{S8`cX1D^!8i?uCW1OXR{^WPjQnWyi>$ zHq}8~bawx6vFV^NR2v2VU< zZ&dh3?q6ZO!ZQIrJoC@^ru~iGJVU;1h;L46)bp@%tMDJbnDpTUybC)!QzLT%)28}jLs+wMtjq!|2kboTOQKa8JLMO z{m4G@WWPitb07MOEw^8k(H3&4d3}|U*}f{em+yq@Y7t7=GNrD%dBT5ROc(>yV_5+=*Nkmt*?>R zJANemiNIs9;(=VWMCS;p>W{r3^md-&lQTB^MdU6!WyeG|9&WRAb zZeOe2bKJgQFxlS&QFEEkS3p@O72QOrkuYDm1*y9jmo;naevlE zK8neAmamVptNlE`30_jl^F41$^7g)1Rx(hvp(5Uq?F#{)K~6l+LuT?3&ROIWiDykU z{;cCS8kSrYVr!~mY(%-y*6{?=))?89bNS!CuFP|M&sbi5gm6*LST;kaDnOocz_JMn z&nb455wcw+kAIj;CWB|1o=aoZEBzIo8(WZY#jfid$pO%REz%@>_^cy=)s~_^o#~u1 zKOi3xC)IK3Up{Lu#G4-@euM|4#9jI-$=KP78IK{!tuYT9U!CW4y#KcEXxFablTDf8>u~KV?IFb za5Kub3FPlz@s&q@(FnJ^MrH1wD&(}MArHaZ_quYnhTxH1Ea}hMiU%7-(vJ%)qo_~l z3^o6qC!5j`{#vBrf}>n17>mbNm>b7(tb>ORN#}d0F6&jl2J~L-Q?Ev~UeAzER->Y; zGnC^24ix`w2-!wOyUd)q{YhFoT1tH92l}FUtOotF?(@1jFo-~g9D#*Gxm6M8eRclz&45F{dUa&@V z!x}^XBUo}R;FLelnrg#XGWllNf$!y+tfM&$w1m8uWFKCU^`cfe*Xw&`AerbRlqFL- zDu?iT1>rL3D~53$mf&$6_V96X8*EFDX-vR#A_Ao&+kM(kqI{NUq#H11n=RcW8`d+tQduZ+$nXD|E*b-5HfJ{A`5D zRfjsK4rbM#4ODEvC&saq@Yj_yP2RkpnU#Ov?S-7uv*e4&hc+K2|Lcdcic?2{zHY1I zS&Elm&LC|r<_MIoj<=!prdSrlgg`&GZu3dohO|uQQ(mZ2-rx7i_qh>_Mk=!d zawBShZB0TMH|iq|*=a}B6VH#QIz7#`mj|%jQ5pq~x34w}IdZ+<->cY}5?|At?@GHI zQu&eot9dtpN2y)tdu!q7QHS@cXW|803h^rOCHcpG9sIR1U71r-Ijwwu*#Umq!Ou)zijL)E(t?Uh!Mc2(5bd ziT*vkSCPklK5OR0+rGZZ@?^bhBKUOz`1Wq_?_HQDke{+~(7pH`Hpfrz>AB|S^(#R< zPBiA_aoK-eD696`3tLjU`KO5JB1l<7qdb((3|&i1sxgu89C^+?raOHe^=n}FI>=PS z>0Fzcg*y+jZvAPBTGz)=*ILNezwA}U8D^00f2kj#+S=0D1WNx1{Re$k_|u%kCfj3^ zEgRu9HeVTU2scY=H!i=RGIZUz-0FkNA;?xLch_F|99{=#vCncR?p4m)Rp_o=ZpAE= zJHETzSmcQf@R5I|top)k#WtG$N9~pEH|hUQ`Tr8+zo$EY5dGKq{9l9rS>69d`tR5M z|GCY|`54R8?ld0sJrla?{@ot(->KC7G{zl_hb6TUHKr1(zX|onn0isw|CKRbslRrw zqQ4ML^+lJx*HU}?qdd2pXpqJnW$pBb`}{$J0WuA0tCu(w8sudfsHvKDX;HQhbCGS{ z61>x#W+nRN6&?Amw5@4@-sDDK>VLb$aSl4=Fu-`MZP79E+w9b^>SqfdYY)=tTssSH zX*Y7_MmjT0xa=r+tew)V;sUAzE6nXX0FS{;YBVwxv%*|Pm&9rOle-$Ccvj54>N zu4J2@Fn|U7VVwD9Pxl%m(W$&%9NqVP$XBK)bGf_GcglJL`m4v<{gYSvQ1KVk`IrE? z;|KYpsq^+H`5OE|@Uxzg`%|!bZP42V&MNoksB%X%$(DQLTzAabwvo<>Te2|ko2O|? zU#n@Fc!KYMZktT$eiXv2;v?e5{&^AAbWf#h*O5+Val)-rQO8=y0lGI}VE}8ZTEW_7 znnk-Syctcrx%1R*;F*GMezxSKEMU;QbRvJXZ5`PX_O&lfdrH0crF+}q zjZpVYZ$?_GL*eQ0YFb0pq7LyD3jS0lX`r-m0&p%oAXL+Ow)JhE=9KbU9oaF_`aYHM z1?Gu#R!w)aZllEzJc^YKLezG5?bt(0{aSilT5oxtaWd9dtJ?KaN z%FXr3>e(b;--mfx`F<9wm#*4##)D?YVXUJ4mb5E0M)mqu)ooh=^LFrG>g%X&Xdm6v+~kcA8$CTzt4xRslU8xQRp`NyOV>8H8W@#AFxdVA+2~9jIP=f#Wgt z{WZLAeGUAT&R_B2+4-0s+Dd~q^GDkSfWO4L=_-HG-nTD*k*!*X509C^V-vw+3HjSL z5`U4+wot8s+!3zu*CpVa;1oCZtB3-BnH2t-t?<_(;ME)q{tASig*j{fRs3aA_>0ag zFH?AABlT9?~bay(n z8|86ty+ZSrmEC-L7nQH*rH$J)Kb*I_}ktGq;UVU+v zpy;N_yOs0z1m{J_m@{{=MykJeyp`+jBU=XTmVG!WvL#ZHEq6M5%9b;tDqE)Xlr7=N zOTH;&I*RNiTYm8LkS%eD`|`fU*^~DZ|4rVv%Df+ah4-tN;tTx;KUv@FApVa8&2Iyr zf2i}70nt}vz-E6%2BfPpK=iqvBhToS0TV#ScY~hq0$q=X3{ZWcrYka_FPXU5bG5C` zZ`8AJUy&cRQHq{V@jIxGRoOxEB6Vq0HOUT=8-~{uxsf(6qWW`;m#1a<5q*{X&_I5K zpf5h?Lw+PWAp;IX^({Xt%$*e)uJc;*Bk_v-Fu9!Ix8CxD`t!7WZ*aKvGnFPIK$G&F zEx_Ge&Pu9yr5%-fT*9m$so&pKd5Y@lTesfwXFTN3vDXxN^IKKc-bkMJ)I2WaS@_4F zmrtcamS*l$(#6Xg`l^p~VHt zT>MNw)|3#yvd&-*?=Q=)NGo)P;7fvNEkOE}dS(Aso|n>jC-7v_Ys&nXc>l7{&ss<4 zMrYjFNO3dQ-x*JSW=Yo*SV%W`%}8j>U{Sn^daV_S&-|&%|CfY+)C#)KWC3w0M~NKlTCXb zTV&h3JFE$UD0z4>k=E&`-H;}uW~O{zDA+K`s>SpTSUlp`JNpUYofbD%6<~+awAw*{;j?JB=x#;rMz6W zhcer^cA7Vtn^`-}nM9E_F;?m5`lIS|7-f0Op*lSNSD}tzQyEwJ>_R$Px2G|i;L7J) z53qI?&bqWoihnw7ZJLGFvP6Sc5{IkbhTF(*PCdrCB-sZNXd|&?z?6wdR~W~F?b-oy z+1fhZLg~rIzZF5=dr`k7jAkLsmf9)y2mV2<>nh)VVYVi!uRhjBXVjc44J^CxCR>vo zJX%D1Dk#$#1NqQ}u{Iw3u4mcX7d+Z-2q|YC7t1||4eG_Zq&}Xeo*>{_Teq#3wap^&$BKkcaY4T0BrZV)KNN4nKqWe2U=Vstl z7-4HdA3aMpN_zC=D$Jps3kur{0fYLic<}ohETA#McU2tAIud8M+kpe+r~b49-_y|9 z3Q4LrsXJF!=!N=^oX+Pn=UrZqSf1)B>YR@#0>Ev6X;(j zF;0^IlfAL%r?Gf>2+~xhUpB<+3$8pw9OCsO+LMoI-R&TBEz;rTJCqV^SC+4Wx1*%P`B$hk$tb7gS(b0KpW*P2jXo z)!>_mw)d|8MX75-BJu!UvXu*$B)}m$DF6%+c|_2PK~1;+C+Lqzdxq{K7s4~-Gw(=8 zupT!l{x#{l_Z!iNaxOkx$~RrTUptZ0>Q*9M0qBL~PaQ8g3xDD*bOwFwBE?4VDd;)& zxWlHWc*UIbXMJiqI*wBPPq}N{?yKA(0)#&)uWBX1Ci>sp!D zi+y-~EM(hz$oDk(w;uV3cSyDwG$cc{vJ5SP45e}!C_Q9bJ<_j6`uF7Y23fWl{jLFj zQ6KOj+vvWQKH>ke5BQ)zBYzF}WBPy(+BR_4fIpxQ`0|~Y+6?M*qFW2nT9DR)bOdwd zi~_Wy*6xPxSg6XjP3Si*=PTP1U4PrD$hOBpFS305eX520uFEox3kQ7AzoyE>O&a0i zF;-56jzPRz7NPvlH!8kH=v$!X<8Vs0xMjFOhqNvK<}1!MFHz?UZG2-@Tu2`qu^^$H{Ee=@xOmMgoY9qOFuIP&XLk+t&6pd}nO!EvX2u~j zFH_Fy(m9(N&LHpK+YOS-!bvR|oN^A@X=wg9Lp`S};|JVieS!20$l-#`oaILBb+dQ$ zZCgyfP`rLnc%IG~aPmF}^d{>_^b_>bj{cgYejzTx#> z>`?X@6nw&Ed{6FB)}g)l23)~6T$#V_Q0oJnSE}t_!*eCt*uxuIm#(BS<2|omQ*Ybf z$X>%=U!^%}cb$CgCw$xA>&uY(f$HfISkr6XdZ_(C|6f(>ap``W#rONR9m*Q1?>F+n zxOsUt??07OKOxnJ|yIcWDnEi+b^f&I&|Az$n{89>QVdZ&-cwH;3`IuZp>M! z9^jW!(iv!PhVUZVCEUzhs53lit%=t5p4g$>tv8Pk&wxBCIRqT?77J4-ook2eA1HaA zj)fd!-F5p+2(>=CL$NdRJ>zjB{Zaht^>~4)yuexIea8gie^vi6gvmOKuiis>67q#ygZgd37~nm-VEW0ko_zk-#ITaXB(ou(3yd_U z&^mcqhoQ5jR$J4$c$P)_z~P@fGA<&=Wl{OGMjh6x+?#eNyoYkLvOUTfm>c1}gzNM# z@f)?RvcAjJ-$>igJU+}{X`B3BICh~do9b7I+Gkq?8%1|$4N}{P?lkqb(a|otJ5p&Q z(hYmJ5sd-VK9&Xf?Owgf0vTSnoVDkp?I!eUGdkOu-3y<8ysGRc`r<=R?<9Xm-m@dWLw#%kEV*!VnuY3-gL)j2^-l03##Iqxs)TQKXb;l2o|;kE z{$6`gPqeqH8Jk+hQk`RXC?H<0!}Nfl=V^8)GD712aluuX>2|tKe=GiFKOQ5 zL|JyU51k_6>f(TWAH7R40*N&{09pg3Ow#jxt-SpQ1zVx;IRp?M@Q|gnE6XIP&hqKX_ zbAiW2L9mieVCF^V!4M|b$qD$D1#{c2aV;AHfzuMsJ0^tIKl!TS1DE7I^pg{WNM#u`4-eds5p|LIO(tw>`*q&pFFta#|4n3IGIgy?U4Dju6;omWwpm0 z-QeVcq{$s+#|pz)Nc2sAhHfA3L>;oPzy{sWTy!pAXK)7TtRQ6s-_)`}7ysUd6h2zM zi%J)NWJA!vznMA$SW^%mZ4KsGNShFxk>o$wPUoPj)%u?9Q2dH${i%<&PI1|@rhL%J z09Ce9`#pwuJe}nLzEr+EH@UgSR0jP~hxy&tvaPbqRKt>zz!|gpWE!J)GsSjLE6){K({BqCMzsfx zx?Pm|g_t?Thp}=l#B3$KV6?oyR_>s)Q(|EM5Oal+rqd@4oeSwenWP(wp#wt92fEYz zsHPzwbsY$F20@IB2{E4%*?-)qM@+X7KS!x+r}JqIz}K%d_w#h=jL?GinC>7+xYyP2VAThxJcgb6DBa;lANk92`7 zglIj1(y27;P1ot0PGYie2GX%6_|UO8otNHc2CxvKQ?l8gpwe-iO7FgD3GPgR`c9$w zoF9dfR3qI*Y_|4J*PG_wK^xl)H$Wc}jrk_4DcrW^j^HO=n=kdGjopAlXP$lzPKB0e z)wX7O;uEi37NY7$_u`$++Z9`}pM#SM-XUB}=})}YFQ6xkYQU(y0ltaTsJPs(;PQ`N zyi*1^xi`Q&J{~d*ZdKUVKxuU70id92~N-7XfF2i|`TU@z4Z)jQHmX%;WxD z{>gcT>?F-rq)lGQu?v>B(mQlvwo!3@Q6ss;RJ%PW%9dhW6fi}kHKhS% zYldj_u3?Fs)c9KIRs^qt}&r?cn(=*g~NY=mC1CA6k`>v%w^qcldGLiMD( z<_OM&IMJT!Q_m`-rTbpLqkS!?SCD47i}nhr zZcgC;KI#Tt*scR!66$6_-7NTSLH*)Uw{56fD(FSapaTP!M8KH@TxhRg70R=qJUyp( z>Bd`SJj4ag`7Xe=1FnS|ho@wp2>9{nCJ|qwEIMmFj3mT>~|@$bCw6q&~C&*CR46 z2KzbSL%2{KF&D6F7Zk{Q3iOY@rvb-JKT@A4IGWkGl9M+pdkN+sTZuS#y`yzSjcF|1 zUv69CIJ>^aMCTqzhXV~*W1+XndjwAgPoZ;lzIO_3d*KDg#M{<7s$OU+HQQ_@mfNJ=k(R-Iolhz=*EFJe9E0FJ^Rp3le?HkEHpX?|7XZ@N3z=*O~-EUMr5bU|_Ze$MEw=<;NLr}_yQtj>+7 z-P8PNU6WbQ(B3~_=sBsy?(tlx(Xuu=Q$zb~t2@fp*v20mL+hEvgN++s0d7Z7c5}r` zqt7Ez8$98op%q)=s2|D{YMU-5nl&P2Ya)6)-Qyyb@S3@rf$g4oU5s@EF+-H7E3h zThR-yT#mk1IYGV3Asne3vr^7B-*V7qV@YrF>sb!@9rrCKPUu+<`A_jJhioA*u5|yW za;Xk@U#XV+u1~pRyOchgKz-Js=)_)GklP2Xh1udp9m{mFzL8s;gToSDpgSV^A zEKK$(MNg7Bq4-8L$*VM3i&{;C*6LgRL!CD{ZT&W}?9bo6|J;#p&!G(6 zBwI(z)0e;f+HewagEceU;5rg#OUXoD=rqYEs=1Qc!X957~U=m$nqD*D%1i7vIhsY#rwT(?h-j$j1}#D~4>GO#dLC zgJ!YnV8{VF$C%{Ls?S4i9D&>*p9)kSBmb`XkF~QWrHxE%UTbE0H}B7~cl_kpP5*?+ zNiN0$U9)3JAF-v(hAb^(%vlRrNb9Bz(AS7Z_j982RJ-!60Bur-_OPHW4x|3!BJIY* zXd^A!=P=r)740Ev-(5@XOEy6V{pI$3Rty)bqrB})KC{q%)W-I3t;>!&=AfO*zxQOP zY1r-r$XD_4plXsa2mRD|QvYh&y*VcXM9b?N#?|C6wx1@1WT*OVO^90^*9!0(z zz(1VAliha`vMf3qYZ=sRBApR@?koWBG0=gmpFGLx%t5TF_n7W$r*ytfxi^~Lzs9?4 zs}ypzZBmc3u|bNh4z1~%InGJ`Y#s-F(*6%U_b}xQsrP&}%^?-vc;WS8+=r%!MT^&x zjpIp_*Npah9BIjxN&bd3AJ@tIE?3T9(jDqty<(&Fs}S!!-r@Dlb;{YIGUU~$-`@0o z^QS$Co@Z{+4)ASO0IltW%6E~S^)Gio&&`NmWLxV^e+23CZ?apw zd2}TMrX)OG6!3B@T%KH<#n3bDY6y z;0;m`XOy;cdTG@|t3sdQbkY#+7TrBupbp=xe?I%X<6P8RrFtI!IfJx1YgOowajBM_ zT%dFhH(Ym`3(^fj-XdnO&hGcLV>L5KLgm)d@m#RfzzI^rnAM?MxFD1@Q0K=BI{df( z*FRryY#rHH+JLkToS(GSvO08VSem7h36eiQNcRZy)9nEKFPXpftBVzm^PIo5V#l9K zcd{VqJQpCHf9RReFOlytc9U)e^Vgkcf!4d<{;lJy3)@Nq`9L|%@JZ>G^I6Y?3cMhF zh5XO3n{^+u0Npcup!HV=s~lnedrBW;lr~J7!~03+$2=4IJ@WkpV(&By)8U_WVC@>m zp>6w1pOMoE(rL>xp@T+`vpmkiq|g)`tg>(BmM9B zk-CrgNZt2Tq ztMvDBx-jW)3Cly1qwcaa@ln!6K1F8|qJd|GG?o0R4_e~*8y_y2N*hZ54mf*wleFjI z=R)`L(Nef@yY3DlLPzPIxb9zf(7m=3IyzY1|bcEmCmP>$hhcHt232o;5UGpgBW0NPZ?ANGa_CQ`$5Hliz8}gQr8{2yeCToD`3d07XG+=q z@}GC~6B|n}A>AdU`xy1T^x*nX!t+NyS~mmu$YtAIj!#;)mJ*(X-(~biREC0MKh$|I z+FmaA;%^6hjPc3n5oKSmB{F*owzYkt? z+!^-fHSzm_Q}FAzpvJNA^1f^0_qy7CpX__Vap&l**T&D=cbCf@KR$fz_Ip8XzaMv2 zINsdwr)%POTy4KMf49VO|0~P-c{|K-9t5-Vie|c9az2Eu4OO89g*;(qvbEXh&C%o2uH$W|LW zKG(vh-=ixX3;+J7(hhcQeEL1T-tpt#UoWM8yB0p5eDsWCVd%@H_pxi^bHAt3acA~h zrK7Hc&znKZ(T8u8Mqj6W-mG2f=vQ4^`oneF=gxo)j*WM`S-PITHa>5TUh7!+$-YwJ zKVN*(P|qH1RP&7E{_nSxzQFawX&zwr!0Y{|92=i&EcL}{59;crZ!aE@iPlTs);Ol& z|Aq;bjysn9sg%l0VG7O$$!V5yPgZ(ga3Q^cg znim}FPVFkCvXl5QiELMrW>$wLp^V%3!B*ggazdrA*to2(^uw~g8n+_!D;6w$$AAap zrHA-n`)zNy9BX&(ETuA!aG_EVA0Uw(%#oo_haSP0_%@ehO+ucnEWrN7;b$D0iw(fX zPa-@Ne2mgKcC)nsx=95y+WkI#(NX#Ke&ECbCqJojR+VE=?(R}AUUB>|>k;IMW4t{G zX@ZbuQ1TLo-+{W)pu3(84dR0(4(*io!7~mrS)X*S}<;Q^W?bq`gQ}yKLjh6+%I=i%c6k1V3v#(p(aF z>ksO_Kl!8Nm2*e({Z~K!Ubc1FO?F_p!^kI?qE~QCV5{dZDgyMF~;v`Sy@ zNXQZUnRoe>tfDoT=P-vFz)B8lShhG=$TH*oOoHsY^aIOLi*g^`g8cd3dn2ZgW66if zSL;Alk~T^?XLbf<9^qJ+cD%m~YXV@A{w@Yv<@^$@(|Yr#vE+Bv{A5Eh@zUaBI!^1_ z&+FW?vRHN<58a&;-P$Y#b}L|K2)Y(gt8?kb+Zvrd=d?U1Q!c04a#cA|W68F`Tz^>J z-_CEWqy0pU;s=8ECoUkbd5>R7?I4zIZt^QhjbqvK$0&ISr}xNap&-98W05#z(xRX# z^*l>?#h;~Y*RhoM{FQ%VFsmk9ucR>|O$gFNu#`O}R!w(09Y;Ad2jI}R^Y1|2Z?e@< z-4Dhq^`pAkFD^c2;pDSvSL#T6Iaa*a`?ID!@yfd9A;6kGsvE|8!&2+w2P=2C+#~2) zRsoKa)4S+=y#;~J!|MT~0r0j1-VnTR2fRu2)Ul>V>1hP~G*(UaN3Rh45ej^Q{r>G~ zbw;F_8Nk|JMgC!Ue--&N>52Ts^hExr>4~}CtMrTjyjj2(@YVy~N4J7rthPF8*U4zt zmRnQn{(MJT-CyoXuZu++)&j1Lo`9Q6PrxmwC*ao76L6d93AiWeX<|*Wg-Y9-dC^@P zgYuTJ>P=|tg@petSN9?HUGF|ayQb{XDfiYCA>VI@_RRMRFS;mwA=jO^P=j}!`mV*h zu_w$z151emycvKOm3VzTsC+N1dR;dxqM`Rf)OXO*dl9|xR%t7*i_UJLeQ*AsEr;O0 zXHwryc#r8-ehkV7J#HfVDJp-U(!WHHOH8z$ugHV1$hIe5{TIqgsNW9`VA+$3zw_A>D-A{8y-Pibbf<+mrK8u}S4C*(8jcGCy4ur1~7xH|`&v?B{{g zJ;13p#N;w^2G=bJ`+1{#GM?YuEbqU$^YDI7#w*Z$kTdAc$=|i^^YXVKw~|ihZjSj0V5l@K;5~&2aNnP7p!Z*)4(%b5uTG!mj<4zE=D~95%z@WU6b)l zN~JvKH4sNn5pS5_F8}Jo&CG zlRNK2PxgbTAGOyj;E5rr%Gu;?{yJAV+GZ7&gxm^qXWo@_$tL$nEz2hP zPv?@Ys@&HNX7g!mn?QX@{Q%k<3b>gF+jJ88L8`9>>}OMb`e9{R!yv$?atPNvJjYXf zIn!4zV|v1aa4ticHh-2qNswBc7%S|YZ2Rv@MA`QstU~z`#}*hgYyr_5jX5__|NUn1 zns3lnRQ3hIP`}M+aJ8YHCk1Ui^E)s)&i}yZ&ol={3n()vgtax}eUOTSKf)IO8a>hH zekvb2N2XwCX+Zg_Rk z`jQZ3t>q(IlylsX^$%|$-AVBk82zJX{c%3hwT_Rec(q>a(^e>!H{3>DSodk!6K$LD`xX&=Ktke@(uFAU7_e z-AQ(ow|lbb4s4G~=J`9vv-xD(OKItB{7J}h17~bGVl=w`0(nHb!gSfRZ;}o5}oLAQJJ5>6h zI}#t~m9=nRyW#&%IVbxl=fuBV&Z3^>Tv&8fIq&pQ&fEWia<=zT&MW_cc3aa&In_T? z4xQ^hKzmqEHe)^qom836)qCwU7Hwf+u}`v%3-LS~7~R=AkSp@~ZR%}*NO+LV&@1G# zqB*DWjKEp-wwy+?qdLeba|ZJ1@q_ADppT62!}kwEvY z;FURk?A~_}IK+7b0YQThfDnWbj1Yzpj76rvm%OL(=?Cy z#~ZboGiOB>|FNQZ^e3}Ii=RY1dByI`e=Kh-j=3-H-se{A$$S#els9WL|F!&2#bwLi zEPitNzT)94c4q!-#p{_1VqVManiW#)!T%Rm{4w)ic+PnK_nB=gc4c}1<3+^ZUhy*W zzEPaAVsGa36^)s1a3@CpfHXAry}shj%oQvCkof@ME?oXranybB_a0yVM&_ky@%R3; zerx7NgyVoSa>eV#9|6_^gyX>R5(2+sbLPMmTQZxM*JhqtzA2OI@MLdYzB`j-;!VqU zW`0rma^|ec&6%;4wV6vBcV!+x*ou&bzz_=9O);Yo)+0vklBxzSgPU8+Pr;+ZvI>E7$_c?t=^VFwj9^@pamT4th_L7-VEJ}*KL)Y)1 zM#@O!rHnupDA`zWqEjPDEW3QBi<1(+)&^xbr@Cg=0LH4Rt7b;BNa@#Hg!CL2PG>`0%hGSQj?`FS|Z*a*6^srwHvtO0g}gOv%!<9kES0Yh zTli+J(TeZjW20%=8^%2$`-hn;|1_*_{U3~ukT-|*1Dtsw`-XiwEiUkf<$H~h_r(Q% zw4%{?=enK7oag>v9Me>599_9@*zsxcfsq7nMUxRc9#|Rl*02}wJ`LquTE59Re?{Z4 z%P0eK#t8m4g1?PXcz-$QjbZNuH4RG(+B+;M{*gBphspZ{U`_nCB;>)hqvX5O^? z%gn8npM%#w%RJrK_MqmCwg=zAvl1ZlmWVGHWArPNi{tZ4C zIp&i2Twtz$m(MTp%ypcXTzh!E%W^fJyNS=Wx2@lBhsSeaEEnNUq5EHxp5HK*3wK+R zo|EscQ2E^5daKWOLF-oe+_@r()T)^^MwWC&7;P07nWy9-J+(2}_S0Dv`3{HFZ4NCf zUz5t+9FxGam>T>Q=?)-Q;v4!-cIi#Utw}vLjJO-yqImMAN_9?`qy(!-R&#_ z9_d4Q=obc<$$FMd^za#|aK2uFNqzM_;1OKPSHUYV*QzjOe>_dyZ9E+`;ak7kRs5+Q zR36n|wFPOSGAAMZ%Eh*|y5a07rOiP7-uKBvZ9wOEE+8M-X0;)F%Jp|0>B>3Q$VIup zV{Hw1KNDJI+K=~hc>g|ho&0Y2Dd*Uap&RA*p?JR>xw{N&pI`WQc-1BI z>Vxgq;MF$p>c>@{oMZHUCbGsveEUDiuls8M=lS&ywg1EXn)`p5U*mf4Yuz>ZmCI@G zlV2+yY`-?YKG^<$lV3;I{%84hQ0;$~U-kb3{F(z@I6p{UXMeU;;H~x&Yc=^clh@h{ z3PyR?+DUgNKZWrZ7auz%i1m-Z#KK6ob{Xl68Tgs*=pbKc_%CrfR~aX`50lR_rgKTb z>?rx7PBSPv_atR6uEojJ{j<6<3$2BczGa6VS2&(E(wfpQnNo&Rx=tB-wTw8m4E?ps zFhFOdGDP_KuQ*S8I(KUjtVI?e_u?q6ursqBTw% ztNt4OOzVE-8e0Rc_boYaV)W7j&7*1kk5z6ij;q{K>|Fz-JqgnH8J<`X80I2Cp*I&Hp%W4v_DW zzHkP-X?a1h!$~a2ALX@=k}CqJ^F8KmDV(~r46$;&(alz+PNoOb#@%U}6_m-oM^ z=l?XV@_&~1e>BUM@J~teU^{)|3`&|xHBCm8l7{G3RMS)hE3{cpXT=_0yhew1bjA`r zXzOju${Osb??br3$QxtzvT(3!0oUB*4KzZ3;G+7G1jPuc}#YtBJd6K>zAxaA?l z_urIxfNLHj0MBONN&e+lX<1vEjYHdfR%~Yvzm$P8X8~WkMkmo4Ryy~{OY!(Odq5QE z`E(}Xx_KGv+@#`}D=K&?8jP z)+;#cWSlwRn~QKrTh`zQdsD=Gvl(;?p+GJXQSOy`)|&irQ)Be@>(wRUrEMYS^v}Nb*?`{hGtS*bA)eBJIT(; z+=GoYPiadr^h@M}q`6x1O%y2C<5|?BLY1Awv)j-nR(Z~c@wDN_#?v=8D!kOP2%g8u|d2OTdFVG@FEylr0|0>Ne<#XT4+#>SM7XZups zGPKYmX#7h=m}FDODs>E$?TS8%%bc|uItreIzwOi5;=E7eipx7`!K35I_le+VtwGz*(kQql0@v+KD_NnxW*`u* zZ2F`7R%uvpYOglV1-{hR`OGgv%V{#ka(>RI%2%2Qewxgaf^`Tdw$z}F=`IcOoo^LI zR|3A(F-hKIC75h_!9Fic+w{bJPLxGsDXnu*o-%?*XN#vNHd5QjHa;45bU)K2F1hT< zws++ovtRb?@$ColA>i$Y2_2qnq5&(~Gef3djg{KdIAMs3`af;8st?7n;AZHTL=Qv@ zUOMp7wO3a;^@At-5Zcl$B-G!-X~-8f%9Ikn8A-N)QsOy0Uu_Q<=>%TRcF&%B@Sccg zVye|O4E1-S{u!d=I>AY9@)MQ_{d;+y1|5u#aUiTi_)L5x#t$J5-)AG-rh6nN z9AO#)FWSsE;oqPjoB1I;Lo;pWsncxccrn%daFoqFCn(iCIKpP0l9_6rC8nEiGNqaa zg`}G`5vk^QU6$G3lx~g;$udumNHwRMEc4U{C!3dNrkm%**t*JR zWtr!gSXa8vVSXrrbyduqY+f?WVSYCAZu7$-tSi^Zx|SNV&3QW3h51_7+!)q18*L~} zrg1dfs>A#~2mP?Qzv7d2B*(&z%YKR@WLrhPZP770^IW}wtvicv^UPCtQQ1*eH-*;&QHuN zw7m(`+~n+bPV;ZHuHh*S%oE?-g@R0CUw&N2d(PfL99PJ z%5hq0EMtlB_|7_WkDcuD?5Rat5C8SzHK&04XKKBd+T&emL9BYvy0Qiqgn4RT^V40omp_re&n?@O>guUZ6>sP4D*`<@ zHOd|f)nQg)QKwRmp`D!KpO@N5-ZwQTu{N56?nha!(JVXH-&RC4H_Mn>q)TE)=R~9x zl~Sh>TWQ|`PW+*XFLqvRLKE#oIN_~J-D3#d#xm5VZ+ zRBkF{JM=`tpWqYjE*1z5#uohy0|r{A^iH=ON5z8)yzI)*7iD z)6ss1G!>&2+ibnNxzm$fq}8`0ocv;b1cu&Xsh66dBbMT&r_rKyv`7w8?Cs>rH8sD-J<{{XS<`D#< z*9*tzW@0>YzjOTI%$JWplsV_aIholX{wnjf59eiud{~B@iEjd75-Dr<$Shz ziEf&CsqsGZ)N_l6M&RElgc<)_G<3OXrkVO~;UNV^Wym!1^M@2TDR?f6m~LJXbHDl7 zw5jH`nbXYGcim@RW1nF@h5A4ib>(4vorQ6BHpbjJ7<*|vei&o(BN%h%#dTP(L@Uvn@mItsok{Dj(#Ve{0E*yCmZnmjY=PT@eEgO^ron^60Xuo zGM@i!TB%TJ(wyr;cZj#ZToAB+|=-(eznJ@+44&wPQ zm6qO3eX#Su#j>J@k!BgfiF1pKh`vZ}d594d>-9*+1foQE@Hd3-5VAj+yZCm5$WIhH!x-F6XC(@q z&B#k1dVfLM&|j9M4*dn_Eb5R#6GU4?FE1kGaJILI&VuirVQxY^Bx9yI{J#6mALIQ; z1SULS4oaVHe&F%4pZbr7z`cSuex(B*|dk&P59u>zqJlz)w*z7 zhkT|qR=Izf3tAj=3u|6eztr4rCEcCbx-OLE{xOl+nyMG29vkb=N(L>jF_DkL*g%$D zjj@uSU(%Uo%ZnMpv)we8qPf)z4B zUuJH7|Eo-zFE9CUc4iHyaiVz zFpYa)!Mx7&pmg);O4cdA>&KY~7TnfJFyCkF_RleQt-P@4*ua80owN06_&2C?8T8Ek zG@s>_IuD#Nr*nLNy=%5@R?KW$T+Ei~gZ5SWvEUlc&mB+g|1~YM1Oz!+4 zonH&V^E{GY@j_Gqgbxlh0ONxzkU%6JQExE32+^ zE#0S}k!?3Ve2#oA^GDD2V_mHS{ffMIjdwogUw8SDfV%b^5j3gA+)YMUb(~zws>PM#I_RD~x}Oqx zvPQ(cYq~6qu;#31>(1P4YpN8)j2dQg&f!_ppBeR$$@&D|NgpNqoB4RwuyAJ~{*k@U zJjDG1+DBdtB!8Q>js^ZKxd3I8Jq_7enJ}latRB~qaKNwnOd#u^x*m99!9FqWzf3$A z9}_Re$E2Yi+Jsp#qq#XRl?nZwI`YXEXzPG}`Yh?7aoercp9%e~r-dl_4(l2r(pk?b zb(H5D9?u2(A2eE7`RYZ9VLnD39uP8Fl-Izq}rkVUgv;uo$Xe0@{zE9)t^( zYl%X?pzfktUs{(XxKtOpPQB{mizD9$j)WiKMEDWDHsDr{{-rjI7cFwz>Cko$sBQNO zba{gD9(aLplFL@w=mTmWDwpu2cA_>qsn(P1-Oft34x-K0NX1Wbp6n;t4%(dv8lC`J zzPqmTG3Ac-)`8=S&i1o)f$n7<2z~D7?`Z!#kh7c$U`=`c%6*}i1iro<^xPr)*cPq8 zcPHUSZAW*tlHK)pbmm8r`z2Vs+t&C6a4fJMHen3QpgWVs3R>s3ajdZ!dN9HK0(74L zlP9?oeM5P_$MXqRzJ}(xuVqH`dl79pGn5@gTe*RK_76|&YYcb3A=|6&c0_EfIhgEj zh8!T8ss-Q2ebf(a7LC5@Z~a_|uqu5OR=@svw(e`PefydF#ec3%HlvMTi$o;HQkn3kqziq&a>@&zeJI!N9lAmoM9I`>Gr!$kZ zU+3OptD|)|`8>1`1-+|Z{WhKGDt@c1p!z^r0p^28SH-cTPRzTsHyJltt$`bddOUk* zf5MHlWr9h*OSoPs>nQE7WC&8rr19mAhPP|v^SgC}^&2bHbwDb68(-d(V)=9fqNEA2z+7n)}Bit=4L?-z;0i zN~(fb_Opxy9W8K<3ep9;7;ip)?~~PduCQ`@|AdCL z4S{@M%LPvFGV=!aY4U|kd#m8X9gsh9-zay;`1--@o9_6YbXSmg19UgN{R-VxOmFW& zcifEj9&}gX6PM|3W_u61tMG{v-EsG~_n^B9pSVnS54887y9%E;(H-~8_8xRs;S-nX z?!opRbXVaMC%WTu+I!Glg-=|jJLPWqZo2b{%XH@xm+8(YF4LV)T&6ppxJ-9GahdLX z;xgU&#AUkkiOY276PM}ECoa>SPh6%upSVnSK5?1ueB!CLj%(6g+_mZMGkKkxx8~3} zTBht9k>B-j^*kNft9$p_XwA$`YlV1v_t>1Y-_}^0IUB)NS(}+xxha#@3h8{Pyl33F zD^tgAiXlJBiC6ZGZH;udq-E;LnezSeoK7M;pfiHbss+8XxWgO%w{au9EWnR^KG3~I zbiRymE9?WeXV$VX@>@&zwff*knD*Rn)o2N0Im38}+S=lVtobrdsKqE?OgQ6|^lA@NTgj!uYCfnGJmZU4832=oh4O zT~FVlGu0=dW0Af^dY3rP)~HRC^)1P(Z{^?Bt#6(5(YM-r>06Dn`qsA|FT7gcqOo1p zx6TCgsc+r6?tfq3YP_z#7520GR@MKKz7;Eot`lpnt8YpF9eqolGkl+PmA*At5M0-S zar6f0lKS9)PU(Y-=z|A-vv|#f?-m_v75cYOUtE&=M(KwiY1~8~L_NN64b8(XXunqU z!zJ*kCxn&IypUI4jd<+8u`XJ*ds+0AYU(dYZ>@B7ET_UzenW_2Gwp^&)T#(jy&Wzc>2(AdbM*02X>v7i0&A$)eoe7`60y%^t5 z;8Ty!musV*yK-I3bNkmuJ{P?v`nd|_fnOfRXW@vUx4wXn_0e|d{-ImfpC#RIzqQMF ziK`A6vt7sTRZ*5z!#Xtp>kawcdaSEB#{c9P|BNuMLYwh!)y+1JG2xqr|F%EIx)+@8 z2fpu*;P_Vd5#M>h_uT3|VxRJqf{~4&MTuE7(!lN;Db|-R9GUYJUk|fIX_4z;Xx(bz zf|2J|x9~kA(!{AMZ}?XITx<@vkQCTHG~OS=>3=u=w0+ zqx0Ne=5UXuvj%;#Xa(I$%!4C4N=ja}psbE*9mk{iIE;|Nw4ntfY2CRBY^g5VXCfW6 zoQ9nK1pXotZI4FXZah!tiPYb+XryAiZ{#R^hDNa2Q;o5a%#@YPQi?{Zvx-K3H2vw} z9}i#-T9-^RU|Y4~EN?H(iJD5l!@`l>z?qqfN3z02Bfo;ps|U{R(N?z}+jFb4(FdQ7 z_GK;pYGUgm{W&S{EltT`YzLj2V3qewXKnA9=sd=FHixNVANCYF%OIwCzM0vEsDs&@ z$S~WSLOSa?b>Hx0mR_8q$nzLR8$Z(T6~h$UoR=J|hW2SZ1X>p;Y|BD?XkI5XA-ZJjS|9iIb{nuSo$NWEdw&h=H{OG^7pl_&q?{{1M-@6s>?a;k9 zOTL%0mBnfA)Aw8Q`*-sDW%<8rt0+Iz8MKs_Ks=!{TZu23DeTxJTXik@nRd`~NSuG` zsu#SPv~jE0m!5dl&Btx5erGMyIU9lJz3TaRgXH6~5PZBA|2J&$=jGY}o~U?ck&Y)3 zk|z&u_2=ay0q>nbx%cYcbH6LzJLwR-S|WLN2=U1xd0i~O7wP^h^48|!`nC3uZ6Myw-YWJVja;wsEjV4Y-wx46bP-MM_)WA-)6p`#7g|>L zftD*IEgd$042cHLmoO`@KNUX5bSZO^y_jnA$4S#!?JSD4FmV=e9KIELFO0eAy|H+2 z{V3MPU^~)q4SQ31KkSWdA#3Y?lIVzaY3E4zmmLDPh703%+~MQZpPvlPtE>9JtHTb# zt6$jqfLlXP-2SYi<>g*ziS7d}&61W?!EkE`j@$n7{^9m^xevGBmHXqiu~*y#O^nuD zGu3eq^gh^qh578hMhmr*8EkV#Zjn9%ZT)&gfDQT;Y|!=_-NW#V#uw5D;>#p2Uw-F2 zBYnwt4;Y;KGlS{uA1a&98Z)2EqP6NvZ&#{ood%`L7$wf-%~T9s*G#CWrT_LD=nRc8 zSFXE!E8)wexWlzs&IYUV6qpYekz+Oadt&Xu>Td$6CDM(uhH>m+@t4< z4A4m>-d@ev6tygQZjpgarT#}DWV*a0Era-RCX!+k-#NeEJwWrXfAGo=Jrn8hVR&|l zkc&VJdAwz?Z8a&%{$ya4Z9<%j`oA+qxT?)0&jZkZ4R_HV(Zu7Ezz=bkD=zqQW z1gklV`gd|(UIJbY1s~#FBcKzn;hqh=KLj3=KOTyE+YO2KsYX*5oi$a3|5SXK5^GOY z5;<=N;hBT;*)g%-^-$MqD8I-M58hi}wJ5{v#H%U;b5|c{&bksy2Juhh-Pt8s0sM<| zC80d>fh6bZcB_-xZ-Shwknx;&TP?|UI%IpSVutJ*`C7=__0_eiVz%FBWKH?_Jr;fT zbY`@dFq3^8YytWiZJQM1{#(#D{o1Iy%9Vj#46^l&aiDAVvHXk=&*f(%8wQuqydvfB z)hRJNW<93F*z1cP%7_`j+9rIOpOI-`B`Nd_KTBEVXK%izMA>P)BS$gq|EbCP9aHN5 z5y}S;?&VgtgU-07eJSYN`SPD2E~0%bS0&G_N?;ieD{SXLk~=GNBmhIQt+#@wtp@9> zPgO9^ODg9zj$a(`i|1H1;+eYQi5bD{FFp6}6T$6&nzSc53Y$WHD1|joqxTEV%w1rx zP1DY`(aO5!L#vbUt^&=ipt%q<(_H1j>1@X+@U(S0d;_OD-j(tM$&dBb6`-5WU7~v4 z86eKLCfgWD>zXIT8F{rKXg$U4rO`!s!6zjDR^!>v`27UQi#XV=*dAqDwgiu-{x{p- zzWL(Xf4yw~{1ELIw%_O?y*MFl1L=kdx^V||gKUZE>55tt*n{+|89qwObve!ZOurBC*Kfg| z2p;Ka3G|V7fS!Z!k@pQi-vP2i=cds)>Ga=w27VTFI{LaU3;d)7{L8}g7M8G|_UDK1 z&TEB_;rZ0C*@q!(pTU2K+>tofFG2rG*ljvjxk&oO4EV)&42fMf$cbW1w5PB*`y@Qa za1A+4CpjJWB-u*qR4O}|-$|B!M{9p~K4J8GKYz32$qUB}Jnq!_8)F3Rn`@nV5`Kp0 zJq2Gwm~_L>5bfk^rhxvQz6Nym;%kOWUo)Khn&GY#$p0DCy^GtbB{0Zrj=8Hr5;p)vkKLYzYWrjHK z-KEB}48Aul?STTBRuL~M3Y!XDncAD(&WGJzsI%Moce7*9>+JSi*lijYDV*-J+wGfr z+HJkfwr=j-X8&Zff1J#9OefPbAfvRdM5T3KCLwoe&^d;fP9HP>kJp~`O;_$jxgLrq zAt!nrp?sZ_X#c4mdXfk|F>*cW@Y9n-=t+WCPrg9A_k)%s+FuU7TCXdg8WQ+YfmC(!tNaG4fkx(`a@p)HegfGs|(8c+P6VoZqKOy7%(=yIC z8xv%lZ{%@4*&EfUkMlKqHk9L344#w$n`m8}KMxos8{>E!I7%7H_rpyCRwA|@YCpGG z#MUV~`*vb8#nwY*Y@HAkBdR|WIO+G%c5^%bt{+a5bv{wQ|F4-@-TTmX;uqQVFXXu@ z#0TO5(Vhw(WHJM9JDo{vUaQQd`xOR-xBoN9XEu0CZU3Cs8l$cloh_e?wu}A??GYZq zWv>No+fzI#gX}0`fhkoK3&?gYlkGamZIplf5K0gESun6M?~rZ2%<3dOmlNO6jy(pp z)O8*{740DzC{(JrFWXnKzLs(~Gv~2mxv)9Z-aFUZp}VlFuvH$|&_ihRa@Ih;uEZl>dnF9H}g2Fb04=& z1{cXdF#MySKO_TBLys*ien08WblKna*(&Pm#(Qm51MsDN*y9X7JEdWN4QNMPV*k)C z?9cWP_UB-+W`BCx_h7QD#iEt~8*c%~0C#I%z^>ZINs* z$*ZdPd1@!gJ<0t2n=dT$Vf~bOx_{H=p5x@f{Fw#qp>x6sqlvI> z6A>?JzKwh)`8OJyGNJEB`vEMmi?N)*@pQ{(pU(@7ReR%ec9@UPcS`i-SFD;kb-#2w{5^cM~ z9)foP@%CKs#)LL>@;pzLSAcjs^%6GZQ}F2+%A&Z3e4IF|H5vL6yPxls4u2HLzhJti z*(i#G5C^1F%$Oexi62@nTYlI4IVYU=MElPx5jM zE^VLpZq%dAB~V$1`Ty}QZC`gP^Nmd*?8vXI+~*QMBf(FSvm)4xy^ueWMbYO}T$9ni zN`RilEB<||XVHe8=u=reo+i?VcxKxgs&5snEo2jg&sWkrpj+|K)jjhWKcoF4=yMA` zlxt9R2k3;pE41zyy4|7(TRsZ5{V?eJ3?E^`l3gn|k^dF(FUhJ&WrHbJCH$+P=T6w} zY8tbJd{n`n5uaia3lq&05BJq?rJ^rYm~M5x&tninI@v_xRV3)7IEuz!D3)3e`+E&| zs_$3*P16a3)~`zN(w_tR$=1-Ao@oRdLeKhav5*~#9VsUK#4esCyVRkxOONn4a7wRn zAjN;AOB(+A@unFPfAYO4z@GZ4YEeN33uA4WN@~e^xkm!&)rDd@6|qVycr=Vr-%IEZ z@h@Has!Sv527i(f)4yZj>j<}QG??~VWZX!7S86{!NBuJk`e&W!pHY9TgJNel_08hC zOzUfU^v#B%Z$`Q8+uA7w`pcZrgluU0_U?vaz>u4*%FU4|r5V-uJ>je@f$gVQj}C8iO&g zDTM~bZdsW;mt>awZ=x=4y&dh;#=}P`2|RAC$MXtHu-_(SrrMCO-$dg-WugrOB%hLt zQZp8yEhK+M_})`&3F*)2{w90y_E=$Ci$T&sl`H4{`m} z68N2D_?-vf!#6=T;^2R(ARqa7j&$r6u1i(5JHeAQ#508Xh2ojTc3(V0ZOhliGYK-D z`FVB!;~Ba?D@6N+omRO0e6pI4iA{A5@bA~qJU-Sy6H-`S#CP}BqkIvv@YsA05Z~S4#AN~+R z&%c#&LE|Z^kUckzGZb?F!MtM(R4$D(7?{#keSui^3oi3U*N%<;@$5C;c-A*geA~tg z+py67yQeSI#}MR0rto-dBOjNg_3H-O>xi%QFoIUv2j5`tHkj-tBZCgdc5PH>4sEEb z)sVRVAnnJCxQy_j7$=zD;_(R0B`dM}4dHAD?KeR7c?H_le9qIP#X@A$qjArA&NHbQ zK3}UZ7teo&zE_}rl)x7j64v-cg$e#e^RbZ1S{HoySZTvbluflI!2dqTW0Z;f3R_x@ zHkYHl4?}Nqm9VZ#$Y{P2-jz3DLoMk?#nn&MK2PHWkP$@*vwu;|^I|5K%Q-~K?dATS z*@m#mRXy~GQ6ALY;~Wj$nqK?IQ6y8zkQ20%k8zUirFcp;`o{OQvHkIs<63WZ^06Ms z6ZNYm0j~(y-$KY9+0{;ixBu)8><`^mCHg}&o;B&`R%ZqJ#AyZ@3qy|hT;l-BZxPSm zSVjIO&Q5-c`q$O)?e{^JDV7@*F6IR&MmK^Mx~73wSIp&W986 zc!JjlUxwc`sP_@@hvJKO4Doz!;1Kwc62=_k@!xxpl@sU_7Sgy&ae4;vi+Gg_UKLAT z6@ypBWy!kQo_Km z9{CuejvUl6BS?RqaLM)JQh|EIx(w*QkDlwbg}@A`gQ0 zZp(=jc@Q(WjQMgP602nnggHnKgxO>-MEy*Svn#5oBcfX7Kt%d-APm)g$bnd$wyUo> z5br_PuY1e0?OJ@M;*;@~e-6!b)c<*ve_qd(-e*GRO;FAR#RVOnFOI9QMTE0aWqH-0 z+YT%?0u#!ip!|t$W4McS6~>G1JPCi+Z0@+WBjnRxV@POjU!oz6T! zzp2Wz?!b1!?!4zHJ8E(Km{ajJbC$2R?7C1+ z#UJqg-`@0W`xu{(-W2(WWZO(J%wc|6`akHB1^h9=zJ<)OP|fyAnPV{%K7a9vbqAgR zkJ4GR{Uya@FB#0*W~H*W50vQr-|#$(K|IgmYvUl-?3VmpM?c8lrO&h2Bl9fUlo-3M z=%HOPLs;8{iu_%Hc@{>VXAwjDinZx;EllU8KXsvei;geWyjspoU4A(iF}j?Kn{_!C z;70;*Zw4;MHe5L8;(Fjzw`$_9Q1f?%;CtDdzBnaV-T~R$z_^6gQcwQS>fAF#{x}4k!E!DPRej63`0VlC zb1r_Uwm)`(w*Tev&~5kN-Ix$<*XxKj{-@WKc&RIClygCQfkHl$2csX$<9L&c@-7;R zSM1Wp2zP;w)*&MAqWS#vrwnpDwy*gYcW(%te-TOJSD=+>)@Y0ejcK5f`X8NI4u;ua zr!h^Um)eu48uqssth+TDpL|^AVt~J=hxl?aNCy$S?Pt)rA2J`KQs!eU)a7GrWZy3z zW8vc>AHyvBf5p;I{{&@tb21_m?J1@>dp4eBk-j<0J0HJ5PKNY@z0E0`QO4@udA6!UmWMzOtrJ})b2$R_jOTM8ZY5pg`5Y#Z&++Wmy4w5k z?oRVCyVO0A&+%B_bnimr%IPTQIq)w*+o8&5!BxJc9Qhc%BFF zyHK9Tn<#%Aa2vZj`1lUxdsMR@Hs9mFHi+>Hy$ll$f#W^9@Z6qNlXhL~{CD>*tu4Xl z&&WQJ{a$DixgUgcpK?D){veCFuvhxrkAiTP=Yd_O`4K*LHN=>l`M7j=)@!mEg}xK6e!Fnt*qRhV{&s zQ$pqTHD82y`AntHH=KbyXxH7iegQnKuMD0ma{kfKxgyo#|0fyz?I7XR zY^MI7ke{nITws1>l@?p{^d}GO^7kVYLkG)WxK9!J3uC1pDI{HhO;BLhMop;nj<<|K zEZP2O-*YmubvYSxQDR^vn93yD>!MZafWpvz}di?#6^igXC`9 z93pqaEOR#y2Vcy*K$gtg*u*YAZ=+{y=*!zMJSyhZ4Ki;-ZxgA{(r!-h_P5Sd_8#+j zq)iN^gQ;(b7?1QzjS=?CmlGvqp^DatLlzLPwV7mJ9XK_m7ZvU@@i8TZ=W$Tqwr4I! zk1+xFMj3zTb2!F=XC>abN;~3@xGq``(tG?N=5}7M6#UZqdz$ai@(}1gG&t;jklkvxR@M=1Xo=}-*O!K(m&r+ z5qwQ>JGT*vs|o*q>K#MyUw_>~v8bBVgKJuSCjpv*0xHCFA2C%Vldw}9dX zn(uc7t2h0;*k?A7H;Z7iY0SM? z<{CtYiM*s-ZoBQZxymM8uEA!WYfve34WeVj`gtdw=~KP|uu{}8+9&v;@(p;OwN4~si4=?L=J@q<`&D#qwqaAPHpidN zhVG|vvD+cXeVyaqyIzbv>*x5D$as4S>fMh2_91SITRnMK@@k9MXIpeJ5BWJQ1|Yrc zFbKOAOgBGm>f6|&Hpl+EI=J?@r`!mOXpPb{L>4#BRmqGQP=dT1t)7SgP zF~562V5A=xBmZ!U?~7$}C9h@9WR((1&i|WJ+WiZ*UzN$}#)YnCMza`$3dU$siwGe1+9XWf2ya$3M++ zryMkWP7IBU`o;s|x+)A}Jb+?f-`JXOJYdLrvBr(|vKYp5;D@>XdmXExJ%H1U)>j{q z{i;XKcy`h`XuYj5nNoh?oJ{?g+IJ6R^%xJmxm?6qq?d(^T~mO%_N^0pPHbBuu<%sU z-aTQ!LUGW%MBazjeN_C!6OeBM;!Nt-GgnoD)lGZ(92wuZl=ji<9L=U4d5Z1wOkq>o zM>9uOwAl9|xp`t2tvl)%ZJkQ>(SFd4sE^*)_84Daoei z3;*u<9{kIa{OexlKO^`T3;rd7f3P79QG(76qvRWW)}R2s9b&?Mi~XZ$uQwmxE+LwYqW`14?^!G8 z{NM+qQ_~YYou9AmJDtbZ_N4P8N#_e*It^ZV)b9g#r=+(7bscH^&C*TLd^|Lrd^ORU z1G>pi61|bI=U>icD?30x`AE+o=3X9etG&!7)(H$nT$s$*?nC!c9Kq*s(ns5PT)+G8 z2DUr7nw3&cb;lq&KR3RMh11v^bIpZ4Ci|yv|BKWnUHel9h_Mm>z2I)-d-DaAbMW=? zC?V%J=;Zvl^1jLWWW3`o&pL*e_pmH4U02@YT6sG{l-GpvcHQVsCAmYJ$W9xj{Ok2+ zgkFD)KK+^apwOQM5@SF8F-rXbzvfB2FTY5wpY6%R^)C zD)+~n8GV?iNX#Dw=F}eu#QpLg0C!d5)V$V*(+JR%rm&-W+*9!zeaEAE+}~KsaeqaR zJK}p@-pji3RIR)nA_xOv%yT5w=+jXHa-?gs)dOrRKzt@%bTn# zZ|mB=VLlG;JheJB=4l}?zd>TY=mMDkRAPR0A26pgHi@?z1Mn^+S%4f+%*k~gcypZx z)*X-mlN>iA%qf5IwY8yf|NYwj>-&Ts0Q*>p)qAUbSY6&n>|0j%#NMNd@?O`K7u!ed z8}UwDFW5U*2giO-iNOA=RlQ^X`6_|^Yb8GHJ4Ul}=gt8C(Td~rm6TH_@V`Ua`c|^_ zAH2MjV{c6EHU-*w^f&ZAeHnCp%386XXQCh8ajsj|^7)J1Z}h-Aj$>`Iri=`x<8Od& zqQhwvIA>8lC+)ADl`@iWK6mCu=jki0ZuI@m^@jB`5}P0Euz7h+->|+p2-c6v@~+dB z_sp8UVSP2;d3IH3tgi@x^^Fqir5C`uSYn;=zksz~zvH0aZ>|ZB_eX2|@lMgi3?qP*@jS6xQ`7fz6#dY?@a04eMKjV7*C} zm#r(Wes$llCi!hB3yt+PA+Vk$v0ilntd~lxt^W&H2kQ6U)xq)peD#I&yC9@~Pyc~1 zA1yKZN0|?!DSgDet*j^JgJgMs(3LkjIOZRsybtx5zm0b;?FIAQWx+B3bCJOO+{)fD zKe1or5^-NBj)L(-R?o*?u^0 z*i-{PrQf8@;=m+H@=T} zll)%M3*LKI2FLr8LV3?M}Z@aSPfyuI82AEc9;EPigPSPM*Z z6sKV)_481Cd>Zfm;gLS@E>_38+q}GcUB|oq=)Vfy-JjbWy;N8C*ap5>$kz$Wz@CH=xbNh_h{D$y!%!^+qI$}?JB#7 zc742}|Lyt$?>=^ccHJ)JkaD@{oTG`o_>RIK(05$AQqDu8?iJ8sS}$dFRBxR^8eO%L=h};y-k2BZa)ja{d*gy!=$L)}WL27^5{u_@?~ena9>{{R+=+ zS0e43fJwDE(%v|jIleYVy6QKSk8ez6jz8gD_AAqssray8#o#;UO529a#!c&)gU+wd zS0Z^Xu{qwF!E+36V!IkV9@u)Suv_zt!F)Xl;_-P7;0Bn zi@nTfPV}(Hlh{FZ7G-46yWJ7YeGvCLaeV~kXzLA%m5sIeim8jv##R*bevR(2po`A? zp&Wmd=SWjbc6Ft%d>TWbHf0v2Wr*`Fo2OBIl>blb0QfnUgXB4u9Fv>DSK1%rMt;_5 zN38W6c<;sK^uw%1(Tz81W3-<Hyn!nC$x>QIvWKb_yNxJj&SY-!b zl`XM~l~|$d_6T4V>yMRpOfv{pdYn@AIAu$mP?k~R)OKIr^om8<<~X@trHmxZ$mV^z z6#PH$d4;co0X}TTJJSf)Lxc_Gr=p#8bLiYm=2!rJQ`<==bEJ-$T1Jf|9XWwA{$^PB zYR&X<4h;j^TSc#z*9GV$VNDp5eiG&n_Z904>806=^=?1CEX&x{AH9qY)JxTi^=?1C zEKA$fDF97EVvB(ahbrA zuqQk&g#Vk%ddL6UW&fV|Z(jC)7ymIK@OS*WC;kSB|A!0vW~&K%#^>=62f;pnTB^f7 zR$?Ea(|-;7Y#sKv&RHhxHphOD#C~ZI?4`ZDepydDPuOd=epz3!zmT2RW3RXKgnd8k z{J>=ac3zLY-p&*D-_y?jx>Vwx?T>q`hP%%8KUykqpR=&%z9{tUq@{dL>J@E{i;o)y z*^5Ku?Fo5tk7jOt?%q#$>}*UoB~t&x!1oRypHAyWJ&<#YNjraMeeL`3MYN`Em^S}6 zTFcE#NLNI^W4Bw*0a|IkH$vp*6@;<8JjR-+KSK5BbMXB0@3Nil(R6;gZ?3Neu?FG^ zIfozXx|)f(xz18K=Z^nwe%PN6a&GRGQTXWR`r7f1w>&n#?JwNI4C+DFLa{b`~D%)&z zA2hI~*BGCvO^n}G+bY){R+z+?Gp#)|g^9kRw)XIK@7hDLUvG9Ptv$RZgZAL2y|^#D z2X}ne^9Hd8w{N{+&po(LF73JIu=ye1nnUdj($~*;cG7uUz3tUKy!66*bzf*7FH339 zHHR-hB+vg|pwDj&ttaWUpGP8{Piai=O;>2IE~OV;x%OdyT`AD%iVb)zlk?^M)RzzS z`tmc6XB)})2%UVd5_V%tfWB-Q6|e>=PUy>rdVQHA%Tsmb{Y)!wM~L#IzA(J=z(b++ z2sFTTf9I_;frR`(6>%6xXt%!hxy zG+Bu(nbDvo!S8J`9lFv%`|HkZVT04*XU59)#gX%hSLTY}Wn;T4=0tt^=$x)=ez)|& zCoATpGqZi{+LxZQrZPv#xy1(-PczIddNC^L<+THo#^ZX^+QD-has34RY$LAijRTXS z<}F(3d~sk>$u#TM=y{7*TJRlNRR+3 zbKo~JHz}oQw8zAytTZQr)@*-H z1&vx;r_*`UYr^N|w_R1*5zg|)%67xYO`T4@_Ik7hZ9IbKGk-p))U;Mjszy6<@vaGN zQ8%bbM0*uvaR%s)#QTNveKm%y)M)AY^VT_$u|z{;3E7!+@TCOh=B|xQvQv2|YZl&} z`C?>JF`avjGNVui@gx%EB$FSMG^_I#tjrN@Ez`=6oL9v0LA_;hG(I)2a3!sY=QQD- z8s>Yp>xC_I(oOFTFPY}zIZT9I$+`OvlmN3R;7ohuM9wQ&iTcYT@m?gbUIbdrz}d1P zGRZoaImQzGYon6r{Swelc+)$?3&L+EaLft!VP_Wd=yGv+Ge+g~a=p2~zGUPKCznzkQuCn5lQL5x) z3Uintmv@m~sQYTmH!Gz@4eE0mJ{@RV`50CT-OgJCOvi$svuf~HnUx6=!z=tiU=ZUq}ts7r7Cs|meegE3%=SX*NS=;y=>1-+JYQ*=V9fOkU zaqV&rO1g60z$6E*v+!YS1}06w?{7fs6}V<=2G5r7Kfl^GK$n$`-A>QX!fbue@q7D;qfP!IcwK(*5F< zV<9)=z~Axltn7+mtSoH=D+|7Fn%LK%nf7*n-{3g(qW_)>+WidJvS>2jE7Tv$$ zFDCph>ezmKU-iD@MW*)SnW;Hl?TK~6edX)+=DFVBh~>UH)-~!5Vb86Kwx@V$#zwq{kAe?t z)W3V%5@Gl6xFhslG}^vx6z@D#D%Os?VKB6n;=0~2y!&B%@2X&J^%cXr`I%z3PIX2j z=4&-ucbgg8Li_gSHeBwY|4iX*u(M)lx2s}gcdME0?zoryPoirSlV>*7v`lKF8# zh0-Xvn@TmZOke3xfC$r^u2g945kAr+%MzIiN zr3m#FLRMNKD}|7i56(e@h-pYlB5#6xUZj;Hn zTMcKk3oG)wRTJAyeL;HOq_CZZ6|_GPbCcY(BX-rd>mLuY8b#M0jlL+{i^cn~;dCau z*yE36n&jCOb#u2~rq{#A6<%xIofg(T1-O+KUdwi8(Y@hTH^m=?6rm~6uh$Whe!8dh+)m;qUy$8M(gKzn%GrDMxA*Nd01}64$?76Q>&;7D$AEJB~ zs~df|oh1(n{>4KVmO~d#LKg}L-rW5(^daHDS8hFMi06IaDqy2RK01shJ~v$j{*a7N zz3}@Sj~KN55rfrTf$|F>i=F>kxt01CskBcWWDtG!()8P_=c`f%Gtqa~WUvS_Xp%Ar z{?9g{Y+m;nPagHJ=^f{Ho}Kgz-Ou{YlV^jD9EL70d`RdC*&sa+36m7qgMZ!QZ^HsH zT*z&re|GnO9 z5kg1Ep1zg`>?4FdB|Av+l>mI!f2y>t07j>P5xc}T?LUB#-qzL+eC+zTFy@YSqJ2Y1PsyIB(w>N1h1jkl$ON}1!xr43Rd*^?=$N4Jx3)D}x_BHUAKi49jLp(?AcYJxyv(4&>xQ6^n0`8SO z@7Y%TynighSmwuwM_ysKjc{yZ0sGeJ>i*Uv_s`eW6HKpUd0W_36CIT(Zx--v=KGx` zILevGX?f{Exz7z~xjBFy*qx)PzrWG_o4Z5s;@!Zy-*%qW?VaYyj%h2Aydis61K#lZ zI}DC|)c+LtLUq3g-V`XbuXL=d4%cY`aX~UO2^$E%Lt}fiKL_X2rpXTFS*v>%BV98% zYRp!*YO~Fu{F6^X!wa#jZTleRw&A@_BXdszE!+3z?}Cg@=6f}wtfvOh*;?%A^Y_u7 zYLMGj^ts9B4yOB=AHxS+Q$L9NfGuPL<4|9kD*mIrY&jh-k8^1I5KZNtWaIk~! z5sjk*@Z5*@=sAlT?@*s*?#@O2{}b=IZBQZ|{0wKsR-5|PjqaIuhn8LMJEa4B?=;`& zz9Gmvp2`7j=T^r%&t-=@&OVTUI4}|RU?_Bcn3M7wP7bhbO@KY^7;SLGA3bd8cmyTbjP)X@wE=$K>n->FvImxT)+OeYn}C@m9||6r#hd{HngQ0 zt&LPpL*;diU*iAz%DcM1evr%YD-=_+S+QdN`awRnvgLXFpBk0yu)+q@^Hl!v2Yu&` zg*to66Jc%h>>b#4C!d=&@?7ifl_R_9tdtY{%udGlwS=8(`YYMcDAt7j^^g&YM3MPkm|UkA<}W=I_5Sn zFgsEd+cZ@Pcd$&`v|%k#jw5pwx4A)ebpATgnQUUmI%AnzRa8fN<>S}4S5{r$`A7Ut zHRE^n^#zq1uIKM(MLG&9AH(m*ukS2Z-0C6vU4`G({C|qg3o19_w;i8N_-w}K34ETs zzQAa1EHFmm6NOJSJ_GR?gij1UgBuI*eq@Uo_R%)YoEqh@8g0`GD&re%^!;bswDvC| zmUO;0piG5N=)P}&^Shaf`@0`2?yg7hfev=9R@_IPQ`~2t!e^V}?tD#gdmc9K_Pn9E z-FsEH#{+wjj5uaAJ4a_D(B7fRYn9S0x*mY*BBiu}(fKEQ{34F{UO=%=6gxJ00L!Ci z4~4V5eP-*_Ogx)~YZ_x|HJI!jV^J=!n5~HZj1}?lnTay)G}YZ9I_qWs;gbbT1h z`)cnD_x5nw-zl>#^WDrHG>*g=`pB;_XE$)IPhm}+uUVZGyM9G6HE37YFsE5zuc$4Q z>tnL#uD{mNwU=G(3H?Stq`uk*88e~ka?b_WA+Ta&=nz^i*;A2J1lxbvL9iXEC@$v!MivZ7AJ)WKI zPi4{mXinm9GP|S;J~D4V{r^aoo7M`FU8Q?}pnEg3@OwD_{SN)!gIFQf_srXLZzBC3 z%75=(q^hMb22+-jAMB-6j4S)EUv?GT@BpnTE+Da$iom^N8|c!rF1=A561Oo)JfOz zxNbz9gK<4fl(T@YN8tKJrBtEoR9x>y`7yX2gX>RFK3!WxnTH19db}uSJzY=0_0uSy zt|!wvPE!}*Yz4k!L2EhH0p87+${ci`_(p5H((zwzC!3vbVG|7a{UvaxbJ05Sok}ti zhxUHs;WFOxwI>gKZLS*&=z5^IR_J<&)RV6~d2|-gZ}@y|vP+a_a?$>v>EX;lb9P5i zKb=uGE8d#133br86KTIz*3^*%J;!^UNx-5oEraF>=w20HFPofx0`JcK%(HV8_;`Zv({CGc;IL=s zNc^UA7+1JGI}>sJT`{ZKP%PppYQK@m_5XJTi>1-WcxHxs**j5=da29*Q-T<&c80s1 z34N|Ju&%;d)lKKb>^?-TM#p#|`1$d#!X2F;n65jNc0AVq4I6ESyyVYTN~w>MKLRp4fb_!DWxGWDZjZ+|{#Wq3T!md9j(4Ef zxzWn({PnDK`!LwA^PZz6%0$N|#$pzi6|dZ+M%!&l^2c=)ld57(2hD?xG0^%y`0rt& z|DOtd_z?D=_VQ)J#5pV^7xl1PscD_gl^I>BT zU5e{?@%(zahE2|gJx`@;*yMcJ^On)LP8IJipljIVe6oFX4V#<~yWKDf*RaX?&!T*~ zhE2{NgYr|!pT@dM2BN}Qrk$eKVynX$N-HG zkPPrWRdJ8_MDMHzA5Xyski2F?o>lTgGnCSL*vZU!poiAttJ+zGJXUlULlzip-g4+X zjXBVsw_3bmlJUlW!=6|#g+0PM@rWS_9m7lReex+cL^eZi_bT9dwPFDIt6l5`6nO%NAE1f~tkVEoe?a}z3{6D^P!bQGD z3EHy+^?iu?^fp1OGcsJ*1?KkANc#}|KHzz-|B86F{pAkV7uS?ej^voYZ3 zZ-P8~o{4AMPxzkQiE>{E^6arsE0gJTniV|lh{oi+mIQyzSLU9WHj&+&vU*SOJ{!;{WA2yBF zms36h>YcnjmajFR#Px;zJnq;3kEe6`3VEInTiSYAi<8cn8wz_w{~Y#6+mqm037V)b(r2oJblC(O zoeUdY2tKEcZmmsLh96Lw+icb+s9Y*<|Rf_h1Zj9&q zT)&FC&Y$-rzEj9*ZYdOFyfkl0b7Y-HB^|!mP5a@_fX$$9nzN;|j%fb_`c1Jj#f?fB zADeLJD+-S>EOcfEV$Mv&yJC(vnaeNP5jsoU5+-6f=oH07d1E0*C3wDE#;B=~Bff6| z;?z**xFkF?L*`#8u2!9lr70qphve+hBG_1?kh28H8O5sgQqG7EA3@gkLAK_j?Ol+$ zYRFs(WUdM^s0DHdIk&$wQ0aD>#dz&&>56mvkllKe1qtPcJ&4ZlA)@A_EEW1#YVN@*wTI-R53 z3Eo8&6=l#pvY93E_ThE#NkvNa@Ghg8PHRh?cqX!_B%^#ho{6s>-f4{F_vx%eet(!! znvxEmJ{Ue!p*1dQx`p>4RDR~U3FVunDYKJb6#7qN3~8yYwfO#vrJ$MS<&UJMu{@%U zczRV9t9e-Q`6aReq!Zi@Wck~HOK8uIhlALG^*%drBn5Uri4XLFTTBhG12dT8O+(-6 zQb-ma>StZ+!yM{ldaiGE{SRcJeNjK_T0GLBrZUHdKGwA#b=ivgRo5$~1*)UCkM-<8 zJ=UM~vrX@gcc{_Kaht3wAF&jjH()|6WqKO2mE|W}!!4|8j)m1Pg=`f!B#Ah!BvRPo zlpNMnIBM3^sw6f^vy&-FGUh=X*fLx@-yu1Dm|4blHzT$qI|v)-=wo}AqrJ`d_Ord` z#yH~oSkGOA-(CHcmFpbpFy{EKkNRdsT`525r>rbj9cn!K7JaO14Dm1T;_Ko*enzxI z9l;#Gmvxc9ZO)>;B4S?jwa9l+3}U-p#FFW9Y^7qFh! zbpxxJ$a>aWChJYP+E?#NS+9lp+G)GizmHlX-)lCCe(nQ2W=rlSzsCEl*&=Q`ax<%m z4^r=4^8JDVbNQnLCfbGJ`(s21k9ypMG)MqSO*`dQb` zWbRL*`dHT-)Kze8KkIsNEVsSKSl{ZJg1Xq0e&RI^dOLwRKJH^(m!PitiT!NXu?WYT zeXPeo{JW~3_1rYXq29zC4SlTZi=TmiamVUNt%;gSsDsz;` zy3BHXmF#K?d}yw2%=(34Ebj>FK;O|pG|f^FYvQ?b*sI0I@-vcCj34Drj(RS)g|*cu zvb@EJ=jpk|7B+j21^s*URp@NRT|w;A#B7;q$UDt9$-(`8@t_f-l&m zw}EZ)y^nP7^~S$7#J}WT`S)Q6{(WfrWFogsNqxj>0qU||(yzLXn)XFG)WN)f|DL^45^&-cw@+_2pVrm?k+gtUw(s0$lA3i zyThou(BE=d^L|vai}*?OcU-PG`JP0weP4|e?~ax9Z36F#6f+;MI3dSx=sCLz zeau>gV2~+F&`^5chUZxGigq+NGWAz z^sjGGN@;Gbe5%6pT>1Fs44T&zW4DC8HYQK~c|P}u_D~)^{YTF<;GW&9H*qePD(2t# z7}AeelP1g7ANl%2hi+zhJaz+L4;ffZoq-+W??==PZ-YKG-^7|o-)U});=`%|Z0a)D zimD)X;;Q>$!*Ye4h=ZLtA??Hw_!+VlRj?JgEfX9sU22;%7IuQpH>UaVgxkg3IL(vC zqF2xA0XDvq})#CD8T>gr?485A-YceX)>?p z+be|(o>c_T8^VSCNV(p}>*nixyk3B7J%7sv$nitOZhHO_7N3q5ZFw?6VEBPT^Rdb4 zu}pmP@jruts~qxfbHfsP7!m_ z`7@N#0`NS4n(sY&XIVIFqV*$bcsCB$M8_DKXT`l7#bnpyj*qjelrO-V9)YZo{B#77 zmtR50nlpsFBtl+J>-@?oEk1#)nAT6^ex(!g(mGJ-e$UKye`IFvQ{eSX=&zSPI`bK{ zY#ha=()=6kosdjp31Mkqe+OvCSW!T*upbHWB$ zKDpFE@=daxj@X*yTD2(74$#wWWbW_q-v2?LxqFmt&T{D9{JjZ|pO2m5$iO>fQwt-Q zgVsh9O-{sbtLIyt<;DbVPk62hW7GXrf6k$@=dHX4Zo|=SKU}rm|;=P_7vGy&5T6fk8({T@P4#X+78=8JfpeW zMaa()ht25$6s@|9*r{7a(gi z9#w5%{M`nojVr~umQtTO3AmA5MFbiDyAOSURMEyHwDF8yrl3>Xq>t3wiU-4q&&jU# zp{$A4WA4WH3Y1Se(;7r>KR_MrBmBp0)FMu(CpNy zj+xMzB8oFX8`;&gy=?Y=*w(+nw!XKJO>G^?b`!7e1cqs-Cs|j|WRfp4Yw8TBCk^!! z8C82B#lGOtlzHUaRaXXhrSV9}N`h;2gdBf0XnnhQ7wlTi?|ICe;Nlpc^z3|z=I@}h zr=(5%808c_KS$)1kljmVZH2XDTdn*odHN=v>eu?xJUq>x>DT*y_rQ(LvrDw|7yak5 zNtSKYmn8pu&eK$Y7{WFgeHYZP_s_qcEM)6*-TXG?nh>3n5QAv_9NQU>w~vyFYpu_! z`OEudT`A{0JKIp!=L=ZPL)tp(Y??Dtoaa#Q=~&w9Oss3aL&l!8*Ej8v{S1B2Hjn62{h9hy<}BwM$JZXv z_>rn4+NsVPFBkKqCuGjA3HK?Eq50Baam><%wC6$aIHq0pkJBlR$(rnqW2k>j{q?v! z#rZPP0<2FI_|CQtTr*_)m6hjOQy+u3%Se65Sl5&dS>5qU;(m`o%sDrFSAKxj*ezHn z`RPx!4uqc=2wnb9 z=A=2NjRxq|oztCkhV^NFUU)X;y}I>hR!>>rTVJC;M?Lf|swaqCoG*2U6MRTphOFF-t)WYpiZ>h+t=?1-O%MD&y-ICT*D3gWx$0#Y!fmTIvd<8nKu(6FT84k0N z)-&1WP^{7g-o1}c8mnJg58A5@*4@#ddog?it%?U_7r0fKKpVjUJ1;tC^Cq%;k7Xx;-oO3pY+xJQ1s1^^rZ%;Z*xECd*u7m_ln?OvY>BraQg1=Cw)Kuq3FvRD(IUL zoW5xVat-gL!p>R;%KXj-(XTSg904|IUzJw9LcDk8rfAqeR;}b z=|um)!X_M!#BaRcMd!n#3|ezYaZrab(iNr4S2lS(c~Rl4?Izl*g6jSR@u=CqZkyVB z-8Ped-9}T-y2-AFs+;=JUn0Iq<2h+jUCrcg$ybGoa(SLMu8VavwTB6sI&?G@(%3l@ z{dV{>?!S4?3uwAej*IGPqP&=3G`$61qNm5I2zsau8a;e%ENDlZ@1Q(qm9HN)r4z2S zc2S>Gb1|~_O2H2#d;1JR_KJeb-Y@fmui;GK^ojNBtpR>U%lUa6G&UQ8X~lPRyq3|b@QSi7FCmHGYC*@fSqZ$~-b8e;_C8iMofwtmug z?f0io*yLzwQ*H`QUs^xu8}dWZw_v=W@AlyIv3z0k5I?4C>mWm2hs;Y$$#QlYAfySws5AB5r$T8B^jwNPJ*;F}z1kiV{#d4Mw|d)8d76D`^KWFEn@yt4G{$B^yDcY2IEcn{)#^@Bnx~mnvemX} z^vu&{!jn0iye;uA;zycn|90JalI`+;Du>pU(El8d!F{9caY-9&wxDgy<~dZ>zQz}q zs)cF0=w1!=X;5CZRKrBv1`uH4(#mPt^vw>L5msq@ikKnn-=<6?P{RiqRYW+lQ zeu72F`H5TLvj>R%uxY-5+DATt4UlU9zEWn_6|o5m72i6hRQRyM|D5B@RmGeJ@1Nmb zoWj?-^7VOz8fM}ya{eFhrVR4EOJnEbFYs3dzXCt$P3jQDc+_1QVJq{@6z1KpbNaaT@~;C4(}q4^1e%J^g4C67BPl9 zJ&QS-fJ-O(KV!$(=2+H`<742i0b(w$dZ5xxV;%NUiseNEt;30E+J=b&i) z{aMep6VS)4a_t()*QPMr9Kx4$@vCs#RvIH;44n4>=R(Nj2Lo(dwY>9!qna=LYuo5K>o=af94!|5+LKrK8^|%_-XiDcN@$)o+TLZre{p3+ z-Ah0VVf;sY6JGDq{+N)%z4#^!T4;O<-+S;)ym=en%kP1V+@p=(7@?05&U1Se$Jr$& z^g~r=cfJ|&7YW^ra^^xebK-ruzdW8EF2_1(j&7LHO~f_n6bBxGJ*5~hOJUa(-kJqp zraB51njMGsGDqPY#ZBwko-x7~&>FDI6!%H+zXSgzz#b+Yf=x_salTPucI-ApI&u_s zzk3kMJD2?w*#?SdGm?DcLHaQlTAvkX5Otoq%jl%F&1XiF?kCciQzMPZ6wS$UKXR+$ zF1`cbprz~%#ocka(Rl>VT_VSjW}e~W<8C&TH4)8Zf8W79+IO@N?;B7a%|&Tr6Gq^_ z0XBfjrn-+LS=}_3ZUN1FFHt_1p6EJ)=V#D5CgMS|kh`l;F0JXKeG+M%9F5O*Co*?E z%1>sAU4@{P?-%&%Y$wq~bkqAqpu7DgqjOgn+eta^MSP!?L_X*C9dM;GX`F@Hsu%0)q{#bU}oeSGnYYEN4!TvEkNxd}G6Yb1R5_bBrc? zu9a+&7_WdHzhcR{g3k@8;J?|y_}+1s+^^7B&{WH}YY&*mv#C=Rng^u(hD%(FHdwni zS*%m*;Lm72GB=eSTYx@Ev?a)Ml9)xYm5wQSpK##v>(t)<#D#!XR2QO)?PaEf>{fqe=@v*fBQk4XI&Zxlg zz6ARUIo=m+Ze_(iw7xU=+)A^IH;ZY!Pq~W6oBIOB`?S4+j=-Oj-fLsj3;15QvtSoy z!an4}PRwwgU6KPI^&=;2h}TEmdpE1;yi2tCTfS~8p{sz;Kbc%}?-6THpPkkJKGF^M z^s$eGHqw0l#q^P`yu0U^wthU0@+oNCo@`Mh;*@fl+lSvRA{#VYtgWUt7t3|bypKQ7 zmuEq1_={8#!|?gNXfdy^tv4AREpl%bBOaTn*yg+ppK!z&?W#jri|~BoA(5|UnH>3? zrG>RojCjkj{EXkUu-UiJ+DQv5rL~jve6yM5&BT9veY9E34U+#Lx@bP|R;k1Eo5n9@ z%GjwToOqq=qI`wo%Vtd_Su~s3q)wIXB)QYCSJ7nd6Y#Bma1fb8ALp+^=C*9Sk><*W zI&!T-=BnUxH=5b*r`IXYx(YUx)~gmR&Ej!T3wZWOah99Lj%i)7#{j<=cunV-@BJ=$gqYcEzM(~5$TQWK7xf030uZ(8bZ{Umn1Rfrg z`F>h{jOK@F3}iXVdQ)3#o#>0%XiP`MY`#5ddEBO(XCQk(_S2-8_nYK;&Qai9F8nuS zwJCSJ)mei7_6)T1m_Dy;dG{Bnt9WCU`vk`b`gfMjRWWs?;hXT|J{5XH=Vkm}VKv_M zYJubb;8k;2@En0AiA`XB9ASfg=N7`pq+ZVR_o^;aobP}p8mGSpWu}A}r?16-D}c-P z^V6Slp3i>jy@4is>p<0Byi{@1IDOHB3awpq{}yG$j^$L?s2kL(BN z$Iz?;gnn6+q^mRAg#7B~aF!sRqdhgWHL=ZMzIC(o(rWN(=6E{$8T=X`^KMkwFS_P^Fmu;N>gOB%b8RaPBG=ZU zQh!p67mn3(Y{w_?99!?2xfZUgrF)@o8FH+W_JcPmL-$kp(+xDIk?iWGd1>RYF4|+f zR?Ewf_NUP8;rQ&n%Qrux#SS#aLNV|#quKr}^#M_ag1Tt$HL(YqneV}-*ISy;(9dJM zH2>n~I4!iM#~@|)c3&>afFZ1g)*V{SY$wH9x$lB!sjReB{y%Dz{2zAqHYW1S?lZ~w z7P7CH>q}2bl)FNr$dXLQJC3zgzN% zDPldzwI2WZUXm@^M?`-wOA%{0dz%NI0G!{N7h-%bCCeG7#PhvSwDBQTiR;sN*yVT0 zu^~l29v08f^}OvaZ9FWFkB0@*{mA)p9wSWbtEr7&mCXx2eid&LnDFsPId){am~kbw zZ{teG=F4`;aitkQdYq3dsfB4Py<Xl#Tmz@9KHJ{bRrH zJoOv%gU?g{Vt(j(>VM4t{`1tEL&@R==BXb+-7MHx^LnAbF?O^Ae%}JW zPq}HtYZ?cE|FaYStKj>eg3l-Z>+$*7Jm0=C+FpJpn^=QI;|?)$k9Wd`a?oHCrxqc; zOI8KuV$WD13$(vG^(AQ^#g4?f;pKRa_QV7RL-hN)(EUv_F0$UVAoYe{e7z4|Y`x=x z)T>^6y|)Fam*;Y$ZeTvSU?9!c(;n*RFXH*MXi*0*)5wDEFG~Mi8=^f_rq&L>atG_m z4N~4^7h7I@@8u1dA#~gN6FFY2Ta&IoM{&DuJ$kxg+E2MW&r4fReraiTqe<3{tNh3ydMQ0TlaxdN`8ZE7_Cj$$|v9VQg(92VZ`9{9>o!R@Ou$_c^&+6 z>*Xx3k=DPrObNRFoiO>m%4+^D^ZLDOH0LR^GuN}yobNn&h$D~Y&Neu@+@2n5G>IPg z<)f(`mpUrs8eMN0Pos<(tTe5gm+@ilp{b7jr-PTF#m3v=Ki4U~{KUORRl2*6TVLuVPo(5WXvBl`PGmzHJDB~8yKF4Jl^Nfy?ca_xjp%zmn|{?QHK9?aY+zEa>!a=ReJ?=H@_bZUJ96uu@Zpzj9vWAKL!rlbypx z@qT6$t9ku=0Ctms-LsINk0o|(*EwGLs2A)uUF#_D+K*&c{&;q5^j|%B<)HP@0gvdf z9Ac#l=vo!|r3zg~%UsiIfX`sjwwB4bju+Pp=z5sk>yWNTh_x>blW?6X_BYI;Yufkl zS=2QV*A}6F>v3%w@8i*XUwgK_CfC6HD~#2Yofo;Voc`umM~s2y1N$yV1*t-gQq6LG z%=79_xq)9p2wfv5vut-+D9HqHBHYs=fMgl_NrTkIUC2qvO-S=L$wT z&iSujdnL7=IsUGDPUFdjuZ7$+|H0G5%UcvdYj1iyxB6=#PqueGO~j)rxd%`J_!4Xm z#&(_H%|BGZo94Yf-jsqj-~NBhy$N_z$F(>*%HTO zf5kx3*pQGoJ8AlmF}xT|^0YM!FqZ`-4IB}(S-z5d&?JDEE;@z$x zLL5-RaTA{h@*B@wcf&f{ro;!{&k?h{`Tr+cF)M=kgO5_7VMVrwhM1ENX-M@GzD zdmsAX344JwY5NR(t&Q zrQBY-VEruf9`ns<;uoIS*n@QaG%&mo|4#2`$y&_espM7o?R}N;)O_vQ6{hlg1|O&W zXRc<%0rAYdJAyukGi$>b8IDp8Kk_1vMFzP{(pfy#%Ka=R*)hQ9cfV(T*8#sJT=red zw|~v!8}>39U%td>oUvEQuP1>&LR8*AeTl!n$J3VpeGOdlZEN#0{!R|V{sJBU!OZJo z-E$QOu)YLX(q3l!iOap0op|2m-pgw6GvCWH@iX7c+IG9#d)Z3-%=a<@KjU834g1}5 zF2!En z0QNSO_ts1EyPe_oki;MGeIu;vqD>~6^F@O5S~zKVOa;zeQvXjeIIB6F&L@>)`~cox z<2}aQND;>;#dQA$^ox~zF0PmGtEc!JF9JCu&DmRUM!D${i}6sC564nv^1(|soZ)&$ z&LHyTe^LjRFQLG@(+BDQr6JzG2;&^f$GJtr@Tu73;mH>O$8>mpSGoUtUPb;pSsa6O z&->PL&z+0no>veQ`R{g@Gh0?2-nZCNs4WX^Yz}VAI$$6zc>h9ep?i(uD`Nzjj~VS+ z&Mb|?k&5=zHDsAYVTrBJb6AmnyYsV#GoUkze zYbo~t&7%tQdsL>A=)Yolml_M%Js8E8D06#nd@E>IML+kuy?nwkw+hCmq5t@2-uhO1 zyqJ|h#AYey6P(3YPa*6q(E_p(KSwe;?43-?agRb@#>@kPhS@pAKhm zVUj!Fn)9bd9`ARJhtA|051sc;^rf>4Wbb`4*tHWlZdE{^2~5Y3+TPL)b1+^O%QjvX z%Qnsv%XYg`&pMv@(Nr;OJP{v|(V*DO`+1vG+u^)}`Dpj%d1zsS&qxPXJZV9gyS;U{ z_6RX6hKN7lznkE9vtT;5B8QxQ2yGB!ftLaPBA(Y0>uHv*PmL=b1!ve4GP!sRJdYgY zc@8{}9pw3Fcy1cxIp}AGltG?n!ShjrJO_Q*kU7Y6(4h@EgFFYlB{7eP7-x;P{MIy5 zeni7)yE2WPFCXDPiE8&0(ws$Qy56G@s=> zISDe8>Nnnc_TM5DIZx<2cWdeX^tpH2`;sxnMA`4?v0_#s5kK~ZF*=Fr$M1Q=xHOCE z+wTyMu00OU%p2yD)2MG}n=qEeYPp-Qb)@lHpz&Iu@dluAb^<()9OQXCJdYjZc^o`9 z4e~q|o~I1*JO-YR8svF2Depmlo*Uu$m9z%#P7|{-h#29$mgdEYS@Vb}ohfRh9783bFOKx+ix#w#z@CRb6Ny`h z_|lp+g~*Q*A0l6_dhXg8Z(gMS!QzSdR|rM&}iR(Nu7xpXhk)2T4KNM~oO#$Nc}y&Ol((N!+_XnCI( z_FnJt(Hh`)pmHBAw=Y`)KO;QHa=l2tZ0K`Aec9l*8{zrU$5P{vzfecVd`DOx2xw_W ze5S#AGtl#Ex-$>!4e8l_TN}&$;PC!tKBe_U(i*7>Me-(B^pG(^~P5T)t76a0vJe_B(iXurG0z*bj7Wl|Ki5vd6I8 zRgL)fW6*nzyuCS+wr`fVpDk3kMzj06&S-XjhkQ)?el;FzynEX2fHts~qWvFhHqkb3 z#8a4iWbG7pWIFeFkC(on6Z^tlpV4#0a7=ojjScd#@P;H8bF{riD; zuksQpp|2=~ zxig2hyN&xA()%-+@7n;qi}(1xDaOd}g)oP|n@6g{#lFN56y{5pUFtM@Y6i7M)A6nf z7iY51$NO$f77L}{dq#`Xq~HI`BuGP^IF^7+xe!n%1 zw$1eXcB(H`OMe?FZ?|=lnB@6>u&;o%iI@MDHcO0>fB(x=+QuMpj5x|m zR40h_oB{vT=h|gG=c2V-KP_eXsy>|pJS(ts=v(;wtgHET4e)17!|t7N_TA?|{@}XD zoH6d4K-^v;?d$77{#Z3CIe`R0%+OM~cJ88Jp-qA1-$P%i9q=9FT>Ak>l*_tsUqA6>*L!^TlL1eRd+FqU;)LVb zgMNT*7K*1{hNtN|!4ZEm;=y9$`d6R~_V`R$Wq5o0eMxvDUTZm?|0mD==7ndD3{Oc< zYL#j)uzluWDcxsg`sPeoEI3RtWI)2RkFF~Zo{85582g}76T)?!|C$hd|2v4bFP&|+ zcsKmT{m(JRUM9s7*8y)*pXA6m(J)Cv_c0@!bI~W+JD&KfWx?b5!F9|(Zb&?Zximhb zdyH?+6vPej0-6pGd(A94Zf`FeJWd1ae03>(a_K&~Ka|^Co@6x`>HRe9LnlI*4;||0 zri{gw^o$4##<0!&r)wAP(L1stENF||!hOgwM*`Icf6QV!&SUIg6s>hEI-_j;TV(gg z-nE-rbu3=s$OU#ck9Db;FRsa%`;BYY7O&b(QoSR0jHuC*(?~7W0Ac@z!?s>=HSd&S zHoS7#-HmgkQ_hi{=d$~N>Q{_^4nzt4JN?9c+y*dO!&G2+=K1(mTqp2du8-lJ1Fn^L zJfFc@X~)<^gU#`gy9Q2zQxKBtk&ZeOX{a+;=~}%&eT|drpT!)XQSeMf#uQw~ViV*t z#;%kx3Y_FJ2JuM*d|m{6#siPNwv;sB+}|_TzidgO>p|;8dp7z)cX|0{Uf^pYMz(ep zG6i&3C#;2f(4A4XRKnl!V=>Pf88`yCq8u5IGP_12O7cVx_(t%xVXPn)-5r>J$0K7d zFAaX}e!1*B%b4R-#yk}7kuj6iWz2g^1IuHpR=1D(`ly|u@t(f1mbbtf!T6BEXrVv< zCd4PjSzshBYW@?giHt7uO{`WXNhHng+?kvvm6utJ#&tjw`z6<*_n0nY4w20h#^Ffw zggH><_sLRCIJfUz80)h?f#<_cRL&d5+e!158ReO`w@>lA%mkLtPrB3k$7zh|W_gJ3 z^2sTT^Lz~E)}2?_W)$T)g-v=gaGuX|4e#?@0_(Fe znlyg|?|uyP{|UhPE6M2Pr1vOa5MGBx!yhO2C(BTlq zEC3(lo-_yg1O7f(1v0->h;-K*!+q%}j0@|I9l;#PO9cnZX<6(znx*0SEIfNu`I6xG zsM$*g-=o%!!91i^nmcq2-=mIT%sAkS`xNe{=vZZ>r4eb_7B#F`<&Z(<9rs7vFY|Z~QYvRfp?b95c2VGGO)2(7J};xs zxss?(yi%w}9e3^tIHT&}@6SL+Us3v=K8$N3g3z-p{`PkloLwW>o`~aD(-;4|gvxBy zIAKY*Fr)psCPvkMmu@GPg@pM~N_h00b?vh2Jb0Xi??fk^OXHod0bVE<9^q?O?Y!pz zyfVU7?BeqC9<__dspiN;oiW=BuMWTi&kPb?JCHuw!RhnF65n%EAoP*+X~abbH1c=$ zd}Q%paYY)xIjmML@$j))4lBe~oE9Kw+BBqmt=Z;4 z{1JAkU_5rSS`FlIF2~OB9J`?&+;3FDoz~iu#eQS1-`r5I`!X-P+Wy6!GqOT1pbrw~ zdjqutC8^4V^h+8l8&W%oWyI&iBE@;<%JT<=dF~tgSIh%`Du%tDpX+7!j!65FXXUgX zNojB8v`5_f>G^nM{o_0qw@$@YxNfn>R;bX|U`%$c@0hlPd&#=nHIC$DGH?uNx_S|5 z_@*>~SVftCmn2gbFUHz5zA^>(X_P7G%cTRH*1{Uz%Fma4-#&N%^X$G5LGjDYBO=Nd z#800gIPm<5eqQL~Wz5hx^vOgKUs|wem}hwveQDHUPfkB?eTkpt2l!cjVDT9DS^l8= zEH8}kJ+wXQ$NR_C=DLZqu(7*X48COmIu!X z>Uh2u2(PNd)$U!Sp8pxwFLJAp!|M zI9fHA1{Mpybs=3J+k=UP-ybAyPx_fhw>+_B&~?x=c6jSxLy-7X4TX=8Me#A@4iBH( z7W%(SOr&R2##{6rPMhdNzb4G#aa4o(rxTqkc>d}3Fd`n%cds}^%`>R^3n z;7qB9Gk^i++X(X>0q;c8GbNrI@!Wv-AFw8xG1nyKa9opx`I(s?kXDKlTn=Z$46M%w zpSK9r83yJH(kd_?nYMIRpHIp|XH93hb0xpEtYqf9g?8r;fDbW$SudP-tDsNJd5XC; zf3%l~k8QOLU>-88XI8aW5HYtI#(PW}VCZ*BY+4wm0mYBvO0+_0l~|f^#pNiIHEh{Yhr$ zqT^o;vO)-Dc^S~=pQXXvED6pIXp1>`bR^+edMNpI1wZ%B4`Hz@Slg7vt|U7D_$7D3C8DAYb6z)~nhR4@ql7 zX+3;k!3!*WW2$q5j@Bh3fBH za)+nCe{!vVdy8IHZvgj@^jLDbSjTG)ga($2?A--C$9*izMWzq6m+32W3u$gOyh8+> zca8xW&g&VhF(4fRt7q^UhfSh=>lt(|P}Vb`a0A47FNcfwB?u!=4d*!P0M_P9Z((=* zXxHs>wz}zp`9l#d=AXj&1+3T580u+f^3(T5-ggDsHPH^h``$XH$bsJxHsWw&=+@mx zvo56VFzHq$jW3k-A*b8Z@T^P2=!UR&WYPS3$LiupM|vdr^=Iyy6M^N}TPn@JN{+op z7I^2_Q@B_Mp=J3kJJiR6&E*G}!t`zEa z2b(8w)B^u`0R1k zq4u~az18xL!5%jW_Oj?m;6pQ+Tn(SCllP<^M<0Lqt|xjUiM3jOJ$YLyK0gJ&YfN~@ zAvkT&4u6|SqKpUOxzGi0G||Snxn%Nn@L5Q)TE2;mGA`ro{v{CFj*PNebQ+Cu61I;yem(Avv1@Sn71 zrMWf;4p%%G7)$7RARl=%;c@FTfzIvEujT7aBb!_FKY_XZ^R>#kmFFxI1eUW5W01~c z43aEo+2;AAA!a_C^9%F%oEL|{oJYf)YsE{;VjM3fSsczcg^r)6&v1ODwLcBISku?Q z&nKgWrc&5rK2Op#MV$Kd%$MofrW=4as-W*CK_kAjpLBF-wa(9A4u4iGbU1)t9JNGj zdS#B|1neazL9hNCXzX}twxc4{+SK^O?4~2gqw;)fK$A<$t^?ecIRGy=-k;5r-=T%T zJ|&3f$02`)`RC91of3~hRfB(9T z`TN$bPM*JXbN(;ZjsAi}ug=YkDuVAhUxY@l9yvNHyd*>1h>Zuqfgt>Ihz`sV|U(_MHIsXc99$Sqz+;HpV)w+Df z!y&-K@c+aUr0G$YYgaJ5+c)1o?`90iyVt_}&vTp~r;MNb5O`qaJjS~VB;Fl@A5c#~ z{Q&o+^v&e-n=xp^!T5T?SpsLvalp?TU_3`a_99(2YO(%Ux)bBs$Ae7!8syo#Aj|%x zrG9etcu#*F>i!_>Vtbe@yB=hh9b^oCjv+C|j>%7^nl%PvdJQ>^vZbeLTp^x2Fo$p# zz^MYgUMCpq%|N@18tdY2=m&W+8~85-_+l*l4}DR0FYw5{*Tvj1abC#mQvbK?A% zJJ*%P+y(q{*SZ^kUw~)U-3dJN!~9>byCeU_b@$~TT(=5%r#kptj_<}~%}Om=x;lBm(lK9zF58qG3cN&?txDD`)qWYaY}3e`rEiQ}y{>js#!~C1%%v;K zBbE{PYnYWD)v2}5?$zS?)kyXBNhbgEtPsgB#`t$Ai;=|5}R~5B|JZ+VlSx zbW)}}E9yW6m=kwA#}jlgmJ@fAS592R&^d82H?KVZZ7{ic4HCV$E^%bRrZNc!?4Rin zXiN0u-8{`{vAS5=OFZ@{?|LwjZ@uJ)E7@MMNAdF@)?XjVeCyGEl-D9$KX(GDE`i^+ z0*{~_tJI-ajf;9Pt+++U>-}sABe8gntV+|isO7c)9n7IU%Qq+cnr!=D@J#$FJG&f* zbJQ7K%^1;4TF;=p4tb~DT;q5@jIFCoo6r?sn~*!Vjes^-Pi~uYI%eZS`aMQ{!UT6- zn-Z0Hf-Jz*8m&^72F}Oje6Ek;T(^yL+a9F3eh%h(XB-(&+BA~gb_KKnMFIQ~D!G@^ z&~HuS@odx zkI7_v%#JZOM?Rc~J+_0u_WN6TKh_Z6vv6&(a=#yr&1Ig1Ki?O-K{uwhG%vf0Hx122 zY;XLD?ds)~&n#BYD<59$Kd(IE|6TLS_u1reb1dFQikU-w8~FisD9X3EzeiYGFpoLb zAVd8m1NA~3nTGc@?P!bQI>|mQqy1=l|EhepircBSzO|)nl>B)lf6m{{@-uHA7ID2Q zk~Ft-I7UBhj}>|BYP9X9tz`?*Hx~4t7(eg)uLR$~-oe_PTMhQIdU&^r#oAkp0$ER= zf!^GX_cU?VmacG;4?I(i{N4Re&2LbKIG%g%OevfLP^XUg@sa1(0iQO6k&g5na(dh4 z;`g51YZ!qxho;bxQ{K6rSTN^9GwO@X-gV5|t|zyHzM)~ir}-JjHa?CDzHYj;R7XCR z2RPsw%3MywSBl*0=NR&Olpk2@IV_@)Y^p>wgxSlGw}6&~zIwJ)Tcb!3i+CIG+m}{e z|I_Nxv!y)oZaY~{U~BjWFTARxTB}W3(GY_25#G%t7CrDi9cK<()82XJr`bF?>m{8( zLC5&sUp?Brv|fc$Z&uc#mFm#G;jUAG<9^EOd%mE1Km_ZL)(7By*-^(DZMD?FIZ0kW zfSv_-%>+2VO&^YZJgi809>98ezA{fauCJ|r_O$O%Uj)Y{)$GMFq3rdp>w)wtQlXb( zzY367+HK17n44F`G;wRVz0AIf(E7aocxEQ(9F)!z0_*Q(-d~1{H_b0US(Z0hFJiyW zh!^k2zOWxdHKDb4F^`xU{hze{f5)F+&Xwq`USCQV0(ionFYRyWUsDLbO$XWdT z3+jJ^)POR}=0cKNMCZG9DX&BZ#w(dNrM(~d<4CBz?C9H@%V6KMz`nhn{vN3=ze2f8 zMLcNz8r)aL^u-;NA^DqaH6y;p{Z>+M$MKBaai>Mh@j=laUYU~G4@t)BZzD!X5*r#3S? z(1wmP!)l>1$%jZa;(>Kvaoj(~`GDt?hNi&!%Cai&E7$yaGhI)PXk7n4yI&tm*FRfN zCiQWLa(;YdP5^c61El|@xg0N@@7&^dTC+a%JJhTX5RXqd9*rUX`C!M-gF9QkIoJO^ z>sND!zGwZ#97gw)KRF?^D!x)3q#m|uRJc;MX|F-s4{ zVYR?nEPn^+m51PLsNlO`_>!NyF#l6$HW8-^T>sB{SM|(58re79Bzg6ytU6ob*Ma3U^H1G z&7W@Q^)XzXv_M)Pp}zE3p+=7Y=P^TC9~vdjXk$Scg?M8fO@HUTif>%1cZK)BdFJvg z(oi>x$?kQtq&q!dn_oHiE0=QFn{Ku`4tVimqihU~vN7z1G5l*b^=BOD1lk-3^T=ql zTQ3|l7ir(Y=j+p~ zEpx-kWUQ-fF_8SZMpBJ=oFBU4I*zg4haT37c;>h@oW#!6rmoGx;1S=fU~r z9_O%0q_3@DV3vxlAV8dd%i*H0J+qf?R`_x3NMyKw-{+ubrSXL+&q4K0o1+T8W4yp( z!FX&B@F(JcJc>S0$fI`yk8T4Vy$AU7Dfs`o5JhGG8g=QM$(lNS6m^DeuqJ z&*#2*Gj5&9?f)~>*IS~@2ORfkw4n@_7nVr4>3n(NVKrU|a1N$(xUHe;>!=})jXh|e zo&fPMsnA}sT@7}3`Rxow`+L4e+J7^X<7V)sebRTJ{YNvEdzRcThTrX+_O!0BjPApc zPp{API(vsJ*Sejrb=5us9LLXR1~+%x_}pEwd{4FB1H}K~naX=bh0Sl3(&qQg!0>E- zD^zF`$esLrPMg;({_o^5_T>g59IKi`V)5Q$`aO?7&0=7Z>gTr2JzWcY?)t;r(-^zg zSwzHKz=edcJ7AL)&LWQ!M=RiVRv?Eku2_nfNt@{>rx(D*ypARtp>Z;=(EEFK|8NrM zRSxg1yi)uO%k8EfhY&p@0Dp}DX{(cX`}?xz1-Qn(oT22Oyim^?bMp_^1%5D0{`vEa z;P~f|h4If=%OLjr3abU>p+iIRhoe6@XDIFY3l}`$qw`RBDS^S;$>C*iIk@%ZZS4N# zm)pv=Hobg5;I$s@#i$c({UP9-!ul|18QysT-j_bk`{~qr?xgUpcn#XZRqmwFpB3lv zYTidKoK>Zfv+`>9CzooQ|-f#>~<6{;HrR(BHZ^yo`v{JU$H zf#=Ko^b^;v1G0R|ozOOcKl{huc`i__nGEZswTFypy< zuw1k8%LR}rEC(sZK%S*F-{Mv4TXg#wkCbQ7%<~KyWiinjEz8TB9m4Yu{$8lgz~4le z7cI~C+QM>w4VLdUi|6@z+{N)$C!LQ^YNByycFwM4U^NfZeY4|sbDXQ6k_X7 zX${WiP^_~O>)fM>pfzWHg*494eo#6)>iN<((t&%6sIgiy-XPac#<-4LQk{LpRsNMW z&si@R>lb6a8o}6`g)!9tcSRmKZJc9#-U!du3i|qoeos2Ej?-sYcO#VNVh=@L)7LN3 z!?}EvP%Xgs(nwaTXKx6977Nd2v-(q|CU~Z2x!~xt6nGZO;;~CdvG-1B__IuShV^RT zdua~qPr$fmc&5vPu~}G7Kb*Sk>ty}08>&?DS=JbdZxJ&Lo-M(e@Ok6J2YEe2ieE0T z(SW(!bD2CJtnca2cjvqy`)&&t(bsFay#LWM`b-A;>;?La=JaVZ`lZiGteg0Gn)nYH ztlBIYtXmkZby*zN-sr($9nTTJmch!-;B+~`a=8pem(##=8MB5@mrAUsninEstYoyj ze;(h1^{v}R4BdYR_Foue|35Q{|015gi<4z^7zU@~fl1=?^7%E%VEtOa zJ*~RZ#9=vNRAZFfu-?~-b@KU*=QM%6#h$|IRkfzDnoWD-7>($AkB${@mc938EPt;9 z^j3g@b)Yh1hoM0)dsw>?-;;Ee z+AyNXU;T&6u3d!dcjx@*ciBMWiY)*2CsE(piZlk<;j{nhSnhl`eKj(gV2!q0fW8_| z6MdvAui0>}kiNOI+<0FG9ahQ>hHd2MlFU)^doG)!56>mVzO^eUy|yKbuM(ECdN4ja zSpd3-pZr5Zu3Z6S2b@0*Jb(H7vii;xhmU56J2(zF*31&E_zM3{pC8tXzmk8q7l<#( z+c%FDH_KwD=x^J@DNMz0*Nqc@`l z_DI%Z%Z987%cmMr-5tIsbuJ;NF*h30aiO3$SB;6V;GCJ?Vfl$W;XL^u{Ldwro1R!$ zuHty-S&VIhwvPa;ZJa(fsa`0_anE}s%=;#nZ~gj&%M5l$X#F<@|2!Bgj`i;2j#=(A-$Ov#>E1L2zL)U69_Vz9 z67N%)5$R%_ci$0_ZkQJUW|sFmh3Sgg%M9NT_d9@hg9+YAnnoJ-<$LD)rko-C7X!3| zMHVQ13Xep4*~$${TRGZZCdDdkFQ@XEUb-sZkG*+_^TJ2d0*hz+CG@i;$auCt<_C9{ z`E`J}J@FlIYYY;%t<%)cSzDx>P_VaPPAJ@GhLaQO@gVW}VSu!n^&M!nJV@N;4n?aP zJ4{u*HViiqPZS&2Dc#uV;_HFDwELzQ-+9<3NU7AL%!0Ve<9Yw zUAn0(PmH87OQj(%-Y<<$9=AltH{t4!Zy6t7+fd^R(AKknkI(MU))PQZj$p0WoRI0}DuZ+u5u-}o-BZ+RPB;~!0Cd{i0$9|byJf1bkEc+B^}GXchP{gUH= zbwB;rYQXeym@&?+;`)x`=$iQTXEk%Z`n9+ zy)sFix2S&`_u>9P2rgErYM<3%4rMu^<+RtiX=CWMaoXff@#t0Pmx1rCg)@2GD&SAf zo5sq#!cMKux%ZU1&x^l#jho^X&kyh6oI0aq(W5rPnK%}^JgCJdFX#QUX5YC=b_Ih zDbGW}&c%*>MVRkehFf5=s9}vJOyB=mP*7Qaft&^ZIg#%^I_3g!>}{MlU1)?qs;50Cacu)VPTx%`4P(d*IhurEKWlm z(2&}QtVGQC#+5|g05~;Tq+7LVbtw#@<*OcN(lM_G+xD@z)GLe@Q&2&t?_+(A;|_Qr(_F%Bx@v zAg%IF#EM%9i5XW_Sy-w!)?2xs0OoG3!<>2+j%z5xbqc?ebU&ZZ^Yw9C%q;KcDFS_d zGMqiX*8BO}9KN1CFZ1*KB!7OR_wytCeIt9m$`36+p2%q#Nt$t9aGnh~&qrd#cEMgY zZl$#!=Xude(!UVq`Bp!0{y5PSbI#!$q;T3I#axZOEJJ9W-5p^svkLav3vI+w;s?&5 zi5}fd0cS~um?_wrvLdZbxhIPIiy}!=$q8+LyUt!_6NILGKk)WS;|ue^%llx07~!Y= z4ypeTcl+&mPtFoEYphL(M{bzCEDOeh`F(6Pq^StU(?%=_e&E$e{cF6&(^Mdq04#)y z@bqsz;eof3tj9H!D`4GvUtX@5z~x$my#@Id<7D%MXt8uMr^p}1`#&4$Dp)Ov0~(e$JkYhcP@4Bp&waS}TBLX41mzxE>o8Zeb_e!@_fdiB zWfLd**UN62IP@7Yj?-<^AawAXZ!ADPB$VUW$ZbJu#<3WZg}^VRKOs2#^>2B+_xSO?GB&T#vj+ug+xg7|kKM~{+W}naihON5=C6il+j(B1 zowserh_)RYwe85)TrD5l`Eg8_NGAe|B_XV?*k1Hc55e4fLa$xR;vDqcA1({NV;iiu zuG6tL6}+aGnb$PV$Kdn!+A=(3lOp-Dak-c<2Lwe1S0-crL

p4xYXFA?sx`%C?itf<{x<_Wrpt{E|%uH_Pjs)HgC6h63FgMxSg1H zHCX!@>npL=g$3_PRthX9Z;Zh1^idAc_M^3V_)gswS9zy4-Hd%;jj+z7J*iTE0rivX z)lca6)X#XYe)PfjWA*B%O5P8`j#P(@`LBl!EA}eFG7SwY%{;`}H9-C4#c_OZSIKAK zZ95*>!}3aY!P-DLaF(slJH4WC?f{{+HsyL#HjD}TQ=I?DDPwunay}17_j+iY@@%V? zN7Q>w-fm`V^4+|vpTD3XDllJ^VD>g!j|D@S-mgP2_?LFgQ|7uI7cUDJ%2Dl#F zwj9_)+_||YGhQq29-dzPwt0AZbrYveI;TShv6o5mK>DnfH$Xb4;hhQ6RDQoq&zn~{ zxBGvamHusN>wKuS5)AL$nRj*k)>laLk~DNa;s5Izp53;D!>&m1wZ}>R@Cdh&$mUAA zFPmX)DbbPw+FyV+*YOfx^ZQG~9qf!<1?#)#ou>Qc_Ev<6eIO53YIUri0BxXgyr0HM zFZ=i{BSdF}Zw&;5EA>^PjTu&7eR;}USPLcJ*S%xKtx^8#E!3T1x&`KBKpw-mZ239J zr^W@pht}{K7fF8bc`r}tOJ{g%(~$K&{*dSEr*ah5i4oqF*Y{v{2|e?5OtlW=3rsI{ z+k~&ey4jV>S*#18^eIw%BEg#=(7k{?C-gbS_hB7dntu@E zR6}^%Sb&9}G2g*#p}I4Il&|=^P)*|DFUFZ-?cpKf`Mv>q*wfVG`JU70>MK>^`EsLa z+~`5Gk+kS)l0>XGlslHylpg>%m`lSf=qRqJj~Y0xI4+v!;-N4&5v zgh%`OSg6i$xyo0Vgla3$PO4qFU*NS1k*3fVV@>fMkH(!sTbx^rAC$gVM6*00Q(tGX zcbFTN#on2m)BoVwb<*WZ%9X|Fz5A|f7hMk$K3d)%z*?zgHnxX&94fXypfYcEn77s# z-}--2?Y}xgwi~jr&e<$FZw@|hIA{4FW3av%#*B8!;zd_Ru^O2dZki8kV4maRT`X?& z+jwQYOthD*jbgb1w?^48_F`a-U^uoM=#9K=Cs;2OVFw+?3bjt#02N5 zw_Urcve)e1fXjAJy z0UTL9!BkpLu=6X1AI=A>C74?O7{`y+6KtEKz%MG7);IC3CnyM_RmaNX{iKBMuTvN-`P1m{DOYY1lSK(#d`YbIEivDHoa{gXQT#`!@0(U0Wc> zIsv`OY&@JvF{jJg$%g0GwvmoT z9Vs7gvKDS@BiHZABh_!h9K(EeSPAA&OE+WxC_C|93Uj6I0J`FO$KNpz6UB?mOMHGv zlRm}$9DXZ79-F|Pm(b^AiX<1V`(Kv3sS)(O9SJ1gUk;sL0o34xq%A(P!rDBlNjkP7hn&XP^&Sn&p_GZA z!{GB4kguRu^%&r@1LO#Pj)Komvb;Avaqzi~<-O@iWc!v4Kc_M{m}jad4L*0Ud`vxA z96o*?gR(fWcg?ND(FpyOVy$F;CyK}?at6sG?i(#&;;VN}j+gP1dp!>m6+^cL_pE|2M ze!yyPW>WelI*$P@G4JWd5ak?HWf2GJIEd3EUUPFWoYKa6Vi5h}l!C!rVQ0otI;w<3?fO{(_;|E~Cg zRM){cr$L*%NR}T=l1*mdt3zQRi;yQLkjc5Qr{mo{=48TLPUwRw?Y*VHhdG6_lC;07 z?X3==VP};3-pX?w9W>Kgr8@WC>Xylm3yyNm)9?QYc-r9E=NoyxEWG!!>Dip=LOr-; z18$g~@e$5%T`IV3#QEhlUXuW~J{fME(takxty_kh={mtNfy!Q;6Z=quzXsultHbYM z`rz?it`Cmm@Jm1zqHVu?x!}OD_86H?m!aW$7S4mBDztY>H4M%oYs1&Dwk*KEZ;kfsk3RJ~ zc^*4SAN)-4)UvLT^uQgUI|444iwN&YG4~DXh10IMP93^9QG7L#h%uZGr23nfa}L+M zwBDa-btNswTF7QWd^L{HJn!`T#5nQQR3b`w9q>J*$rG(?Ppg3S{W{1h%%A6;M=!b5 zpW~4O@_F2fx#&PPbaromW!^N=b}sO@Z_R1=?Oi*O8Cohr~ZGVgOgDmIqEXX z;8|bW@s3tk6D_V4SR8{B@Ef0FwIDC&X0WrKF@snlMiYxJfvoqA;cgwRp1UrPJ7ovp zSE2R2Q^xyb8ha1;q5ZYg7Aw{Re(k6;0#0{ptSvO=0&v88WyH^nICK0Wl1YmJ@J+vu zSme0w2!xJ;FC8}ko*8PgzRg2NCta)YZaI|){F(QKUPSuQyexRe2HAjko8cUXci@H? zB`xm>62F^*#qaBMb^P+w@tZ&T>cn=>%~ARj=1DaoBd7h_{RxwEhVM`4%4W8X>g=oI zN7^@98C&D=Bi&5>NF_VH+_tIX`DfRDHar{s-$tw3=&`1UnvMP~KGuTlp>6a-)@=-M zPF@-96?>&(Z}+a%xI6pm*vcEExkwp04|jwg+WUk3V-CAD(Zg$l<&Vjg?BwR*+sRi+ zG>i|>PHqm)PF}+M$%yf?T@Pj_HwR}YpCHjM!YxNm1!O1x&G%y`*Gp?j7r34LQnu3X zJ`g)O#uXiqYP$ru&QJ1wDzu(;?o;zwhtO*+cvO z#&a5N&gA#)I(A2lGNFbj{fxYQZ)n?GuePCsv>or&R`1((usdz}o>><)e7XO5ma-nf zka9mNIJtk6_fru%47nc_oZSBv?~7=>^v5COzPJ1mGyT_dzLF7KKDGz52G8FN=lX-e z-^wT<<4^!Mss(SW)ClSS5ap0=RVjEhV&&~1NeF&(n;vDRpu4mHy z#6}0O9`4EvY&{$kU_ICjiuGU|Vm;i<@l!jeDP$k_?^NRrvO2KQCyJ zW0IFYWKTMqx7!Edt8>6tX9wY{jtq}oNX}QtOUPHHLIjolF9Bbn%t9VQ8yniv(5AMQ z^BCIP(4IzZZNOh>XOr{T>*=!G`T^)ss`^fZufAiCV!Bp|hWYNDko)e1vNj+Cr_*+z zXXymiPSDEQ>3KU1*PHk}ZshZb=XrnmrFLZSbAv3`O00Ay3^e~&hR4@m&g+Pqd(Q~{ z4tqv`xkOfFD0OD)`-<`K3!YG|GvvL0T$}E@uPWv(hC5yPF5tN@qIvJwf%w0>s&v48yXzn{--o=G5+^J zsox>jmEr2Is~aW!WI1=d@pIELK$tcTljvbjf@cdk%q|VP_d>XM_tgb$yleES+W$B_ zdl-X9>$8Q!Ip#@EekA&PB;Lc3fmf-$A#vFDhIrl&)&xU%p0%6coCVKl9{>yc7>?d@ zIgR5_7+kdR{LbY{>K)}r)=D;k47N7VX9VMFq_u%@r}DLtImFt?R9PF|a{u4Of3Kzm zAlD6p%k}3*G5KU1b#>&E?mnxfD~?Pe z9|Nmn4lr@Q{dA->1`{1aE}ZS@`F;lx^HQ{;OGB!s33a=r`tS8=p8D^@p=Bt?1J43= z3Oi+@b7Gp3jvMYL7Jv0g{|Wfw^Sns*cWk7edU3#?m3m+P9E>*w&YN^Ty!f#X`SI${ z?{PZ6jYe@=kUaXUQH-vQ8u$Djr}GOik+ye@^x{#(#iYcA(c4$=*~V%8ZUp=LrwArP zE{+&po`@a$Y(eE4dvuh4+xFg3L)*4*AIa_iBcxiMnvTmq3IQGw#J+LpI~E4AES&n= zcWH^aJJC8&0rP>nprbauUofJNnAZ8Gfi$1fhB!CD-{pUM^l6Zn(`{PabZm=tAQLM# z|D`jVh!5X+;LK+rgD0mEP27ZITUB9q8 zI&0fz^(4Xh_l;49t2Al-6&rs#Yt6>={zCL42iTPWw+mp_QFyxE8cob}go%A(f7sV7 z4jMIla>cYNK*#34NU|)!rbY#|N8}uRdhjQlk{5$Fj?f4mf?~?gg zt)w}t761=k3p_Z_(Kin3nQ9b1R-ca`4gZAy_mAMdfLblgXPD!1tsq{yH{5aYE}f%) zg&trSU|x+h4_F_}8|KEkq}7)<6h z?}pD8sn2+ZQ%JHFHm)WehilXN4-2H>mD(TlrxS8|vDUu((L`(E%eABW(_Pxr=)2yH zKJxH?4g9_+#rpKx%p<$=M!`NW=$va@bEj8gEk>9d^xe`4_OfH}*9G6D_oi!2_3Po? zSD-I{Fmyk*KHUv8eF$K^vTTl{E1nFXuenQ;+WRJqb1tmcjqtazmUNuZrkLvmHWumo z6Y%|8+4oxb-oyKMXn)W@&SWjT8~WXrd31Lv^jio0u7!SAXp?#!+O+=mORn-Y+SGcN z2IK&ICh?@%QTreLdZMpyk0s5RE3F%NYDBd4>7!xkagoIS^m$k#RT_Og#^864unr`J zl8yr~KkXWwp(ly7m9o%bwf(&n!MOOyHf%1%4I`NC% z;95>~ZUVe=0k1FMGs>WgfS)9X(ut1Z_FMa{{i)HUc|WlA4Vn@NORKz{J6-?H*9VszLDmp2j*r)$|5>9I+&Xh z*c%=}yD;p}?Z78EK3uo$z!!U9@7NN>VwrHApq#2R1AoZmPNo6n&LWT*r64;Nf($8f zT)KAw$e?Q-AYa__=Pc0VxgQWmm)1&s{p$ekEKVP}d`7xpyin!FTl>3!|GI%@h|88Z zkP(0*!r9{m$IRg%eu2Vz7hv_=WOW>Y?}*m{?TY>_Ft)Ax1V=kO>((-SY5N|*(T1@% z(DyT`q~Rwp7hUj~)ZE@bv&psV=~R@>Bc0peuf6v6{sQ>z>%J6*d#_c6Bu zUiPJrJ9cjrI(EQbv}@xJ`mv_fPe-zP)z6MZ8Itfv#Omis{N;vNP1 zL<7bZ6~LM9(<@pUw}oTS0KUjy7$-Ih?Ta9b&A?-&neDrEz+<-vDb6b3u{wZ{@_Yp7 zF1KBAH9rFT3d-@f0l(+8DfO*zZrh+u?FD_S{4t}I%KZOv`saZ=02k!FJ76DU`c?|+ zyReU@I={eJM8FsIz()u<)%WhafvzHuUm*8Z!}rg5ESK~SwqJdG-oSt1J3uGQLAf39 ztsQ|hjCURZn%Lnp0Y0Atv_V)pLIwbjQ$UwfnecAW`|}27!ZXaRQaRJw(xufcs+>t$ zbVh3noFij(M$&RGz*t9NCOLJ`b^)h7_V*xs#`!>=z;Ou#YjzXoDvrZE18KOt)U}K0 zhOe{yRwGH=+{;?(x11YE;!w}7gf&+Ra?=QOF0F~7wPBZ3RTj!?!}e&6&XUd6{_!ax zH~cAtOvCsroWpTEf6iBKnPGG;0h#ejn9p}J|FnA>%x62ujC)`{6M0(-E64gM!n#{S zrYU}(Z#|5^zbuexF^cwf+Wsp-rWq9N?W{c^`FlW5#2A(X7{4tT@jknGAt@}tSbBJ8 z0P}BM{!yD-9}N`mJs|6%L1umfvhFK*_G!wJHQT7Xi*RD!i!ZsReF5t^1m*)_YDP-> zkj5Pwap_(ytiKRgf1wUZ&;0_{$<3e>>5^ES#NKey+y{D*6ZXr2DC^U`pr_mpXI#{E zG0yD*=)R>nAE$QFn4)y&3}URm4ffNYycr{wCP{lJTSE_kEJl5z6!3ol&LVSRpVYxV z13E%}-H+DycNU|cW|CRAJpOsif%=Hcwd)-GML&6FA4zoYV^V*em%Es z2O0?)&<#O9tAo8FK4H&p`4Zzg-} zS&~mEt|5STir7QNJ+PLro+Z{3%d>@v#<#5lFM-_bsnt*E)#{!4w_(2!K+ePSQd^XW z@kAKsyC#Z=$XlhCi{3*U)>+`MwV~Ga81q$({`t^mt;V|kE5P|4%ts_d*1s<3PPbig z!5JsrN&Wi-!=fs4E}o~>PmM*}6j^T$CF}L@Yz@2%>$G_*{I=m*@+j(_d3r-Veny-m z{2$=5z}pA4Z;5Lcp2cu%Iv5wq$Tv~9q2HtE_agX?wCqXo^&LhUCB%>psh{PXP7)pT zZ)1KPa(Z-Z=|z;!fa|GVy3X{@ZP4Empv6*~ET(aC8xW`yKlobf*XmF}z6{ELxJ zsVZ|2ne3sdylu2fTaBN#Au4VAlGNJ{r24k~%1_&GRN9{K)Alo!wjcUw`;ki9V}9Dc zqtdq3Pun+C+8*-L_BEBZ&LlRUx)AF?UI>#Ha$h+q*Om8Ywf0ng6>Hq*;PW%l7NT?4 zX-7FPe-CprVmuZ4VSrw6{2ble1?Ok5R&*bOj_|j6Ad7X*&8aM2Mu)n)i#YJ?yh;Od z8uYAe&?(RD7aV9;>wI9-tj^kv@V|Ce*A1XcpxtK_m*X>O4lHU*MxDG1Z5%we0Qz0X z_3#r^UY1b3`}|DIS?!VI4<|7@POc8`hM3Kb=0kvY@Of?wz=d-_x}fRB^K3_}%^6Bz`u!>?+6K-Fdvc`fP+g-F_mYn7#aJ z_Aj_R{riih&ZT#QS~KE>wzl&ymu{K}7+Xr5$qAs-0ba{tFVp*T8;%w@*!`ZfSjuPjpDWql148c-4hqO$hF~sT4)+pqm4QFnGHv8Zl{+XsB zmENlYElT*DryEIiE$~=-Typ(~CyAK667-slEBZem3=hzi+<0gtxEkbv4QKqptej!9 zbb)@>4m$0pn&jRKz@MvOo=`7CT0R8lp$7q9#1-|^$|;PNcs|A7{l&fdww1`n!V+}_u9U2Q@8`yHpaTb8TvTJDB?P%`R#Fy ze=(1(ag1$*KF{-edh8qb|2-NL!dM#h0pnDAz z!)MPJO^Px8C)<)J=TGs^h;08f3Q`n`-0LYf}{d>(@T zD5oCyWZuA`o_Pbf2jRLu$$8|9WabB`kFmB;UpwH1xkBz50p=Fv&=&Y_K%R#=$M|04 z?_7Zt$YHz>Fwlpw2HIj@z?+BII`h5jz`4B$cq44$QlFPrfR}N_Md5kFz_YOT{=XOf z-Z-4I-uvu1w$HxAaRhnXf@8%o;$B#ZJRQOI9UP;SLk98p)~!*N!@G*MI4_vnMD3oY zlH;QTd4YNX-PiCApvX#V%i}u6HMBvKK;=aRtmPW`Uzd!!44HPnblrLXIRT~}3y zGMQl$JTk){P3;1sDefB-&&))T>LwcXJ0*X#KF?{*&frX5$nK$ZWcB;y21s=XYph}T4(Wsp&r*A%CnQ`*PP@_B+zJtC)R8dEJ@B>|fZfPA*dkJHImMnV zAo(bt@Hz6c^z40jX2<<64(AbgJqmavk$uM8CzaP)TOOW4TGC@-%!x3zL~BcX0?F@+ z)74)9dSRZe6wq%jt|B7l>%o1pJItdiQoTy!`Q8dV+y#C0102+aU>z20<+@NO*RAlr zX(Q-CT487Eiy9JV;JzazDIWc*IG&VugMJ0DS_+~6zI{ZjBf9BTpu^1t+6%B}-2!mX z&es`cqq-Hs%+{*Ibb(H_F9vW0J*zSX<`aFKP-nan?#AyM;P1*XMjKl+v@PKBAL#UO z0cm*%^Ar^mafLRex5x;xV>ek(a}Rlq)2e0TtOb1TNP0f?XMm5S@8P{}iOHk$>GUu+ z1vX2`WmkUX6dS!$r@9^Vn*n3&0(n&k<1B#xxtF4+V{UHztumYx;O~C}oIedC)wqWv z&sV}+F2cJIT;Bqjo--W!gme0Y15R^1JTBpbIcg-jRbKYctqbk$0rr7;VX%*q5KkYN zA4!7SE`!?(a6baN_G=T(;V_Om9N)FLe%WWVGawD{j`M;x*^JM2!kDY}Ey#N?k)YpD zEb>Za5ou|Y#(}w*i9{>GBIUg3Us3G1cvlguNtA_u@Y0`s&nFjPjh3g`ppp=mi@YZ!HD#Tx$BRgSRV;(W{Co6HDXe$K#Lw$fPx&xq36L7z=&?O?3H zL$G!*XW#3@dixhi`_H9&8BcrfKYiLz^Eu$}v)n$8bnEAI16fv( zuH`m@a7MS8iPVQ>8lL&E@3}%^Z)JgCX@h+Vbd`b1DT1ZSNSg7!JtKmh!*T3*mc0w` z)`xp&*9m8-^}w%>1MiMVvbG?v>*75!_9&MPB-*o=ya_TVL}*C;LMC%iKX%JmfVTnm zlq!IkDx{b#!bo#A%)23eME!{UM7$mL7tEhu5pL^Wg#Ho$7h&E3FcIDt?seb=Q#-Pk z!Au31DdvrMCIi`43bg1V9yyLYq${>s4#56}z9w+a$;aedXYAdF7xNqCH~`Q%>FOg zB1gt?-(7s(9wkb@C-UDZy#5jH-)Vg9z`OySjok%HzZ>LmDpHi~FZ;H?66!}EcT!4JJ& z^Xlsf-q&*~ec5A&KOcL%;NQ#P*Qmg+3r+$ZaCeHT`?c*4k3_Bbmmtos=p}w$B_<{Xb71(chU>_tuNpZ`&E72qS zagTS$Kv)@!#LJ(y7r?%}3+ASCXK~9L@yxDm;&H<0^IHUGp7v{54r1Nck3XHP2b!M! z@p}T`IR|jCf)1#QFxBg|reoBP8h+0@{p0u0pLP{I9|QYO1Hczxd=0Cj#7ui6h4m8l zRYXJ){yq%nk_&_!!d#L14%)`nwjjjh{*&YIYZdV%SkTlife!%VHT&-59B(Shx?+$88;o z%>e=DA>H{<5zE=w5Jp6xWA918+ws_g{aakF=8T#Z{jfh!zxaNdYn!b{304EFcOv_T zJiZkH*vm%6GrzES z!8*MQ_~Od4YaKX_TwAn7p0^uyh7<9i(;U`|3GqyJti^L9tgTM?J@ap4#UcUkoF08T z{&LBC(ednltxNk%D%uV!vr68>cqG*I{r2H+Jf!n5ACDQvQx{8OjFT@#*Zqo*Nu|AF z{LsE0+PD8oMgx=|m$HD(LcR3#81LDrYdn`XY zYlC(K^o6d3aih%`$GjEf0NQajQaZ#t@jkCjvm;fa0mdO5fVNwrm`%P0?+;*&0-qh% z0IzA`e`ZFaSkxaU>Yki8u=>T>VtiW&?K6$7 zc96C{{GyF>^UJ4Eq+vD4>o-AO%jf53F4r!+YrF&R`DDLG1C3|fO))w&ZEisPr}wKoY~9Y(6GBP7b{OuF?t>TJnnj5Cz3$x;~Yg$*lb)f6At68m` zoA+U!aZXO4T@HSK4`82^?qX=JXf|f-`*-jRaisDkn%#%U^Z2U0^DFr~;lA&n42gk# z9^?qt7;qw&r; z+oPptDA(NMfoJ#~bH!)`%yS;^l=51z`{)I9?VO*gjCo??RDUM77f`!)CW9yUH}=+d z**plIae$tHdN1s+r|YKF#sP1q`p%`Sk31gd40!qWLDXaz6j2cvt8ze15sgtUwYSE~pO_gG6-482oM`$L z9h2)xP0ZD_D7FnVi8e;fl_ZUSxA}~;sV2F-ZN4^9OzuKST1^wfs4(|^&)H{Y&v7t% z*SFTU`mNb(!Jg;r{qE;I`|rGapS}0N_pkqnb`URi!1y|khUJ%F#*F9mGyCN490qt?(Wvb zcV_X)&h<5%t2vXn>~UD9@8!MBn=TJ6kKRfh#OI+b^e*9IYkB2wiM4Vgo@JzSS5}dA zST3c>q@x@BVi+@F`RoUM3^T;zm_J2fWE_4gD3LQb?V!Jb6Fb1qljnnSBPIOC#5O3G z7ATi|PH>{km411xg?l~D&&3kS1=o^eMXO=Mu(%?6&S+8s?srI%%K*RK{4Us8O1>t_ z*N#Gdv_n4h(DmR|7SeH*7u@(=h^i=3F&kuX|NGM^;bckWp_6nHp;JJMTJ&@hzD zizbd|PkcriJ0a|g2U|^;=82Tq^-E)0P3aM&F^d~yvFDHu^;`@L7dg>db&goOb)q%> z3fGABbGlN$&XXNR3D-rbI_|*oV&WFV7*7rR5rkPm2H(M1hYXj(=pKT#?-48d--P2i z43HlNZeGzP(pU=lA$YwpmZkPW!C-U}x(@>HALC;Fpnmox#QG&X-_$UFJxWySE5tK! z@v|^)Z9TEtmlNwx!$D7|H<-?4Mm8Yb8e;u34O@GfSTXF6Dt<@7AL2KKZh9vweO$WR zh4o%1blQGNpl(fvIyMdJWC_$etdGC(pI1T1HMIA^ZrKTT%TC#Dc_Ci0TQ-jI*)4BH zD|Sm9*e|~utgQKD*E*wL6iLR}!T$1{ue_0Kw9|Dl%KSbz5658Xo5;9sDD$EgJlnR# z6s$iq3nVsOB8~0PUbFCfy>xe-fYVpFmF%?c&-J_ zn}NdEypiKXalOIvX27(`U3ja4hcS8csxNP@>F>xJ6V02IkT2P^9-3{baqv6}IMY=4 zEtD%)emfAy-Dd5e+l(FLohDom_RW;nF~C^f0ydciu1RRi+}7AKvYpaC)7rw}J`-i` zMOWe||0K#kR(glX`{*oaqvP7r!8l$n7hUqVs~GzO?b4c@Voz-ekrw~bAlrh_pZjD_ z-BQ-?QM;vFwP|c^Gp%Q#z5l_he18>nKMUhzT*p+DLBJ;G4rUN?P#%M~oSXt}atcSv z4+^=(9^HJM*AuQCw%&bs6{cV|gcFe{OO0d3T@G z!x$0tlae^-}ZxO1<~P zptD+M*z$sM=rD}gQf`?0Mib=0QrxR0=*13sUd*9f+mG+@pbp_2BI;&y8?05jxPw)? z*nzeI zpAP=68N*h{|x-O;9mj10{kz)|7Y;8gTD~`ufeYZ--G89gWm_f4flK%dyLEQ zTLj>*1m6IDGx$;9uLVCA{EgrzfbRf51^lPLhkVN#S}c#BjlVEh4ciFw-e}-;4&E~( zE_BOlchq?S&fRvITsUxsyMXIfv2LQB$U*%iU}IlAVDQz?ui*EwNV01`#BYQ6pB*qr z7wP-wrWBV6j ztXR2~yM8hn>OG8U@VuCrkEO;H!Lx(Y%Xa2GT&vU_Ln;jLJl(#&{dg7!_THytY=-(w z1&%9^EqKlRq~NrzX@T^`K3r$eK4M{EB`@^szYTrKYBH}f0iK}(sTc!!afMHHAG|O^ z8U$&+vd(>cDHrLiqUqsT3B`Dx8%j#OS1bp$a`F;h5O1Yo#c9PkT^Sd zWxr{6Hnw%*IRkMq57M6`*e}%Ya{4c;)*g9_&|3 zIbz+0=MUgnsuLC#w!^dWI*ezofv%qn=l=ueJyqNa_!~afeW_M?reNLv=2yhC*$B@Z zXcJYCHjeifQC))C%(mf*t)kcaY?Z$aQf!qO@Jt}^cdz!}B`Q|{GG9gp$lcX8`hd#e z`AeTh`q%3Y*pEyz;hd*5(=57hzrkD3C(IH=PXX|J#fQ0X$?JKcpBX<=nhE2}%_iux zaz)q6FHLr3=M}r!e>KI`0_|h_86w$9RA-e%=X&{~Aho|V)m7g`q_RtbRG-7S7wp!8k9F(bxPwOq>l6!tWNxXTn-&^64_h1{`MjZP_U za6+B@e0}}er3Qc7k>?C}?m|@@u1`&t$5=CY<&2lBoKjcJVWJhkORCmCbF8=)zZa+X z3YEC{9Z3stgMl^C2=SKzw^IjY1Lac%+}Xe_0ypSp zm7n)1q+`D(%@Znr{{5$!4(UCuN$+J%p1nl#>@<`o@|>QeaX_P4w9Y{NO+Sc2aG z=xC&TH@7BVsFdw)liOy(ektCK&pP2L())~o>{<`?b_tD(;X)xf$_-cE_hP)8!2cA) zTfmDRjQgnOKGtdOW3^iLIizBbj$}QeiQho;@$?uxAB=Q-R})?b;f`?9QB1G5LL5B* z>yIQb-lH^~n$h=P9xnKON|WAqX?j~Mq{787>{ZVM`W)I@E3`pJF0?afcleFn26^5_ zIUlH<6WoKmMFl%Rhlhufc{p$L1t00gGZ64BgsTwmlayKYhd{Skv8{W~L0iB!lmmJi zK+hGR=QBEk^9uAKCqd5~&@BV>JOp|Q+#omZZQQKUH4SuigN|2xIvQmiGsY-7mics4 z&Z1FuOy9^|cLTRDl2m}bJTDUT!*4}yW6q~JmjgC$Y^zD&3>Iwjj!csEvF{uSWj?D1 z@_IAyRb`WkWdU{i+z(JbsLoV zziaNZOQ+N=xt!BUg?jzZaQ%PUuhawHqv1KLqy5Fu;AMYt)c>2Z$MxO9Cga|1(4JeM zU2oLry@%?ZnSB3k0MFmSI)r_NF&57rR?_^D4&S{l>=iiTl%Fw_b3uamY-yNDo=d=Q zbEb2A<3-umHC8A3%Vt9~>A1z4EUU1L$|uQXWTZOP2kF01AGGz2c+XHi+u~B4Wq>zC ziyaow%?!Ftr8;aLLK>^Vu4(zQ6vy)u?U}_c!kwIgW2_9$F{R~8!P=5p>=8Ke{T3J_ z=4$e54(Q~FBOMma^-`LTTfp{Z`?8Y%X+67b*o*gI1pN)5Kdy;C3(rttlSzJt;{IPu z7oTCcrnnT+vqO4W9DXN2aArVy1kyqsu|A>=(Dk;rqKHrE;ZJ%izUou<-_FOqUxb_| zxPCGtOgbev%4hqpy-NOQkdoLq*B+FnQGBt_dyO2;mWG2&5gVsvtaK^DQ9g-{_u>%g z6kMCgrd_JMkLJO?x&&$DvS}n|$mwRW*TgB(aD$_KBzx^>fgCRr>p4v;orYfm-r<5- z>cVrvAdEm*4LpaES-nOZq$fdsFss*OPTCLGjBLEzBB|QwC=X+=nFWax{Jf~c>qfK@ z(=8|D`I)X@N^1b8Oz<2LK#uUF5l(9i3-1lD$ciy`l?j8DmWjr`Yxp$mn zD4X$s)a6@?knRqa7k0Sk47;rte+Qcnw?Vj?ukCu8uRnZXzc`CTKaq?L6 zu5s9~O`N}+21*;tqy^veY=UP1l*>Tzu$%@AJ9(F|yS%3eT7UnY*5AB|zA}82mf>M2 zzhwr0z5lkdf0)W00GUOG;NNGmMJnfibtlO06n0L8{LQ-lfb@4>@5X&d!QL-)<64g& ziM?h*JmYA6RrZSO+^-O_YqW2?4)I2bYcbw~?6n!A5l{16fWkHz}Vr6}6tyEcuU-k@@#s2lf;IcadWLHM{WRFr=BgpTq_78aDq3jEAIIH&O5{7%iX5qQ~ z3F%F^1_!^v+ynVh#T6eJC{J*o4%1id^p&Y?s!}eZf6gqEmdjcwlLy(qRG4vH1B6>3KW9aed7EeG8f{ZZ#piwAn1?qkUCW-iSbB=gyGG|9Kc{2V zS*N&pU%<2O1n!T|BNd0}IQ92XNB>68ErfBGI~w}Gr=kBFOxIlCxrMmqqO60PcOqp@ zJ?emG7*@jbS%5N)>i1sj=2P4Utz)ETpzr&Yy}s~0!(iu0U%#L1PWAQA$?nQubBz~! zy%pG3S9WlX4*7SYyYd*jzIfYt?&$`jZ9qNFCoP*kM(lspcs8-tk$O z71G~TTR)jhM6$eUYwP4Z^&0=lWR%5gHa@*Khe=OqTj{-7%(b(hdSCb375@C6$r-k9 z2zEW{-94*=$M0Mbep2A?)v)b-#NYorhz@eSPii%#1xZ&9D`+((3>3Dz)f7EY*pFLH5d(!qw3==~J;v`|w)Okl zm3+QDFP{-(mB&0oVLX;ZDvZ#k%PwR~&dc7I+3V~9?;4|#Fh+9z{?7EVA6-M!0Iqe&wc3w65I0hsz7qPNA=rOr zIPJc?PINEv{Z4S#`hF+5t2A5JDwHcdyWX7rz}UKs&~JoeSx#=9M+U z@cg`&`GZ$+jstA5^I(geAIR1n6{bA9H-wX2`Qh?2|DNQMs9XccnD0Z*K;B{> zjp36EGLE{Xr~3^-CNkV-Z>pSrdo$(%MOG;C$$m~{OF*tK>>gx4%=O9sh00C<*}n#n z_1Q(L?DRCB?0ZypB*=bpFT5EkKG_pgHUVVMhuwQV9j0;-Ab0vcF%nNK=ZabRr0%pnxDCQX-(B(t8O>q=O(>KtM{Uq9RRNsG&+H z6e0A0bSZ&A64L*9-H;e@o=kmAR<-)){Wl@-%7sK*UkB=_C=+ z=iX3kzu&4u)ng#c?6?9oA1rS8iKB>CX#VA=g%7GaT9;Q<_S8~eObJiEp?R(PIzF;A zzgeOB5iKf1INgT7o2Xj>UHfThyY%40L!*Ee@V#U$^zsBQmm7Yv~xK}+Eewf-8#MQ;r1m7@zIrL#rG#jO79uTz4(U% zvmbg|7Z+6M{XVbwij$aO`If*8KCSUkysS%n3o$C-LB~xqbxuQ6y>pH`_{zpiz0v!p zzPWrylbGbpWrdZG*HFEFc>^lr(`1$n68a#=J>;BhC0J^CfOOftt53BBURUd&giDcE zTyC4{sKf8O>s<A0_M>v^l!cJ_D z*yLKa8TcjFilCkpfD_Ldw0I?cANkyULpIM*<51@B_hWhHvytQ7xg=`8{5NA9AVE>y zgzoJCnmX|}?D7LUV1AzD89d&e@*qeVvoP|{yk7tvwyzUF7Bk>m=z7$h75;iN>lH{B z$I}fz;Xk1dy|Djn!;wPlJ#`ewX+(Z4YXnO zEZZ-4%`aC>j!x}n_qbon9w%IognXMb_4COI3!PmIpHJny*#F*}xdKH#-S~+1VWuQQ zz|N81)0{0*{fpKbcLpuIwfbB=*mb&+?tMD#dbTC?EbnU6=je9YQWkjF!o6uFAyCty zR$=3(%;4_HjacNZw>A#hk3&uA>yZ>gpfdC9){bh2Zh1d0%s?1+wfh4R6}6l6B1hwG zfS3jG7Q~cPciw;DZr})M^}B91e%1H2=e99?(-Q2>5^@E47zM~cZtcH|xN=4o+u#1{ z(aPq2#I=_J>dOG)iTn`+fCbf}kbAp0Yogn!h2YJu$w@Qk*gIQw1U}RlnYJ_h7(Q~_ ziT}f&ylaqwwyQd_TSZ84$Q^O9t|0YLq`ilsVso%#f()*yd|SP>+5UAJP4HiyDuzsu zJ0EhgGwFCWuReS6?TwFrJ!3HwOCf{Wg#twP>7&a4Bu6A<|Nq=!il!RL1P|GA^({N4#+s=Hm6wnYAPn9t}Eng7*>0Y7Mm4ZDfYW^;fha3=vEnq&sOA78~*U->YW$tjeAkm#nf#!Ha zypD1hWTjELply*L?veQWrs2R$?6rpcydF~Z36;;=F!IO=7lOUfM~$#njPQ#J^e9#r z+m6r7%gOiHI+znG*xtJ-{(9I`p#~G5x0Z&*yY2$^m!P@>(nW*n7XBzb!x{@I#Lh zBqC8!$Lou!u;#*xi@IF`S;Y5>;kXb-zZ&dF08+C zuY@{9EB$KGmtdHmU4A8SwzCg=Q`i{glQ2Yq-Z@%@c_(DiqvH@WTo?W-ihJF2KvYSF z9RNfh3<};XzG-`lTe=6bMgH5C`7SeM(*(^6*E2MCXu5YUa6HmXXS0|ixAS(X@2GsI zfSMyqD|dbd@6iSJaHA-vNBOq39@9^$c`6?e@4D57PM};MS2u+@w9cInThZ@jA64ZI ze84L9NxzqWbc|i=YgEds{1uB)KjF>Hb6WoS(rDv^7091!9_xYTLsxyy@3|xEmt#DX z(jIxvbDIwd)}%#Z)?)}I^nT|=o~f@f{nPBnH=^TD?78?}dFJ?80?4BAsY9?`hI{0M zL)X&5H?HWz5Acpp@8KO*%u=S$=U^qCS$WZiTGC{a|El|b1d~{qB(>`9dlu(TIXKV! zpo>!r)x@bG&X7b}PBBTGBtm6pwsGN7ng2b;Qn?pXsN7Iy5A`Ze?Kq4%T%1WLrf9D| z;O|seB9%K>8W;_b1V)4IKu;n6!_CIzzQyFef!jDf&}M26U=ooz1k2=PyrFW_Pcu1G znOeC>z2pNJb8Mv)v*#_d=R8Ryf;m2dk0dgmO68u?Wv4bmFP>w9y~_lfa*7F7qupLO z=QQG7$CdU{5gl${p=h-au!|FPCT>9{qH|2^b#Q7L5+o5FCMq2Pl88eTbtQ|y6sN>A z5v313mBBP*gGoY{n8Z}?@gBzdUjcZgrE5&O!UTDNN#RW5W7eHp1R_%cERM?UeTSa-fMN_i)y(YpkLN4@;}K>Z8OijBC=<~I(&2w?(2Hj_0GJNY zW4Z{y6qU;4mSBo%evf?fiot9sV-k|-5TqgW6y`RQm?e?P5QI5NH~30p8+l&E>w6}; z=mc6tdfe2Gd4b$KaCT=hj36SaH21y40 zR~l3A0<+@|lhFTh2($hlG{|>UZoPL@tN%}DB>XSVKmw2#%J?GT$9@`14^D}m$WRgf zou_(}JFeP&=)Vq;hU)PxO>m2!LL_x$^`TV%caK!M{s#|&n>k%LnA3$hiSwAV#fdpp zonolm75dP;|Jw4O*}%M$eED#SnK2H1vH$;Hv}kRmG$*-Mi|~RYgRR(ct`Z^u?(}~p z%`;GD5O1Y$9UkS0XOE&Krj|(j##SwHf1UZc{vg-z70=eBzQL|XaV0byhV`gh+ome3MZ$r`=7^^KkRd=UBd2K zEq2dGb++`_iPR1L$N+w>{#x-WU92K?aIk;4AEh$)CEK>*%Z*^qx{rn`p;S2m2wwP+~5(okQo)o`hD zL};h8+!(1=Zcw+iQNL+j zDXnSB@(r22J#~w3l$Cx0d}qP`xH`fpz%k`VKBPM0tjp?8hf}~W|2|tQB468-OKB#d zuYo4#4gLRlrkj5GA^K0jE9r!8eMHc?)>V}CF&~JtU!6<1E@-tjd64xF`LyEr2}<*CCH5a; zSFI00KWCe$fc*nILP{fL#TxXnB06I-F*oBs`Wanqp%-i>V+@uQJ35tZieim_! z@#tNIwwJ~a#;bs55niNHi?AT5%Z9$=8z8Bmofm1tFusAC-RRYONPo2k&&MeY4!Fpa zr04u?Fh?}gGpWz+I^|;S&n3Mp$yejt5&WXV!bTXo~L1241Try2Ml3xVUQxG@&i`y`{}@>w>}fy=mA?E7$(Mh zPu|E|>00ln-|feC)yJh1EH_r!Sj7Hc z_&r~_xOqCenwZ^J3h>#1fE^z9P#7~OlbM^J>zpAOxHnxN>9M z_}u~&Vpm&E_V>>hm*E_h$4Dwt2Ao2)iu)D!WD+VwgetUlWxk2y1RVjR0EIQ*dCqL$ z;rUx3`+^H1qeC}~sC;b=SF{h|iw~9NLSwj;D%@hOIu#lOH?#Nq;nj@i%XvJ#!Ls^+ z^}Zm4%Yfo_wV99Uc~@(@T4kO;`&br{9UdDC5Z1XA(f+X1{RJ1#6A7-)O+UUEbtv@` z{+%Y6>Xvuw8ifpR_?!nmhFfmjhyX2~+Q1r`TWXD0`Ui?4eh8*4uR&9e{!XRT)>lgF zP9{!ij~(W0%I0iZrns$b&7)LXW)t&-ckY=76rME;Y~ssZGu+S`i!}>0N|2^S@wJX5 zE^Zq7_!X+34J=;yt(EScBTc*4A`J;`k#1p^xMh%YKNZ1|^@vhMQTJ^;qIiGif?LMuSus!KUOvhhCQ`~j~y8g*f_=nq$^%E1tMHyREE|b@3jfFw+B51tyOHh!t~!oBJSlk- zJ*XsMUO@Np#be*#YKMrw8qky4&Za-p2d&#O24K+AxO*WjFBIgQ5w8f~ z>~zguqfcy{>6~Hlsn+?~#uMbcBi`70r{g8(@92k6rS_V&8IGrEg<_qX z0IXn)VvmU={I5^E9&O31q$+)rE!P?ByC%Wek+L$|5N1AYa50ycgXD0QWP5L|ao&L$ zj?H3vF6rzn8fblb;(JT#vnBFXtG6o|V{!N2H=H%096PW;j^^e#{d8364I}4clFD0q z2+!~g!4*majngkel@4>&`I-5R-vn|w{9)#$oU4yGA@Wp?8xbAhB`R^p;5+Z#4B8;_ zEkP2S#SY2tui*`Don}$Pa+8(0Dx%~U3=uozd{Wfa)!9<}5dF=;x?V0}A6?bO#jjL_ zfH9{N{-j|?zpfs5^4r%M;X><}&vK02G8tr&a*U!;A>#sO7<=+{vh>-N;f!NS zAu{93WyocM`aksYqY{<)P039LJSnYEcVN?V2nNPH;+#v&OCUL`sW0m7ydgZavx8$#SL_)xg4bV_-$`G)-60EKwzK#0dOAA3XgVZ5>E;*i3!-4+W@d50$bN_i=4H z-TAbFp_LDrl=-D+Ga|_DTJaO0RfJRuIKV{f)Y9%cZ-fXEGXFJYI7$Qsn_xK_=bLSv zv8kz&$3>Ei5G!binLrHuR{s&WN}bSF$xnDhUEf3i{9n)L%3!SR(nmV zb*{Fa*^(v>Y#~``(|TD@7P7~gQkVi(pZ*dhS155O0!~~#_P7meAF~?e!913ai8WMy zvve(r{%aUeSpZS@-?fe<9Biydt1hY6o2(OA&pSo8oh>=KGk$TK!g0#%WG|LgtBpg} zC!ho5Dp9!`D>a0|?ArcK`HkV%e?n-Q*VB`KiDddL<=9;lk1jO6xY5O_GBT~WjlJG? zsEC!18SeHu|J&o7I^11E650HmZu=f14PGUPiL7hnVf3kh#q=&v3@!h5Sb06^{1$R! zpqYynlCzB@Wy0e84^h#GM4fLu1Fs@l&rtG*ZHveAR{=eze)vm;o68dC2h9xYZu8B~O&3ZGZIAtW$e z2UIpn#~`H!ZLb}tVy+!(r;=VnJnZxeRm54f`wr2UhX8ucnG)YxaRs1(`>-m!KDf`7 zM;3HJ797g2^#(lX4P&;ed`a{egIcsOlEdNyDW~<9XmVbk4X4p#IIT*VlWi5(a9w-0W2A$(4aa>bJmn{&iQ78ABo# z3Jdw<3A8bbZ>=_xFMdR&yvG>8G?Bf|l`(!>z0X=L2gW9!V5wbOYFtOQ$H65CVE^&8 zQ^*S`n20pk6DFzS$*)_5m zDo(fWz#|%W#g`Ta_-!l94AR;Ys)s7K9n2qvLL8@T&7&~9DHz%kk)^lt;^8n~sww$t zt-5NPO@!1t^)Wp|npt6+$y)~7EHKU#9>*fRo*gd-IeXOj*CY0&(61x=vAi0=fy2S; z54u0u$i-eR1V=JNFEYyIe8g;>qzAHx)`(iaF_b=^xQBVFqn{zyP(C%Ic@%TGSZ6~Z zHys;5CBD%c!DC*n3Wa9pVD_c-H`9~{E0r&3QS)0C$rnzTwx#b5y9^QU6aegb7XV}$ zt;2)@9X&B*E2K$kx+QYBe8mn}D#ZXIa1ZWKKKreaxQ?cG{YlZ!K!g_SSIVfaO=M$E z>-#v8wtu+}Oz8w;`y%7yQbW=TMqpR_!wPWgQ7`^aq;>%afy*e8o?xWoF|0r1%a<^7 zUm5Mdw<{ZGp=b??uk4X=$0IulrQhL-KIxtS*vT%+6K%UTQ2Q}7-|&Yr7ht@K#x;|- zm`Gme>^HvpuV@8ViQrnv7WGjsU@&jnecf?Nz?SW*b_Gf=k`6pggJr*yI5pd>yGm;^ zd&$`EUBe&v36Ce`z2AKW+&5oc4ZFIT}b)V}9H3_NGe8O(1ZfzD{Z+ zsYY=uy1nxK3Y>$?*$d-6j`yd=ySUs8AQ%MXUcu+B;Gi4geG@$1dSG$9^mL zKlMI8+OScg$0uN-l8}76B)JC0A;$n_T`g86X5C!+H`X=M#92FCnFr^xJ^;K>n_jeW zTU^%f@S*)GQB1upB8wWZqIl}a(IE^PLx*}b&%@Nl=r?m(h`nU2fEm4WtO}T&)~RL) z>J9&TC-x^MUW)NliNUk#{uhTjwE!PvSjkebtnA>x{GUh{6}V#FdtzDJ+ivpr2lCtn zR41b_u-}U0;;Yv1*Y!m->(~Q{Kl)1MZXU*9iqH}9(18RQ1-Xua>Ty&~nqBW4MdhMk zc^a{1;~9itQsOBw9HXLV!7$I?tVgu7<8=9=jdyX>%Cy}zVL4{vK!sgOuFR-}z(q#GGZ;FO;aE?7 zKbD?a4J|&XK1;*5%g3r@52&*p>fc05%#4~R&L0b>62jtFKyTu}>W1=qD7P)+`AAZ{ zDMoyaab|nwyaJqaTm9sE{PQ_#-rh@wQ+&&V!;Cp1pw^Z}7SlSW_q}%&ukZ0wR$W(d zSp6zZlKUbgc3zK7HlH>%NO&ngUU46xxA5&^Vpl4oBfk2R!&tA(+OC^WhLiQs`aEBU z-Vt>)_L-_T_X^Ao5t5j`=#v+1TZs}13h); z0V^`62o915*yJOMD(tca`PwGDCZ`R|f+N^hJx3-{?C${+v*9pXu1?U}FTL!P1K$|? zN=6LXaczoYJLhOkT^Q9i9}eX^m6w$0gbB z3O$36uQxDef~tz3t*66*Xi(}xDp@IQt|z#?K$dKdyJWYL&nt&rG3peR_<3-+-itQO3gS;@UKO z%0W<8r!Zql{&D(Jzwb22TuSd<{C8n&D{bvq+2!xi`f8X28JV)p$lXM~u3)6N(s>s@ z4dq-GZKQs7jTlz= zd?dn!;RX{nSxe?6?iOQYfEbN8-xUQw_FLv* z$O1;3FJVcCJ0~bdSDh0VkzT|2@u6oUUVwnpjg4u57VsD(;_28Dex>s?sUzd(Z!I(L;YI~^XTIN)&&l7X zjDf(+yO{dtJ?6yx;a-nL3{XcIg)aL5yOzOdP1UNvQk@wOPp84nt=pYiBZf^k?;U6t zz={xbu`{#XVWzhk~e;Md~6*TSpF>d-hGd>xBW7qVkNE9<~52t z4Vk!n_>q}wKnA_J=2DvP zA+f_L%3@?UBS=E=G^pak+KT%=cJKf`_WL000Qm$v5}_}@ms)eY`wV_n_ey1kn}Obx z{}ya0!~v)hT@WXV9pqu07J{+O`K<1I0I9b9YNU|?&p;BDX?gCysOU`O`WP(zEaR6{QemTz!eq6sR|O515#%v{o;7ndoG`^_rQwl?!u z@YEmA5P|_Tk|cL;R4(rD?Z`{ab0?=upo6&=&>pkHwOi9dUGVf>_~6R~pU;XXxyD}i z**3o>wHRF6#P2=_-+M~m`O@Nu_w@7H6#(A#6k<$4&Me3@M4ty$LiV`^=mSm>#N2x^)4kU9Q*!v$C zyHZ;Q`;Fyy(7g!!Uvc5Tz{(HAQrUcHYvU2;rZ&tBqsws@qWAc;;IGn_XGpK*bxgxo zm$vYSQyZyKAeG+Ao9y}vAbI{zy0CZU865qTGOfoy)5?(QJGwMzn68$$*7~;k8cpx! zdf6`eGNfz04D6gBua~x)+Lp zdc(~{kYif#X_M>5lzir1WZdT+HKsvQSrI|lNhBXi?X_J8di5w$87T=wEuN!Q#jQy2 z?P;`0bRW<6#KPGZir)RK^v_+dx&M-gB`WNo6hF>{iMF=nX=`@*wax>mZyWIlR@_A1 zC-~LA!L9;a_$4laD|`YMq0L8Ykk~%x>Gn}Nwho}~oj_`9LYY}Nh5Tuyqfr=Be;DWv zwQZh!RH>JVkw}LdhurS+Tv>0pAly`=XUW-i12YugCfZqU3%tWPj*!Yg3LW`K^#VD` z&Qq**FdcT9MIRo0{q#UP9!GjJDF+g^g`3|72TaXNPPt2X-v&Fa&02AV{ahk5PIhAkx3+B%TOm*N%>S?vNYJ0yq=b=p9UDJ9 zn4L=SV=faAT&!y(l4>m@g|%d#=iNrl6OEQ`U3j?4nXSdo=sU<#?Y)h1V<-hd&1(V> zP*yn7Ptub<3CGKou!@K0V<_qahdun$0m^U2o3kFM$seN!u4gFCJ{7VjYg1mY!}lvy6wxzej#5W)?hG?H3LxmEF+ zq;Wtz{aQNFs;Nl&1~p6~nEquDYpU{P&Z=I$H=kOW{FpO7LWV4n20M+0J^e$JuV{On ztQ@4QNrT9tR4a5uRaB|9kESF9M$I95pNNWtb~<&b*b{XW({ee0BoG`488`q7ko~8$ z!eFi!j{+jT>41`Rqey|D#@paeQVyD8YLH*`B}UfPS8P=(V2fo{gA;~BSebdc<*BJT z8Ujyf%``{(uc^AUyJIJ%$4^@6QR?hU=47Q%z!ri zP8bq-?f|5X|1+oDHrJJN|9 zlW?X6317y7xbK8TA#e@`UCPTN07xV$Xgn8ptk~NQAHtSCFr+!9?-qc&#E40$g)Q3o zz^RYAXtA@|R^>b?W8|e9?D#2%FugXeC^0+vO50hHe2Iqs<%N%S;DMDOS^h!aJxCnZ z8N`WhMMLB^At0-!P-;yVP)xr!?aIhymvGp-HVH9 zbpF)~^uzOv3rRt0u*Imq3_2Z){eQeoU|u}v9;$z#CS3$s*BUQRaLXu=$;MZo&l#Lp zG^-sqlZbYuI1MGu)I5oM)l&V9aJJLGG+}u32aCL$8ca|wROo&&l82YO&DDZkrih=e zM}PZL(nTvGh~ z$d}%SFg$u%_-eh(lKef*r1}@H!SmYy!-RdjfYbm!aoBpkf&I@_%(r~0`iVbhKb&-P zF+BaY@K)!M%dx=ZE-5q<=@alrsxYk3CshG^>v3P$q5`O6(%Pjm{~osDEmSW^r??B4q9{FVC-nVbRoEe|WRWX-tkMk^=MbFz( zSG#n4GIMx$d-!<+N?NGmho1(%xr9}C@`=>G8E|;(ba$OsA#J$f+aFttOGYxR3Py^i zYgmb`;goyw;3Th=o)J@{BH_-b>AQS+@(BdgIo6uou>CGlya|m5 zQ^>YK*{Ly{m5T~f+%C@#tge7tdlDAY748r9m=-+#_L%(R^l#IWw=tQjw@_t!c!8Gc zCdH_mPo?zm?Jy%3=!B80wP2o9dgfg@9A86Czg1O;ih>-Zt=krtbZC286yo(T?gyCk zcCb5PF7GN{*)sU*`LcQY4#Ss`afugZ(Q~$P2LdYWX#+++hWWhFc}P0?yP%m}qLAyZ zM1h68KwApj;XpQ#NE8CT`c<2aXq7vyJ3Y;3^4Nt-?yE(mz3k_tt-y8PfISk=kdJayH|ZSP2tMNlBfY&yBGy|o?^t= z#-vjF&oiQDZOi8JvXs7A=)k_7kYcC5wYbkYoV~_{5AiV6{PM~$(1Lf(`mu;qn}u{} z$a$f=Z>3HdS&Ck=2;{ZmR*=Ws`k9!W=xP(`eOvuU@ra^jAgB8-R{HH*%bzW<(eh`1 zpSZt@EiMdKtK-`OI{qBd>*y^FZOR147E? z@QWqXs(e(CE$*5e|5SPQ&py`1aNGEvhh{qea}S&M zhvzTPupKr-v^gI~7V?(Z^RLk@ttg*r$qBBb61o!)<<8U67XPMAv~Jp@6M3zT!OpJ? zq7@EsXAa#K_CTGrieU3BLzac9`1G%h#q2b@n-M3o!}&@ZOg`UXpX9i9VHrEcdJd%LA4|CstK zp`mi1a{PPDOHI(4-UY59!Dmt^g&qQCI`10TJTdp-c!x|^HL9elrp*mu=R4zT^EaK# zRxaJ>2@CP^yp%@0ZP$oBkgMUso+e^rd8(t8v*FC%Sq*|2%}gVB>JWmU`5G@z+sP21 z+wvL&@o}^-=270p5;TS)um?aOm*{cLzgY#wPd5ov$jkwwx!$^T`$`0mO1&Omi&gp&eB3MxKr1ypSJHFuvenggU&C*q0lCQ;jr?l_k za$b!6Y2pnBO^1{7hrXT?I|7JzzS+z5oz)>Hg4?Y-TABNS=i_~MGS_8&OPAWBwAn(= z?a3mNe32crb3~ze>#wttyUdt@AjD^1uchgbQ(El{X3_;*Rf_YDzL2G;J|MfsX(eW6 zP=vGZ2Vd!>yInnSS{{wFBa1HrWUA zI)b$awpQJ(L`K1vKuQt{39@?>^fL6T0&HPq7!FG$CAt(EuX()phwh!aEfJvY0!5sxE)3--!2>9&$UsmJ|d>csLo`QMwK*_+byA6 z(fK_K*Td_=2zAX58XidC`{87B?GeiUQj}`j8oH^r9{_86ag_SCSG!*Kz=MvigTcEqYdu~EQhF+~;N*URM+962_1Qfo4py~T_}5zK z>O{?Ybo{NWc0tF(g=cf~zAUcCZ?t9AcLWv|e*J1cdG0U-A>sG@pOEBl<{1N(>t2#At9pl^J&BjX7;p2lzbBjnoU-b7FM6L)SzrJh%S5z#6NW1w;0*l3tib zT9Mft{S{2-cn@F&)T8bw9%cjo+V8h>3<7K;)Lt#{Ok}pty6M%`ElsY)ZvX_=slK&t zU4J_fd`Q`y3qc2PIPcW!fMY8(_&5hq^e@jeDG4-9m4i=<7aUm&t#`+l+JJ zi}O_}hgtwkw(s7i>@A>&Z~TP)HXYJU*#iS&%nnk&MPu}0cIQUD+U-F!-O`;IWr7Nl zwm+y_S4u_A0=ymfcS$xJ`lS&FHOj7Suakz#kG0H`*$nC%5+f0D^(wjCc>-eoSjTTO zrL#z8AIOdzJguma-Kb1{j{aj+S&zX`V$U58&@#|5Ykyip!sey#c>3ktrhcz%$qWfS zbl=60{#H81FEiAs@EF)e&QFN>Z^z8To*L6D74u^*I)~XL7sg>-Hqe5!D~t>Fde!SD zfPo(2DjMy5G@}BOc%XRCUK_5+WWDjjfdr2ZuO`RTrqOGMv76TdMrfizVl6__RM(iw zM(VTCt=ZYg(#o8~6`FF2a7NK=tG`#(dS}Ovo9?AT=?l5;ZP!rGMl_aOQ(!ZJJ8iKc#VUtidMf}R&O?OI!+$cJD4Py0T0(H4)weY?->zwpb` z&+(?fvLJIS09sW^=pDz+$rCWRW6SGDH#=K-o#OV-NUORbMfV@P+>5P!BW4%fip{mi zoA!2fY+oH9`Ot3FOh$?H5Fv5L+h&sw`e;Rz3?B?}jn;QyC9Nr z4`huC%B|=FbGxq@nrHCjLMPcX`QfH+zO`5Xm)f2oM!>N|8!%{kWGod*>j|jVr9+Xl zz4U#b17~dR9VeX#uoo_+lCxAMr9$KUVf*W2W$GNF&0c7Ay_7M_hzIr zm#8|q1PSebUE^GhTe7VzA5#(w@eUpf*asdux!6N0v3V_-5l!6;hsjUw+=3J8_E%a8 zJo#wQTjNe&C=ZD{d?AiMlKMr~#2D?_ixNs&Pj1y;E!pc{Z!A~_ULDcTsq4XX3kq>R ztcM=mdy%Cwy>n>0-NT39kX;pjmnw2(Tk}w?Ev&z`HECw{5z@=?XmlrkbLhZ4I0i#P zmfc(iJh2rxo~+N&yNe6Ef>A^#q5&!e z);3?q-coUCM1EmiX-vS{79~pw2SHa8-+rWwBX?W32cfNptY!{(k5dk_D0||84=Jl2 z@E``qjeuD9BH3Ga)SQz05zs!&CgJ=KF2?b-3e3OXcHAFR1D4 zR8kFm(Uy30!Dlo`M)aS!DCr+9 z+>1cp=i-~x%q$-v76>D6!3*-Iss(2Y?K?7>jW{mxNqKl&-Nax6D(w~{u>D_BhBrR{ z`y%amtJ5Y3Li&+VJ{L#ssN^ALJ2hVK#6De8FU>?V1an|W4cblGSLG_QW=>sT^ zNy55rv~?@+x}ao)4cgs)#9mrE3D4@v> zVj+-U*csWnvDUi~Jp1_F4Z|sKjN0#Q|3O&8Du7%LdKzBwfs{&mLB;^rAaibOv8m!Y zVhBjzr?5e??nxN1vQ$ctH9jJsZ8BOdeS(c`F$;ufM%zPw*MG{wNW~1_2YEPJSwO0+ zXS>h4p_D*~(kTu6=Xo2Dp*K1Bch?Eo#W1g_h3ft0*3iza@5Vl1xLm8S>grqy zoCt2g{Q8ohtn9>AS=KC0t}gUM<-paCfbcD}cErWM3@(QDdM%i|c%sSbAb&WrZ0_Zg z1);oK%J)Ne2z<*Y29?A_B!tI3UmecvS(J}SUA+$}uph7CwcF7<>Z<>~Xs+zU`^Ayh zx@E8TTn|i6P(Vl-5x`moE_scX&?h@K%BDcCtEwEn3us)ZaoW3xP~OFJE|1Qy%i3iA zBBUcJd%KES54QtDGEhKo@#Tf}$T{}~wK|2GNo{Q~hOfD4B^OlZCEDci*t=w_$e07| z-E`gM-Y%bC8HU@6asBoBm+jeGYaxLr=8Tl)GpLM?w*X#FW&Q#LycrQPjVOipcs&bna0KM9@udAjQ%$qj{d5!46I{p-xH#H0oXwLG~rHI+1&ks+%@YD^Docd=T&>S*34@p}| z`9|Y3$T_jpF~a9AUyKjp+-s04#;;sde&sb>L3$iaxQ43ceC0S)5r2B-Lgubo@%Ym* z!uMsNW#U=;5G!&6f?CQ2D4mQu`cEf7YyWKDROxyG zS(ZC;7`F4eb639H-+n#!qLXG6TZ`sfli8J@uF(EAoH8}Xn_jWTKLWIPV&E5$^Y<6= zM!Z!UOdEfak~6Ab4=Swn(%}P*^1WZbIa2v~oW9&0y({Bz!fB1VS3vcoB<(B+*T;7e zel@WL`R-8p=A@rB2bF5%aV$@ZOgQZCChr?8IUvr@!k@4cug-HKHqyrForAZpDJuJN z)!oraU)CLsUz%3Cs!L5d0gNt-q@@3KlBTBZ-dELZr>U8FVIp@;B_U#a<=xp3?RDpQ zW1fXC<1cf@+l*>Iwhpehgd{4C^!t3WI)2VvX@+*o6=SnNmL`IqBQU8y3) z3KC%1#*LV}L$B@6{dnIt z2o&(vnnN0042E6@q~uEEa1q%jl5Wu+QhsblW`Fktof+5j=jslN!f92!C$O8>Q2EEj zFE>v&WJ#=%p%odm0M9wF!)=`zb=GO8f36Pm`VI?u!LPfXyj910* zvf7>wN!*RJ`aTj!&sx*1oIGPH2xFp#EaU*bnL&(NetOT3_m{#w>;F%ZB8y%}MBSMPwXm zU%iLohMw?dfu7kZOCz*+p`ZUluI-z0y?nz-+;427Id}6zjI_h* zLi{+01Ef3;OQ9&g8RQ?A{7{cTW)H3a<}qg)c$Py{MQ_XN!ky9K&jQ?R<%>6N6MeU? z@c!D6c)C>nSdftOm1WwxBQIauNt0hz)GxVv+x-XQv0Dojr2avU#6p47sgS$^%fIQ@i%p z(QwQBBka^zkvzR@6&bmyo2JBdP$9Q#4%-z1FZP)|eBk$Q(6xI*)!>~~)AjwP-<~?k z2Z;E6jWu-gj4cB6JuOf9Y@iA8Vgay|Zg~%-KXa4!!1d%-daaH)JPl-uFlxQ+=TvwUy&RG^^M81I>!`NAt!=cF z;_g=5ix(?i+zD0)#ex*q;$9$Fad&r0AwWy<5C~S>wYW=hD1P(n`<*l1bMLwTet#rm zWRJD?v*vu}Gxr`Vd(Bx}a1E31lxHv9@ptZyjmX9pe#icxH^N}Gfou)A0kTW%9IP>; z-xCxH8xM)G{LYi0{hk}NzpGGNfAG93(3WnADBW~J>t#)vF!|FmRg81%t;@BS0DYj# zhSnq0DEukIWUDtQjq0gCyWhf`XkEab$^R>nL_{mVg+OXC%4A2*5dHku=CwcL){Uu& z*y@}gtFze60hN-4BV7!%BkZ7SoVF=SU#_Vk%AcyG#qC<59CDpw=ai;{oPBI1%VfdyYQp1BN=rh*StKX0M*Yb#} z%GbNKlu;{>34v_|{qU=>`KO>WbN>ZJ^SLjDn8PdFGIunxxsz0Wo-Y(ym?|e-$ z^7lNMx=PkLq;lDE&)-hrHl@#fGM{BHhwK<_j^bj}^Xn?REar60QPc*elz~5xcbk)X_Q`UJ$;h-Ujt4Rk;n=3e$&uhg9-&Y1rbQIq==!_mO+G)rBL{YG+kQ z^gFZI@s;F`FyXbN%AUAI4%MPi_&u$spnfM$jH+;a>uBf0vm$%rlZRdp&ydVBTQuy# zyI1RO7)}^HgYx8e#_{5q*u_;+!&F$?x5FfwNkN=>u=dk=nh_$o5 zCS}C5QBSE{9Zxe$zsa}G?ek#{tKY08V$Xk9dvVGL!9q-9h6~ z0YqAAh*rJvvsa_F!3_-E=+I`*RXr?78DXgBfXgr5e6;Uzj3oPcJ0lORziwFxdvc+> z3*ULQbRFJ`6Bi&Gnd^M$w5V@+5J=_|Yw2%rLp))Cco5=GJaokHOshj9OV4#43Vx#0 z3))6nC`kxZTW-(n~NXm8o zup>?T@x5xpF-2SV_?_|8IZheYr}jwNJ5;llboY)V;a95 zO6}7b`A#IgyKb&L4=Q#Y+}#;DHM9k)LX`DMv82YIm7T|8yMnZm`Z9*x$9Y(Y*gbUk z&#hLUTw&71>kNu^7ikW~A1op7iCz+y{@DL+AVL~CE+Q^Ktvua4vaAIwGObrmR_)=p zVmZKODVF5#i}?hPf)!Smh+7!j1$K-$e6zH!^FZHI3K2#5nncNSNO}d!hLp5%kPRh%qv;djF!Ufvpt-tS1ex2t-{Z1LU=9|nuSYREs z3Tu<`;9HOuU5dljVujmvyZifupvB9D5EvL#@AZEEplRRb&R-9IEmUX;gH?wK?4jDn z_3UOqg@7Z1#LY#!zTIlM^p0iR^7&35L1f}@%ah;iYpPppI26kOyFYU3xLu@KD&`4K znbOU^h&M3PVG+EhWtd?qp{g}AtMSqZ(K z{Bl4$<9)K&26T(Swr&^8DXzz+_}$sK<%e>^$%fY`dP*$fY@l!npQ>?{YtaU-_2F%@ z^3le)`d$8*s67e=bldP7Xai-c(?Fvg?5AbSfSSz!Pmnb4>2 zu_K>8Gnpyq+M9=Vmzt!=C5y@>mt_`KTC1Z(K;Vo=wgi=uPYy9_3O8g;<|En*nPgKt z4uV-~vFS_9_G!A@UIELY0R`=fQfoE^`P7LW!UmoIli1~ z-q#dznMUt}W;e{5A5b_vHGGg!f@oOxau!&$7Y+)+Y6K6y+VZg>V~xfF+;e@3S^SV= zLPdIW#$#XjJ7zg}h!OWl4!()upZ4sgIS$llNfDF5?e_ZX z9*e8>v-bWVc!3 zZjY+)<~_+fY}Ye51H5SV!)C@R?Dn&*y+!@YsCg#b*KBu*x?+9}H5EGpC8c?+i|qAN z=N}j{y0)Q*A!?x-)v0a3lwyMzpS=Wj`!tRG5Q36WTT~wbO8zZ4_k?9M2fkIY0X8gj zUtY=aO-5P4OY)_blII*$iZ$7)4rJ}=a zF5@Yr77BVb5>{5lHA#>whmgfg?Oz1OehEL7W+}r-4<8H4n)1F@= zD_7aKAaR&RrT5R{aM-H=L)D$)LC%7qB8o;?b4t zw;&-W9U}Me8HcJJ?foL1q7RRDz<2t<&wCJ1uJ$r?-+GN&+nzIB$3X^aHRnW(+CvMrkm~uDY|XMp{)?LlkkA2_o~wm=#3`Ld zhyle%u@!e2Wry)=PDxHpje?%txEOlSuUrr+pIUswz_GW=B?vuGi&r%+ZvrH@Q#o5l&dq8V!6wkY&+vUAV|+vI-66Nw51lWv-zxXP-nlFj zj+Y87Q_h)Mr>9YuA#>t{wVBVOBF(=B(?rF^fBelon<$Wo5yhvrI{p(wlfUH;m<=s= z9Bz5?_LR_>!L~n0Df%(XeIttZC!?TIqBErFcao~!h0=Ir8chWYX$CcIjXi5jLJOY; zzqYWXMrhkqu|nOvrDaUox471dh20FTs2YYQd`7>F^Mvn+PCQDAVRF*)V>Ul*6?@|+ zSrlZZ6xCq;kujm0jKAiT9IFR+f#@-xn1vgo#uaMhMW=7=*LlrEymKj(nMWu;WQ`F_UVi8nQq2Ee2%R*7?<@_qLTm41sk8zmwg`m?i%*1DcShm|_JllMk)y3v%%sL29_wb6 z;YC{lh!(7_ivphNQE2MsMZo!6Vn(}=sBKO>ZK-vcbvXt* zs*YZx)?1TsY<{sVk)Iy!r^+BbJG)Q-a*tB6`TA7si4BvAil`ht^Pq#foA20&V$AMq z#LJQlX)`uvuXuE(=6)AX7|6+HNlNI5f8XwitD(u;zp!dx&&@NdqcK+xLaxldDA4n* zTHK7p{jk16@5{Sf;^r?r#$BoIt1)bD&`|A3l5ixMC7l&X6<;{HxMO$idL-H}ZQLv> z#pWC>+&OGJIV(@>JEsp4Z1G;$sd~*K)Vxa>t9mTWmmU{S|Zc7e1b>dpXH4^_E+fMFu@OPV8|zN$xG~_Gyx8 z<%1OXAyk*xu`pw>bRnlqunsueKbVe*<&}fES77thKweA!%A z)mpRpxrqFCMLDZTv0_+%Au8v=6eaSy5Ha58kuXq#c>B zd9UweYJU|8m{s5B%g4?~?#0(^_g%;+B4}NU#WO=s6eq0&kXB73+E3_oP0mi;Y_{WO z3D{9`=SF*pR!^T}J2WX>$eJCKX0(S>_Ts|E+bS(3Gd1Wga^&5IeQTvBu|8r`{f~PC zgiNaCj1*Jx#p&yGex4>u0<9Ss@o%rno{L4~()+f==)_b1+#c#pEgvkJ0ql0odN3O^ zO}APCS7tq;rvO#oam#=;qk7D6A*NyJ8n{Tsaxgm~#a$+kr~q@`-1@nj{)?|8@{uNk z5!et225>PA^f+;^7>9|$YeO6GT?8DF0kd!5m=avHZEUs==l z!tq+HV4k5OmgClClt)#ZZ@|}$)PkE2o5kTz%JF!O&WLC z)6wuH@PpFZQGN|uA1+Y{=l4AJO<;fHU5xWs!Y==bvd7eb_g+$gC{^&)00`fDNmC6F zB`tdN_KJrOSWaD{Iu-2NPxsC5qh7d%Gk&bWO+l07J9QpV%U8Wf*R+LI3^DMwu|7$Q za;rih-x<{N{%xg$XsEYIs9-D~ZEQ-VaHRy3Vcf(%Y=3gF`pI}X1T9L2Igh5NBy4TJ z>Pt@8GIue?0zSY!>PCgXeLc;6xEz*_K4f(9hknZ z!KywlxP&YE&Nm8N?urXqg!k!wFY%C=^cDCh!RWE%a^7!)l~~S%hui3KXK0$I705Ko zFRpF<`c7O_KzyPZ*-XHSA7u+86suC+(^E{l^)H@Y;fCd)*MkdRp;B;JR7kp6I>+ZJPNUD5GMHe8mF1mLM28h zNigZ|VbuM*?w(SvVsGs?w~fSU>MyZOfrlQGQp#(DD2q}NpDnpWSf%M3l=nm3Mjld2NEr>mtI6t_-e)sNDZrq1=PQJ13 zp^7D~X#An7Y^HZB@Ll`T>Xx=*>}!cN?TG?vG!%{_cY1ky1WqeI`7L6vz9JZ}N0s=?xcb5myQ)CwO~4nb zLt;ET6vPlli6h9s=F*DBz)hPYYnOO7l+|}yr=d~Y$4A4kMU$pRsM+YO4W?d0<^1#% zoYJzm`SZ>>+sNlBS-mD4pwsf3C}J|jr_l>j+>F4_E_Hq`JX6u#--61DCQIp%CEGgI zc+`hv}lXZQmJ|WjIm^o*4zD2(x`}=FVYc{ue6jJeudFR9VQ~Mn3c+% zm()5B7__x25l*xqGVEqnOzs=p&Dt1FbESK;?Ol*?_{_|T^bY&g#_SZQQ^}N1xI=oi z`cwObd8N9#RX}&Cr@4eub!5cnTsPz)=b~oy%T1mwsWih^o*#*BgxdSHqjdI9)gNoO z94Wi0xNJTK*q+-cQ>}U<%WsKj+jFcE53LGHdQn7uB_lhQDklxK-QfDv@>iD+d+06w&_D*|V87KpbDJ^|4#OiP zB*&a_oX4z~0M%ouv-2^7K*PGG_THx!KZ?V;EzRV`>dN>SO4|l1&a}aY`LYK|&33Xk zyf0Sm70Ana{9Xv=a zKGSosPX0ViM;_LT-iR1>lzQ4SLM#MIF7|dMSk%2iG>8}m1d}p8eK@`tQoyp@KyA;4 zNp>78(ZRuBr8!q`#& zY!sNT-v7N_CEI+YjuLDX(CDa9=sX$`32o^Kj0%1@B#ks$v}74yf5H2y9l#Ey>$45& z!(WtJ-~0kFcfL_66>O-T*eo-wKK#Naq->V|q|tbnHQs+%47Z`Ski2ucR%5NrQ5)AO zSLN=XvIcEZ$pyb%j)V4xz;bfa^42awIr-FY=hJTMhKg8RVoGCwx)X@JOq{P}rQjMF zcRT2_+Gj#JGb1zc`hdPRZ&l|dm%ML121X_C3uN!yrk4y-d{WJ}DLR?!HsW=k1YTM+ zJ5-l7YU?a2`gI@i*RelezlfGVhTkV&s(C=JwW;=tP&e$H!L@Ac?_-&G3Dq3z?rhE3 zDGCv7ad0rc^eXf%i0>+7GbJf&SIF5!erDE|am-WNQZ^$jq<@vBgz}7Im;_TNGP3(zq9^6ATcu>;4~PA%l{n z(tjE9evho9g!XYl>HlDteb%k|H#Su&<1|OaeyZ%{&$?-jK0TfRRu#g2wj=<9ppfXy zwzTj9oAO+d(pBWt+%7O1?bp;(^XpS0K(w9>4JCwvP8gR@(xvom=B{ zgPuF2B;Vy*y73V;NA2oPf-KDFU zy7%>pw!^iaaO;HzJ#J;wu}}YsvCXOA}=q(Gs7j&7_LAU%tk-DvG9at=zf@BecEQx|hbcWlX5e;ufL5I+%IS6WLz&)0WHjEJ{y?p>!GILc>^0yVTY z)hu}oDCQnsPJgQC0QT+;7?HMlJwzHgdYfvTx-7@>qndhZ4ZY+i+`;Ml(2RwL#`0dj zbSvw1Q=rbw^?FO3Gk$u9xzVDKdewNAqY&kU&IHc$T!SAdA3jjVcrq;V(fFCdNd+c> zpgZF?%Y&c9zWgDV^~YhAzYX57Y<$RM%^kBk9^Ncx-N7omD2efFXaF~55HM0CI+znX zVS_JZW8}4hgV>7;#A(tkXCs!v|te{t8%29h0>mq9Q z+KjR=YD~?Bd5D#hiIahog_GV$38(^80BQiGfy#(I1`=xuUBh5Qj2}e58LAnU6ul!) z(AW67?7DQof?#X(QNlddYUVa2prUu?$*aN>o=d4h+CnL_XJ(3BZ)Hf#@Vb`40T`j^ z-RO+y#poO8g5W4{GDbOp2CEv28lwO~7REUS7DhCNDmocq8etki8j&8p9$^i$5dg7o zf@6)l>s40(I08K$pN6rTMO+>z>m7a~ZpLLsz4oE&3wW2k5{e#x@qqq-fs8IjScUF~ z|KH60$5`G>-qj>P6{skS6fQJ6xaGYyC zUGKZ1!5-jr3>1`0s{bGRBJdRm6o`5W?9hoYScx$3F?Xi2me<>|I(@O zCinksVH!RH@LzDMh>a?YYgS!f!HXDQ(615p{SO=@{4d}+_&b;kgNMlE`(--WCE5SS zL}_p#SoSZ4#TfrVB7(yA|3e}Ae~^fva7pPeh5u=y_h$s+|Mn=t#O0|VMBM%pwZY(5 zT{K`swK76ff^foc0$lnSx)_!i#{U|xh-fwXZ?U?F;YIM9QC!X&d_ry}xW?W^*~JgG z#uz0sVX9_qQ(^m~8ANbPcuR0ggo^LUgw3Q&aK&<{O0Gh#@CM8q!uy#wi#JYFHbd!6 z=05`Yukt{M{99a z4nDvTCURn4k_XB+#h>7>{YN%H=n!F_ns=&ZQ&=xmAQhsb+@o zFNJ?iOudaAOCO6L%ZD(U(fvEk$YOM(BZT-@ivI}le|lHEZ%+gJ6Bfhv}T#=zs8dKmCJP_!)v` z=G;ZsrP)OfCID-J$HA^($}YLC0q`Vx`Cq603$N!dqU!%(A`qRJmeiV35u48bcXIoi z+Wv(n`VS&T80r6IU1B_~LGurZeR>Aw zj=w@A_(zI=qx7E`ecyEnrurWtYBWVdKK~Wsf4d=S+NeJ$>?-a8$luN-@+N4?&xJyS z&HjP>2k`$LGk>Ls@t;xiMC?@bLK1NS)HO~2W z%;t1we~x>28a-c)$rqUoSgt)V+7Z1TdfUEA14D(ihs1{5q1?T=L&?+|$_FKYAfU(s zPIA#sIzHzRvk+ex8%#|)6jTGsTSb+QE>It%-zBWZuO?e!oTF>fhiJJ#LCK&7&=*iP zs1+0eDql5&al;rxSW)~?09aQTS6KCf@HWg_%%35JA)+u|>2^>Ks0~yP$^w=Bb4l29 z>EwbBpOSm-F-2b(hmeFUzW5a)81g;DBjhr~?*-M1YfK@`-((Ji4y4$b4BN82?sNg6 zN?5&^y%@b%zcKX2i5tQPVXk94L8)qq;jW#MBwrHE2)zX_UU?g73T8IsPT2E3!4Jl0 z*Ktn7h+r}o>HC7kQYldK8|4m+4VmK4ghu7}r9f8qUva){P~^bGTP;+m!oy;?57W(8 z2xGOdJkDX{gvTzP@d`?S_ASkBXcN`Pb(4$i8Y$EOO~3f=4z>rTUchV+UV!!;Pxq6y zU#&$OlXJuap;&?Sx*-Jc^BUd;rE>&BSS!WJvBjE(^v$KGmVr?Q zY*0P(S1mMbb);3+MergPx2%eL%cvf0QJ^`s`@_d5YQ`JlsOE~iNq6D=?X)ZN8uwojMwpGhxrHr@B=mE2853HXwz&vxz;Q;GytVjU4Z?o1soG$+@dd zE0AhCc>*_TOKX9>PLtA$Lg~hs5UcoBsQAe}0Bbw3M!!I+WW|ul(+HAy7TQrVYRJ4@ zb`MP>FTUj%ry2Od7bwqvF^^%z~D4^f5akG(y=u7_V~x7I3g z#x2;(`XoW){B=#mwrv^8gBNa)FQm_Y@a9j&OBFqZkhnhpc4>{Ju-mmIj*G;H(JD#< z+ga=I?RJ7dwQxKTTFSBW7}Y6b3Rz6KuVn9^sJ=q7{T(SYecc+ggO~|Zm_|hq>$s^j zmho|qzvk@{|Y~UvAMf`2CCL}N^RC)?Pi!zBnjj5-&arO@41S=SP43?VEUGHePS#5t^U^m9DqBy_zzb4=cv5aScbsupY`M3l?gA%Q%7RZDvKLy<8e zk645hCcn81L6wdGwS%G&6Xt^j!oCfdw}nL5N&vcA3NJ}wPBnwL^?A82gwiGCjF#}nf^iK*v5r~tML#i-xq!DS&HKaL&1u;zzMDD}Gq#+eY z*q}+$Pr*RWlEIn5VMDaU(xiw;ZP|!)wh>Z}B1j}J7NQk05JL6B5s{w!5WZb}5sywo zc1Sk$A}TOBuS+xUqPnk*u$zyzWCb0Cqu@Xbb4KT55Af~+T&#lV}R1v&ItSZ9_-j2MHP6p*7NU#X0ipnuVOmgwXH5OueTus5f zeq6Vz8^1nXp~!M?h^x=S#ES?nliHME{<`awmG>gXaEJT4$s zJ~FUdwg*F$WZTDl_@9c$a$Xu0sn|z+yv{hbCoIevWzP%3L-0ZX^11TX3f$jk1Y?)1 zRE&f(Z+nM_T(}PFT<*+kE&c_S(j(F>dxpZ%J2!b&^5k3MTd4G6N?1>K<17khp&o6KeO-?F6`2}gWzoLBz z9q0q2ZwSO?ZaP9~Domdge3F9VPtom^mhmJen=+CvwJ0s80Lla3LPU$=lX@_F6LTw4 zs}mz?9Pht51J7t<8OPmnfspCDBG}bxUKpWIs#ICFq&d7!AQp?l4^MoW1O-hIId$%z> zY2-_th%1y(_Lb+%UkWbFzj4d~9>zEfIOZ1#nWII!=jAq#H(1^FBkNR16)rjD{orqg zc(x2{EYFxm)TFaPh|Zrs`#2D7K2_fankOY4v9efnL~`)e!UcwqQw<}|Qbv#~a}QF6 z(l-xMt1~BN8EJJ1R6Z z$~0n$2*+5^%dIED8XbvL70(QBs-G)88FJT&FAz8PywsT~eygkjAXgL4;mR;jjQmGL z9WDg<5cS>lYtNib5Z)X{mDQSm5UoWAh^hi9;JB}0JJ5@5Zen6R4uxX#^7{P#uIF+G ze(4FpyELF26KeVrJa|CabRU_3Cee8?;Qwj%d=9zIcsev@pa3HV#Sr#J^BljH^@x7i zBwmTgqsinH-9iwA3{>6f6m^*?-sQ+Z{f*>DyS4Q${`3sn;gkQOh5uvZUE;{EJ|K}d zp+lL^eEfaEhHEcw2sNMp=j4K@sAEMY{97T$Io8alK3ji4(Zqa=1P*Fw2Y`?Cd9UF4 z6;Za&Egf6vFQ_#q@sxP1Yj8W@+&=)|*6fT0I56}M>}xPuKCSM*=1iZJ>YF@8^L|jo zbMZMwqL=F55~kgJs1@zsa?-@|AtRUD%G-HZK6yTxKmY3XhSFKMG530Mq9=o92~Q!I z{&=0z8#|`>;FymTaM|9sC~`)V{^fB5`1+JQog?nlKen&wewS|^sRE(g%-rD&ng-AmEZzzg0q#dAMGi|2Qb%-ct%M!%l~(+^c*Mu%s<-RFNuhvklWm%O%?%XUPZnrSRg{W*4Qa zp8OKyPLjos7VewcJUDAjOV?&C1dZ~(tetVw9}}HrxysNm-HODT27RPVhw{b_LbH4C z8f;qN`!MxYniC8N(!IMVPoI^G&md>i5wpZ_@?2p3EN_%xs1E_5n9>yqGvZ03PpKLw z#+0SbbAoYBdaE`C$oj@!q}HqFBli!kowH+D_i6wK(+tXQ^^`e+tUjhFY7{JO<{5Qg zAbZ`0<%%sKLduiOo`gg1yLSYKJGZ3u-F#T*uxLip=4|V`jq+;ExzfdEP9NQ?H_kQ= zynSvcO(fST0|N-=02_q1$Q2kL4tXn3q)5-8p|2-c<+HYw)#AU=#Gt8zUIVlHYK22H zm7vckI|F&iuv_)aS;kzrMt7b&Dz(;bJvcn$Qt}E}?DVP$eS|TBaONO1zNm{Z(J?RN z3v8=UzfK$waud!HmglF~)%4ZCu_xas!C(RqKM=j~6YdG!G+v|j9f)cgFTfJ0l^(arZ< z^iKrX$gGZF6KS*z>}9mF;L+gB6k35#rlHMKHS*>@DYQ$^XppgXTfz=5{pjQCKi*i4 zy=V*}J>j)M7R+Yaes+vxygCU&`6JSVQ{p$`^b>6+)OeK!LhJBsQl-F)QfdxeD1-|n z=AG~}RR#)rF^6gudbIi#g!UcHbHYC)0!9PD$}x|ifrYMW@y;SiOo)sA5!d_Fm-zdI zUndVNdFoLe7%w1sB0#Qoilc z+r<(HCUrrxDGvOE3!!!oW`$w$URdp7RX_W+>IwC4#?#$mj6(VSnC# zLv}*8*b^m3y9z!9Em4mV?V?V0;_?z%it!%1QxBD%foRBEiO#V}KM8`AsJD^CF&l&Z zeuD!-j#ial{lGexpxydSc!y3N;(lOkw8Des!&(t0-a+$-;t$C)T`?T36`no!b@%F~ z4MscOzPXd8&hW?UMV?vdKK~J7t5klpeM$UjK&jQs&1cdnuU5UkHEhWXuNN=;6Dlv+ zQ0YeyjW_Yqv%D8ronwB5!eh_gzh$*SHhhaVht^V&hiJX|d*kftuF1$lD&79~&e_~u zvv_0hps;|8k2>=4u}Q-E)x-L2BI-#&Xpr)k2G6BfH;)*8 zwWp_xv7d!KD$MmcH;I#ps~knUW6i0apFrlyX}8ZzPTuU@5d4ui`BMzE*6%bG5BCS* zS0LpUkXAnr?eyyeo$&F#IDGcuCloIb4;#f}wUMQowFP%y`bw;tHHo&<_xUmMMkgsR zO^Trpbp3tv(j{TKSLgtUHUrhF16!+prQZ>#nh*AX|9T=m%cB!K)iF;C>Q9xmv7?>)?Qqo&jgD?P_U8jq$RfQ z2z_5Tf;fSAy}=8$1w6c@YGt=Dfs=kHuZc_dY}<;@s!+)&K4SF&eCnwR^{Gv<&#Dd8Gph6~Kk0{tR|3 zF@*#m1vpE?MBi?Y9_*2w;V0r=?c5Z<-S2x%AAh=YX&wCyW_qDma(#RL_>$n`FCaG_ z_2AZ`T+8z%z?PgZ&z#Z&;hqKEaHvW*;_U)%=V_N4twhP^6K%}#8++b}YA*&Qcp)@? zWP9Co!$DA`>);Zy2XAg?^3lFU0fdzCl4tHsq0m25yBO2^Qg*%kH)MV_6b{qhs{DdOdP9G#{%E6mX#O(SQ5Gnzz6 zt}|;55>L_8_=8nlZYl1~UowvkO8mY&8Mh=|TdcB8ZLHu?=R<)!mZTND%#m|Jz|L9O zUYjYc+Rnz^`+fkX&l9#ueC^QPqF{9Sy9OUc;kchv8;r7Ypl3+wF zKq$S|(1*L0)r&j*nBeT2Pul~|d6PJ>Z>vz0JO9hZmI=`wZsMbEiJyl*^>OCtq~m!Q zG;?%I;@sycq-OPD(G%s^w2!2%{fc)v&y4?!BM!!5sW?YA5e^+>;__2_vtpzfRq~4{rr5ZU6 zY~H+j?bCuVy0ziY?QdZB7QH@)_?`dYv{8p}_KsL4*Zp^;h)oxz z)xP&8fSvbwPZbGYW}?mxyaQYhyd~&oPke7{*FLU@a~}_GoGsh?JS^4_UAa)FqkY}6 zWtgUF%v*H(lHgUnb0Ft|3=nqSJn!Yq(bCa%AT)s!lALhBNBL*~lzb!vb-I+NDAj1@ zLWkhUc9wK+tQp)>ROb5^z?cpKE)^>VCj~cnD=(`Y^L9iNE+5$`Ekt%bnw<8O>M$FA zP!~b8(nq7AChxC==mr}1MrYs(Ki!;%;SuGQK@#)se6!C)aR{*xr_W}qqdrw!MxkkJOM5mslj}})Rl%yG`WTrX#rUwjHgq$icvN1izHE*Iod-rw_Inh zMp-AFlc*!RWirRfWpR2}FIi+@{*dPbW+)5!QXsr8g`bS?tjmjYIvQCSl{zMM>V|^F zQbu3K!tyHfP?-bBRwoT}hd*GFD){5_eP_)NktYb!ga8>vbY0=*@F_SCBrcL!B~4*T zI*@wl*#i;(Dj+g_ty;Ye^_F!xyg}#`2M&ift60%9Y=0vFSU3{YYQ83OrMPl1N4Yp2 zKzks|}7~!rc=OC^`Kt;{cmF`IpP#9E1yN+HYWzi>YyStK{ePSN9UG93#4L0Err4sQZ@nci3Z;u5!Jq0#ym;1m1@b7OD@&I6GwK7dFcF}55(0$+dCLB5S_N1wrb0CW8{^Wyd%k3R(; z#zFoc(?9O((PL5sbs&jT-`)JVs6UBKDg#%9Ytd8^=jE1nQ4Oh)9B2>`XJx?OOl88Pm5!wM-%gis8>-Q1g71~%J+g7$IWr=u{J%+$~mYH zeifDy@IB($Puz#OaEeIhC}E$h;{Dh2WhE zshWVy6CApsrheF!nMGb{DZ0KG84<<8JV_Lfu$qlInt9^xNG?ZyBg%gDDGKT2NzNCV z@rLRkU+LHoe+QJ$JR+Z?l+y=9(n655)BQxW*MX9FrMMn(@&xDo6SOW<9kkAX_?W}9 z|9-}bEr1%I9@-x7f0JXBFj6;e$Q~DXr zi3e@fL7m_H)=XTX&f_B=>)5`>a&>pCmmO5*9ke%f#9Y#$?-#DuO@Q@OZ?(oGJ+7p9ArF$Q&i z)Z=tGMZK$V{_w+Ye*PwT_fp46xLwj#Zk`x0Pnv@BzN{*k@pOf|&T!iMU8oj>81P66 zcwDO`68DJvBGNkIwzYuuQ}JN&WOjh@uJS|Sh2NW*x;O8tc9zX5T;VTmWT$-&F{{HI zcmZPd?Q69F(wH%2?mG(68v|{qyy!fbKUU^fp;bA_z{Nq+{fcfwd}@}EpuOhJqx>-C zyV97Wv(uxHGXLH6m4KP?+Va8)o{CCPwr_W5Q{N2sFj7$N{+{Boh+A@mWPAHrAckzM zT>gEH&I)oaxwvl(2f3AnLrw|iOH($Vkiqls{J(QN!{Y;*?!6_i9$r-a_`ta^#e0~& zG3{o6-#(fpHPHdgm4oVP{;C39D>)`45o zZ~f)kim4Wk6@`^NQ9p+z8tR?7?UOp3QT~OTcq^~&^B5uM@~+pnSqk#(?D4t0{dUdv zotoCVoeRJ31QE~kr#lsILzTxTAjx-AA zc*`+cOFm7I+L!9-WLG#l3%^c8XdRcirG9(wvMm6GvW_Ny* zkmaS1Z~vmo{d#&)dD~{+L)F@wtBkIlA}Qn#f-mN6iPEL+DLE!5?+B(&|*8v>=i2~*W@KKXX83} zVyzaJ|Fn;BnOo{7g4S^>XSP4 z?WprRrsWooSpKuT=H^ioL^TYE(ThJ8>~@GWFFSqjv$@a=iBnwEZ=08Do6jG~Z5_Yj z?L#@op7k1Q3HFFPt$LRgd}^}4@TTqR{_;MkSwrEx@^&rY{1QhzUxTO3%e%qKTY|c| zdjum-(4yGX|E8Wv#F-q(59J*B18ucZpxUtE>$3@vOgdMcjk#|89U!p`--S91kNIJD zj|g?Rm9O3hpRXq#5?K4h?)b0g50i3H8R(y*oK2gqW*t1AJ*!vDQSRf*p%;E3q*N_1 z&i_w~HvIfyQsR$X z1C1@f*V`@Q&))IBei5^S!Jg2e!EM0%*l_T6dcch@T>Xo<$7Wstwb-P`6dIK?eh)Wb7C%fKpUnSaoQtS%i*Cp;w0< zu`^UP^kJ0lH|w{^`VxRCaYJQ(N(O)SPa0FVQ}HAFk$u^h7OrNDcMz}PNJ*OvHE21o zZ@Y;0cf;bRuZRkD^S!7%XWnj&wCcg-0}40SDPyXjX0QU}$de}J-j@oiGuVkRGwo;$yBUS1w**l(Bl zuJ*o!dPvMahT3}PU1dGpFrbor7FsrVp$ z+i^C>AC?IiWo8;aw_|EY{4` zv=sz+yN&+LJ~g$&b{Bm(Y`AtO#hw%w`rKwg>~lW&tuf!Oc^sTXp!w~&0GBV}Q7FA5y#2h#fh=vG zd*Fw1k4c(;zFr-G*`wj}rP8~EpvsJklF!p@(_F6_Wp7F^ux|H+(#(NF3-h=A-%IQnbcedI!w}L2c6$>tiTSYaYP9!zykCpn-;*Eh{lU@taH+9)(nIw6j~9HM;CcN9*OTY< zKfEqG{Ppm%!|S~8>*`_k+;zde4bMNkt<=nfr2PDx-0YNu)%nRONo$f*vh#CN60(!h z@>ADj=ci^TtWL?#xi%~5{8`tmT$!JeoxLXe{Fl?S(sLn8$X%P0pOA@5$=7;h=?R(X z*QF$%zYt{`D3p?woWCYj|7LHla-R3pPinR!ivhxa+$?!cF8WMvFJv*q`b@} zTa25{ve6~m%toZ_rDoY$YBE>I4!gC~C7QH3it>x>C602JIp1k6vzp3{w)|qJy(Ir8 zn*&$O#l_YltGU!wwoG2LE+soPbImnjvUT~K@-TUm=l`PRb4+2f$@70%d5L}b9GlJL z&x-7ujZUkv)FnI3u5xFo?6Q=X-YC25GK*si7BFKeGv+Y6!|XJ=?9LmO55A0B&dT(x zEK<)*(aaRfHW@b?7ni%Nw#5lfr*UhhwamrnuQy**Wvf$i6Ot2h6E3b1kK*#z7*ev* zvsUJ(re~&LhLe+#V3}K;7=M{-vCCZHlD9amF0+&6xR}XYWTU@Rtz#>eGyVswWgWX^ z(V|7aVkMcZ)I2zz6)T31Z3VO0OEs~pbCTy~p{Enu}8X=tjK9~pc(l_^j$8tmzSCr zv9gGzqs;J#Wp+`+S)HzCWheyP+(5%79mc?H{A2iP8Y&{cy>D*y~79*Gwk8mh5B8HhjQ$#cq zf7iTXaf#VvHAXJRQb6BP90HfoWONx9)1NqBY_Jw#shd-s#u77IytWKBvbe;0W4SOt zgVVmrT;wVnsK#b3bizlN%N9f1c9Yqe@2RECS+uysXf0j5snqJJH{W4&qOL_nMav?h zW91x+z1(K<7;T|hhIN}4U0hRZGWaSjEw;<0=pHP=DKn#yIaf2-%i#Q!nzyj|@>N*N z@_KWrvCwADw-zts=2cp5vpHO3&9r3m@gGJrV7sP#SfagXD9N1y+h}&B+U!PGws|Am z2%JY*L=>KIZIMiu&VN>Ap2x{ud0yfCb)K;rJTtOv*}(iKq47d8r}9#Bg#+D!{@1O^ zPE5~9TC;j}N>(mgou15{Lb}ZcYs6@2ot)Q1hwm8NNOdlYdC+j@pJU!5EZI{L6cfI1 z=~CJ5vFxF#_sqJ}Tnwd^7NK@?iNg*X*vc;qnl3pFCJ$<5?vMw)V)?nwB95|S^C3BR zH%_C~Rpt?|5OR-lJTBNHl~=I*wcPC2W#{CtG`sTCOQD@oqm7lFtugNOtfe+9_02^_ zfpz5Kvs{Rk>~Ib_B&YeNaw~RH*|SLGEq14=j5+np8O@w<@6dfXcDL}QSX_}KRO#7U z&ZEZ>nP}rnEHB3V@x4ekS@}w!J)BndB00xpbd_VbhJ!fos>n^r%p6!1YqN6J8VqZ) zb4Lq7M(j9=2uIQolcd1Gt;vEAW|d!vdA$*yX0!c9GpU9xUd|RSXKM>tZYhlaY|pd) zdL}QI87(u9UWB`c=F1&01)5Q#vjnp^uv*9y!3&WyieYiqQmbnPm6nT*Ryck}0x|=$ zu|$aEIi?ZS6~Yu`bV4pO(iF*a%FhX(B3Py&iP28O7P7+dlp#Ybv!D`IY_ye`*_=#c zCd=K*iZ%}pYVqK!ZEV@HE6~*lHe_kJD+cY(tShtLY|eMdrSs)^rSm-g+-$-QYOJu9 zl$Xdu-KBgi$zEzICkM;&^YgD`p}D#2EXO@}@YS4Qkp^7XGPthw#zN~dMnRr4_QK|4 z75k(|i_|nt&v`9jw5`tD$Tc%PE93l`ugOl&O&JW16Jd>Pqt&%A))W&J8x z^^ws2Sq^7jnOtmjdLodapk&bf=smv26ORn;sOEeNwlW)X4J|L*RuQ1WnARGX4*L9b z26YbeMstkJw3*q=%#q1f=ornFy%^!1j&WCH=SIk_dPKrHkfO*>_Jn_>cD|wOn2k(4 z5>FWT5|(5!W9@T0rWGuof5z5kUDDYBi#0brYi)`b(|_Tt=Xiq)hu9;suUVT*W{|jM zZB}v)&doybL4Y|G~q!&{pX(Zg5c_#&g`^>fOvhuojdDP~~|8%%O( z%7}mfK5HO3la+26z5xl}*TuQ2%Sl*m$Q;z&=;nA~Yw@gnn6tBx zrDr9l;4qdb-c-4{h1Zr5h2^BZwc2vLfU%M8Bno63(vRtU?2x&Ai!M3a@3dZiW?8EP{hqbZo5H z-^$44mD||jb#_~M31aI69=tCuEVtTBg?5)Gz7`Sv;!@L%3!M?s3!P@_jCrxkZnwcg z5q#Thn@bitc*IVTdQnBi;zf(Z_W5Gj^1NC)p1471@YhK!hYcAP8H z5mBc)?IkH`2?+Gf9-n~_1P`otJay^(2w^jpZp0Cc+|8BeO6UNxKJl!{JSR{HdT# zN*%G0o9yM#@`z)E!R}zDrNhoQSP;~2`TX(Buoyqz6X$I#H#$wS=f2sX0WE^>bC#P~ zu7la_p6)Qy(0TPLmgCA~p3^5C5%aNI<#Ttz&^ABVdjg4t_|JbtXLHOh<|y*|G|6W^ zJu7w1#RCo=#HFU^4eIUO)pd!t69;bm4BbUs0EMks&fx0i7>k)bn>kB{PKiTt`8HN+ zWLBX#dG1`#ii^em>!dqvOR-oGePMId4OUa&yeGE5VT7^mG4 zJ0ss7WowCXBjRP}Rt8^LMmK@Nqwrih;#@@@q10@Hb1pA+je2Yte%f3!$^l?_o(mi* zMlANsH95KbcKjL_VjY|WFi}>_9?OQ$cY2(DCY`*<`h=!7}~OiPKV1`AvQ{uT)}e9%((<6M~*r2;*O8cLLw|>vC-**^$y*+ zjf-2(N`@4E1EUQIDlPKdlZNJOF;VnppDBC^Gnz);J-Bt)9+_Y;WTx|z9OHM2@&|4e zCG*JCIWUndK`wM!H(KZ}gUyam$AsxFv;4x?e8o+O!?8)MxTM(J6V%v*@#Z52+p=s~ zS$QGSUf(bE+!7fPlrVC-2tO8{>s=5thUPNzwq?%Y0l(F|fIm5j4IcaCxy)g$z)IO{ zrXxMwZzcN~@Wt6?xH+?KKH|vIc?0j3FoV-xgia<8-oGw`=2>}cetvnm)nqSrv8@hQ zDJw}Ix(~3N0ws74mRrusmcY;=hAIEzcSzH-*5N>zoS%dvequsWMh;(DY&G4im38Jt z8yCqoV^M~%EFI+gZ!;H8iY4!F*I70hS78Y#8XdxJ#>dd8f^i1}|$8_$)Cgi|ARLFD8 zxw%}9qyJfCL`7tT>@m19dETH~oG0rHSu15b=5juR3xT!F^W5}VC^mw5%$UW@Hw}%D zjWBp|D?=ni>~pcBAl((@E_monOPiUeVHyOZgXcgHHh4<~0Whs~9W&d9x&!MTSU=}A z<;;|KH~R#yru8Vcv2t@-N*Du|3= zx#ZAGjIJUJEoWLCR&oX<*mV#>m4(wy_Gna&Ct4Gc9h~ms#H}tfpYki`r(a-lE)<1a z>~`izL*zAKHYjkUTfsIQsEiJ}AML~vww8+UZbS~cscnVxx|y$?5gGX^!4fIdr?7eD zrF3XE4c(tw&IWb}9UJ+^$Iy*w#v{xT(cHidce%)BETee!#!~y1(h;|%L^oi^w7JCK zx#R-x45r$QML8aCv09ue7~=gz1RSf`>h#qqd{-XsvEij$M)GXr?;Viy=rM!iW88Chs`{gS%`sKw;D zJ#D(!{b{m|EwXX=Mg{k{*2RY1;|d#GUonHX7w8_r(A$oP2TkFFiXD7|#oO~A52DIh ziM5Qp)J4yOBbUMOle{OaUE?gWP~2){bB0AVH?#RN8@Z8kD~9v89wbtAAjtw+RE*_ z_x$F;dGh>4SH?w0%m|(^NvRs=e~sbV8!|Fe673daahdB^H{Y@AcK5yaJhb=O7hZ4u z{geCl|L5l)djI+9S0~?i>#v<3{r&wTP*p*>^OkK_uglL{y(%gB^68VcK8mrLnR6HG zmM@Ep%1V!4nZ0(y`pq|0{CZP~sc6Mg{gPR8!WO6{-+(F8f+jxwyT;!>_k7zc_ubv_ z$c{T}sz3hUuD`wchr@sR{F9z>s(U&MViVkv?id!wmb!Ipp?jWt z7AtZWWB0SVE7-5xTii}&U~Ap^?(0~FJBuZ=1ot8LTkJ4<+kKRM2P>ylPp%GB>Xp&eS5+@lE~s9l3{}poE>N1Q?bVx< zw^ZM%bSZDD&Z%Cf+@QR+I!(E{dS!K@vR(Oy>c3XMqx`V?W91R$yVa+vzgPAv|5M$g z{8#lC)t@T&RX?F@R{pNKRrzZ5i`CC5>y`IbKT`dma%c4}WsS13+O4>)>aMCCiifHm zR_sySU3H-91x1_UrK%>y@2h@W^_b$bs?QZC6(_3t6#uRIq3SzDm*Rh_{!#UJ#hoENSLG?LuezoxTj5ZYSN*!`X2p$Fr3#b6SS2gwR4uN$LUCo)3PqG6 zylP_ABt?kg@+x1&xT-N#3I$`z?aDgluWVp20OdW(76t=QzRYyWr$A(JMy>F3`>Xvm zemnew{T}w);~(g+_j|#w&3~C+lm8?=seYOM@A&2Uf9!w6FWb-I|Gj^|-_8C#ex-gU|4;p7|0n#L{jTtD z^;_W=<^P<2z279i5dR1LeEoO%*Z3&{m}a}CF2G;Y5HLL;NOO;-B_JjsPV;hrPV=;8 zf50qFcR+D~P4i_yh303?=>TVdLGyv;Sip6f&Va0dWX+qJ_<*+p4r^8g9M#;Q$qjff zpjWe5vn}99jV0jgfa99NfJV(+O<2H_n%IEX0uE{-G}?gs0`_XA1k`HAYg7SMj7@eg zaL2J6cQUJBcK1H_K~~Eiard$>+#k9Rv!u#g$s+x_QY($Ays|P(>Xv?}{8QyI=|JUv zsX@A{vbt)IqDk?3RhQzARX)v5YNO^fQcH4oL)soZ1kzx}D(+s3?qd&ii*G2h%i z`}U|YzGG(G?ijQ2_Vu@Cjybgbh`L98W_!K*_U*51Z&nwmw`|YYzCj(iJyad64%n{T z5#+D)ziNlUf8~x_cG&zI{a@U%bH`r)Q#+3PANBw1j`*?H?7VU3wy_g;s>a5SUA*(N zou|ikj{V2Zma)Ix`Owa~v2Ne{YoDrZ^L@Xz!?(}(o7&m6QNF&uGin{a8*A6sX8Inw zL*Av%9WF)2ex< zuBgtXNz>%l={57}rq*dRSpR#JO<3_=aOV}ulS&zGx>gy2b!<}RvBTR6v-KkH?Ht28+wa%lubJka9yDDyt!=ttTF11$>9*;O)1s#NPTxDtG5z@Tqth~{ zwg=}1Tc-90Yp2#sZ4C|!?w;zJnihO)sy=vsaKlv11Qxg_uxUbEVAq7?2^E1S1LX+^ zC)5UpOy~_X1?EjSJUKq-NKnt@Z9(;uRg+_bnu7`^pPt+qv|(~fP-sx_lWS9M&~7iWuWyejG{-?+V3IpU7T9la`ZY5TI=WtOG=%d|^tmbNYnTh_hQwKQ$n zv8DQD`9#zLc^ktMSTlx z3mX?jE%IHscadY^@r6ehW$M}&hRDFBj7{{2gw->0VZr{DVM;))avgSZdr)uAA zM|})F3vN^1RT0n|u=B3&@el2qxXUuWUGqv^WL=tO_MLg-y2d?qr`z|Z+Ok?--=aH$ z{p$U`x+7_4?AVsEf7;pT|KX0D9XfyI!xhter|*2Yd&WbLOnk&LqkY;dzlr=!+O*kw z^Fq2pp4#ia{HF)X9`L=qs3ADGKKQGKqz7ZCv`qQagN;)^d?@E3-BdQ=mb*{gT|42k z`Ub65dt?2fds+kaff@J66W8y4e|OWw>iha8IVO$0uVC`j`*+{pGdcdUEAKsUZ|7zE z_8bi|1TEO3Y^#XtjoaDQz5Jn9C%$S~-hS09uSdR~cGc|H@>X=Mc6Ws{TMnE$P#gK#a}5#Nh#Q|f)Y7Wg>oZ#9 z@b%BW|7=ru^})U#lcyiWnr)ito>c)`^@UwFglgyYz}{jPwCK_n z$qSFdCY!K6`t**-+UV|>V5|YlviPN~SUa6qXL(n3#pzbMlPi*YSNbLtBsL^!6MC}R zSI4ey$x2I;Gmd5$(wf$?b$#m`Yio154Z()`9Lt9I{MLN^hR*96ug$x*>pERAGxwPt z#kB?9MZrb&1r~d}t<|QtcUl`aRj$E6AB zt~gp@xTR^QySAdXcc<^21-lw{Y47Z*YOjo~Y^h4Ck#9eGyP>A(0rp_ugN_GkAL-s3 zytn=lOMU$A*4_I0&ifkg&AYejKHZb-cYVKeJXzb^{Y>yP_05)j@r|vG`hA^`H$IW~ zMAzfGR`)9vuk^P1zE<#R!>iiYdY)~6F7~;WXVYGk4<0>ec(Lg)`)l7{9fxb*>3%!- z?fQ2te~5pp^)3A$IuA9r=e2ho(tYgysN$pEkA068eAw`z_Gr)F+KF?`x|hV}RLJI>Vpr~Aj? zAM5{P`9A*Nt^d}4-`U%ED(_TRFZ4gLa(0zonN(wdYcIRKy0+f0(f`2CcFp^DezvPO zKzX-TD^HBPC;qaWJw^9BCb=))-LUVWmMMpR(-qP^0M}P`u=>j3x1Q=#`fBfxcSMejuge)%G~RKS+r3-b zr)a4@r0Tk@d;6&|?172Fv#07GOqyocTkuHP^y&%q^^Lm^1h(J%e$Z!=d+%dUPFyfs zr~h5jBEvHU&1DO#XVvd(Y&@vQ?Z+uPqy_$;yakn(!%G5H^n zH^l#MPF~S^$KT!mvpaX+H7y?;y1J{Y`~6ed%2Mq&^2x}O_%CyAEV4LGxIf>$ai6K> zpN9&&KI`s2Wn^b2-a7kN`u?O_3_lk9r>vrSOa1qa|32VqKlQ%zvzvN*8B=RDvcEn! ze!3y3Akd+A$JEC)E^E=XFX{^Ip4Dr17u8!E?Jd7*zoqM@?k&A*S&sHvIbXjzKEseu zu+s5Y_uKV{8voGpxAu3tKJ5Oe_YC`w_LTf@{TK0{7``g_m*Yv+tbIam)IS%0(C}J8 ztK&iU-un9*>szYYYr1xI*Y-YC`J21%fAsEWtKYco-Mc=y&BOE`dR9m@ZHSQ;h!gTU;U-=(>-rI_Q&V`=gs%u``=Ih@!fwFKLvg}>xYPc zCVrNEqVOB%#wz1IH$GPK+^^r<`d-6OkRZ;h>cr5n0E8YxyZ|z&ld8}{$E&jxo~Uy(w8#MJcL z121%sLj`xJX8&;qf|zi9gg#OqrH|If=$GhY^-II`;o;#C;gR7{;nCqS;Y-3}!DDgii(bk ziCPjB8?`iA9~~YY5gi#F6&)QN6TKulHhO7{J|;XSA|^5>DkeH6CT2-YY|PRn`X%8@ zB9=rhiCPl9BxcEyC9zAE#_D6kVv-hjg4Kp6kS}3=9i-Cr6{%(qO9+yd>XbE-})ZHR0ho4s(&!Xv;E|m{ZtA^G9XbP2S{-2v2g^o$EaJ zz%Q+ZSjtClk$90eT%TnwbD2$ERvi(QYIkliI!(Fa!zbATA9eDkttk46#YVF;lfUEW z%}^5Uc3Vn?!|6@+F`jR2h|1H^zx3jiUslfa3(J|kDi_H=c<22rgh#LCZ}X5+z137q z&el?w#q6>c4d}{1AKmk|csR8+qGYnU$eYeaz`#p?apCBJQopEl_=r*lW6_OXmJE+h zvf0bZo#soJ;c$JX=PQlgnrF1I{v>0W%iA)Msp4G?&w#vHFsXK%$;;X!A`Al`=A@}Y zDPt*p^v0{PCwe{~=IuE22B*2$TH$5(*h`qASbDF@+ipZ94Ssyf>z?y(o_ejYtILfx zYq8a9j~bf{FYoQ5j2P_)+?Z zrI!sYrH{0F8wWy}zijxCBliVwCTw8iO>k~3^JWJ0G=^)eF3Tkr43EiinJ(2KMd*jM zQcj-|@_Jk;p05L@7kjB$#DrqQ#%lF^^TZpaCfi*(<;CPSyy;Sw_;d#;*Q>!N^KUw& z415g2%Yxw%17q=akBMAUxGBj%zsbT|3th6#Tx!B*RN*3>df86wCFBi{Hqa*_*Lgmt z=k=NBlCm%LxIJ&{UTUy=J{y>8=bNOL{W3&w~vzZz2$k@!1OPPV5 z8ThY|8Suy`WLua4kBlv>h#Bz6C}I|7z$3%LjLd*XhLM#q10ES=%*70NWVo1>8Su!k zvQ5l@N5&>*V+K4jY^;PC@W?1(70iG~Mg`l-40vR0WoVn4FJ*RSz$3%XlF<@9Gw@$A zGvJX?%uLLHM}~=+nE{UsGuy}vcw}s3$>qpjPWj7OGWtZ%4E&dj-q14x|DB^e{vD-J zl?MxjaCzvLc;vVu^ymFj70N}J z?DX47@;%?6B|*5&zR_AVl#UT!sz+(Q+jvUx3Wjn;*vnn{_Tqg0g*2+h^J*wVq_e!# zWi1&di5mD8#gHf(mUfnG>EM`=h#mZTD>Cbc{aTWN9~FD#pN!qreR3Q7(v+lN)6Xpa zq}##1WX$jxUQ1e}nYQs04f1JZo>w8?)qNcKyJ7oLDAW7A@|o3rs#ksG8NHn+RH<69 zWto(Pv}#N3ug4bcEb1BPbQD+ z)CooE&Juzk5FHWAUF_I!;K^n=k!uA7fdmZ`#vR>sR`uQtHsE`KgC8 zy83F`Z%)1?IW4t&C8XMSO6I3_7f`Mb`&4X3?|mnjZbKL4&rEGiud;4UrfY6b4bQJi zNPV7aDBv};tgK2+OHD}4M*pf#jH^%Z6!bEYYdYTH43>zJ6`$dwqLn$!J-f`q7DTfpTgj+9)_7 zTiz^@Q^O(GHjMptta^LmC+`M4?DMULeT4ewz7*WexMp+W=5~}#KA;4_4K;|4VkG7xwQAq=vAMP#R%jR=x2eZ{=U8%#&oRrF}M2L3Qs@7 z&~BCW=Jc(}vS&7B3yp8fsx28bMj85TSvj^IV`-^QxuHw8Jk+O>KkUW9-=Klzs08{N}Qk=5$}5I_v_h~dt7xH>WpD$+4*)z3Ir)kGbuJ4nP z-h_0NNQWU^*z*)>c`D^I%q{EIrE2e;(VN%f-;ipNEsH%?Bzvq#wuG&klNy$hfKk@O z)9mR|8&|5o)sO$+gnv*?>IWx$uY9;il{~KDgA-b-Z@s3$uU?mm5k8xM{}qXa_+Rz( zpHHZ*GkVi{0w0*VirfBJDhaa_O7VJEey- zxvpnMZ%|Jy*3gsBpE>hC@1Hrd2`qRO{KJ_uHy%E7=H9o@ocZL%GiUPJ&YY=%?2Y%% zoSE{R9IX;!mbf-!MoqZ~;-r}@$_W5eHQ|5K8`u?d| zmD+c{e{z=elj3yBck8Zx;P~c}f0$&V=^w?n$UioQZj1VqQAF>R`#Kd}i8Na-VO& z2Yssg7)w@)Zx^lB2#nkL4ZRnVmGZrq9oWuTK0CR?W@iOvr~m7LK25Utr2ehX*BzAF zYkBL0bamrPI?J1O9W1*tRc8rYm7W?n=wEg4uk$Uv1-jICN@&Hw%fsdyX6rmYji>f{ zQi{|93+YS$4etYceO1euGv$9db7r93%P^PJ?xfTn%O6fi35_pd{n}+uyRv06+MPVu zu8ekPT6zj((XNb^*sQL;?g;WI92zpz8E?eAvR(+2W&!`K*InvPv4O~Q*&!0J{sTjX@Ndck z$5o52VaM#?aI%?MCq?YE{fb#fubAhNyey@(&+|dGh`9|*;HoxT;GS_WSpseq6oJvDcX)-5%H20%+_UrEPF#Tw~u38aqdM z4xSgrpJ&7Kw!zYy_B#I@>H)oRT-{hbiAT-*q?ijdEWy!Ni)n(@pQF?d<|qw=Kt+2_ zfQnTjdMh9FNdUd%ebfN1i7~Q*LnL`r45`oG^ZN*s+OZ@zTQAHK5>9}84jF*gvCKQY z8LR=`qyOPuav(~QtpVVgMI~`P^t}ba756%`zDMal4_r;@F9)us^yz^Zr6*hebm05iZiYakEccXv7T_;r9J z15&>nD8Dyhb)ELbowU?hUIUwaI?!lOhmTbV@&Zt(Bqr5UO*6RZ=ES6)2oaYCC z8wMulhw#$`-tBf9#d@*by4Cz30?B<#0P~+V-U=SRxc6XtBt-DAR#fl6%hdo;Y zx*=1!YiO=(t(%X}5D(BF4j@lt1>7(yH}tc1qMxP?tLPQ7g;T)mJt}dt;_D*V@tN&Q zI-sw_a~u4>i?FGnqe)Djk40EYcq904n8)yJnDWGCe-xf9Oh>H8$+qq6(J1XOH=Ndc;cP}_$N^xA;<1ew~!7$;Wxpx_VP&+q~g#Z z`3bdcQny-k(Pml(xsZyFs2zM4lz2X1s|-B5^fA}tt{mmb0m^&81}r`@HueSB0c=wz zc>iCvxoz233U~5er4E&(G!WXiN90RJj7NbOm9IRr)~#$d?_jB7SJ98mZ&A!qbzPT z2#NO0?gsJ<=q_d#iBjXt_X@*lzPG3Jn&0>)jQ>ro->JP`IcR`hc_o=e{{5!EQE(}g z=w4*JCsm6VqnY`@>3svhFRFpOtY&GFWc1}IO8@UvtLNMxlgRo$fu0@0Cxaw0M|nq% z;dQ}-cqLqg*P{nh!&Cd)`(O0e0sT#jS~={Ki(1&1RlC-&8Tx&vpY>s{zl$W=+TBN? zPcAx}m|#{t@)wb7-TG)2v-e^`br#h*;oi`)K{7=>#IOfFi$`Qbv=k`4kvtT} zF`%|3$`(&woe3f_(qh;Rls7I@NFtp_Tav@XXV?w$EWZpI1+m}&i--D+QO@syOBcA_OlSpolx&1fnIFI4SWr$7}8Q{kgTKpHsD25lqU`- zfm94>X%I@T4V$7H2x4xQQ+(964Qwdtrc`f^lG;C&qUQeCyc#5&SGJ!CW2=vlG#@{~ zK}=vqJf6T27Ns(jEyb+8ACe2@=g;BRcV3&_}9Qc z?wSRZY3M(yzEj-+x}h*IHdfL(HimF!fHN%B3o*DnfLqmnOnt|JaNm76HpX>M_>sw* zI-0@vBf&;rW0tT+cvChTIN3n=S2PC_h4PBo(r;ugX^rq9aFSEF_SDV_eYaUoB5MRkg;Thu z=VXK)oD00f$%%|vPGsFemvB-g9d(zdncj*Cx}i5wq^d5*z)aaL;u!Jj62*Hrh_Z)aWx@3-$?2q8%jCx6o! zw@Fom*Tt>NeZb$%z|+jXj*Vq2)q(NxWL2?3mIi}(SR(Br`jV?6gl`8NHh^#PY~ODm z_^m(uz~A%AO8a77S^ER%H~nQTL`Z$(ev@9n`eWZ7AB*95RJ994(W@k|2JpD|(AXG2 zgXArWpp0yV_yF3A5PQ43Od_@%6#rgUs@VIQ2I75#G?z@d4F-m%cYPcI}?Au^W~$#2!zDkcuJA zg_H%!4CzjQyJ1f(E@K6ZLzF(RAMlU5Dd@Yt=Rr$u@ZYoUH&G+8M2*R*Ju|CjYCUF$ zQ>s$6J6(7@&j9n|06e4*egic)vaDYrtex$i23~>NO(CDFGEP9w)*wmXGobSk|JYa$ zTnqngY^)8g-rZwkHv+F5h5H#tCUp<+L$>V4D7ICYAs7U-1iCQTgv3uOQY5kyZPH=k zlC)A~6Cn*^Luj`C<9zwRXg-@&o5AAnqLgzL>9e-FX+5txI! z;QCK6H*dkU26!b6u7`nNoNrIg$(D{|_meq9dD&xF$9koRrSgp{425_+7vgb-dC_o1 zmqPMLVTQ76NSFCLz-GP7>2xpDiw6R<@Ew>JGt8eDB}OtgEAW)iB$(w}z{Zc2GI9{S zInMANl-EeNN4SbD{T#90w7H+7T=@m*kj#?DWIY)=i@lLN?C&Jlp4Xv&{S_vKY0FR^ z7}_cv5ZSheQQL<%_nX)<_m+O6zFS=r3_{NI2R@IGxi49N270@oJQ!i-&y%QcQ^nlw z&m-*5p6%z%(3}3YezU$nn(PI8NYZ-9RJgwPMfN4W*80Hyy4u=19++;uR42%kNeRC% z5}tj!;5R(vF3&~a%oo6%;gqug*;ICIq zt`j1!|Fp7Mz;(e5E?gI^7`|;45xcZsz4vs4SR5H13v%%TS!*l)u_sTsRppY|9gd8p zm-g6#kUW;^@^@uNvM(TzX0YIE1;M|z;@%#EED#ULhIo<+Bus32xq?6*Lt3cpAyjTM zFkan@)LRA^emDp{O(#*adAl=BA_cb&>Jo2N+1U^ zy(BzM*zE-SWq!k*D4B6xej3`q<)G=a71!WfP8(?YEq=Xk##O5_F}Xm`oeN|uDes%Y zdSB8MA8lN2vX#|?NYh6L6F_5)eY9114yQCo6Az0O>g?GSgVPeFK{m0eE*POH zUG55%pG_cos2DUQu7;?qu@<7=lybOBcXxx-SEyVTyRJfIvyQ}`ZYjs7#leUAQ`yQbSFM-8>|eEBlC3NrD81{ZIda3D1C>CLhJhG2T=$*E4TIUr%mEH~;4LRZwn4h{ zH&fPF|BDqE#1AQ)tvCk0zXFr3yfX0p6_{-0OZn;*m~7>=oUMEzk9$SHcek^`UT3yO zmp5R(UUg4W=R^5w>bxsoO`W~+8{%H)R})QP1;5DBPq|>0KXxO$bao&1PR)Y{0#V#|Gdo*Eg`Y{$)tB1z+3w$vgo#KWoXb4 z-3%@D!_^DdZ{WHUu3y7-7hL}U*LUFh1zeH)KZon5aP1y6T)d`X*~oY6GnB$X${$y= zssR(msIBRkc?0^fmxJ4ujWoLr;2~e*C5! z;JGq}#Hgtrq@cO493=Mf6;Kvxqz%=IAsDsApRE|+{&;=1vTeXEZYTlz4Z6k6aNP!1 zFI>06bthb#;JOR0Pr~&bxIPZoU;D|om(=%R2H%G}QV>+%Uyhh{!CYi3&HOFH)Y-~wgIW&J-xIr5L%c@peG+iUc*(u7t3&0gzwBKrmORM& zadd0JGM4|~&BvsT)%x@>qOZ;**`UJc;R)%p?hc~-+kUxOt# zTiG~>tL4l3t21z)fSPdSr$itH%SOohk0Z=&@wnauTyt?|!~a^Hq0AisD?+=}nx-yw z)k>p{x{uXX3o%(wa!l-pCpLHdKEe)=!rw=jmA{V|eBf`b1aE8LWQ6q@Uc~r->xjwg zvd!o>f#V51~ zH~5)C^k1VADY6ELeGuqfHk@VSV4j45oJ!|#k72dA!If2|6CZO)^*fg50#|L29i6L1 z9KBjc3LmR08@A?EuV$iQZDqsYJ|J3ZRq=2{Z{p!MAv0wCygFD{xt8+HYkA>tgx+yj zHG8@unQOC^rTxgUy26fLUgT`LLh61Ke8be681nF3aK=R{>Um=k7ohz308K!$zrM*h zk8KmEXe=^?NbyVoN6C2)!btQXrciL#0wo0ZUE$bhd_qvQx8s<;l;_}>^f;zj@iE=1 zjVVbcb|;J;M^#ug3MJ191yu#$vp(T^TF9>|_^ER>iCUSaK8Nm~AcD;WU%8@|M8oGPz0Va2_At_cfnJ!(B%G7%_22G z72jHv?vEldO6bDllqiXF3JKhFBSZf8971LRP5R;fAY9i&O86lmH^LR8JHVjCI4dWh z=Rbw$zF`R|&I*t=QmhLx@f?TPxcVwO#(8Llz=+1;j1ZYKNk^su_Bsi7#F73+NncRmwoh^)huIe__2k@8FtBRA@f zfHh$t@36Xxot$5^*tn-P4Q9lh+V!5K)~u1#RcY|VHae3-q$pWpPgfDNGQ2Dv#Op{u z=uVionzVd~)bJGg!|R;v`+! z{~KL9|2w)q(jTL1<=>_2n*RSOUBB!5ztdGJ9pCqLh-i_Jwi(JmU#w@WC4GSQ0>`1N zBB0^Gc8JSW9yCMR_25TnycVoOJt3)zg%qU*q{vcAcw)m6kEoSvzoZU-9onx4eq*tt zLp&np`JMH4!7TWeaDK3;ABbWQd=76Pv41PY?%VpnCZ_sjiL8zi3Xv710EbbeLo46b z7vwx2I7CPNxF3V^v25h|729hVu}Lu!Yxy#T5Nm)dCL!qqwAm&ry{M}ud0mkk76co1D?2I)Mb=IyG2aM zE-)P(*tU%nP#3wVTM>-ha9gncaD?18&E2Igyemuj>vaIxXZAa>;Y$j!@xx^_R_E)&C4QEyEs7H;k z$@xR6TjhedzTG2L*OOf0Fh{IvIK&+5;8=xaUAqt5*t=G{4bK!;k@ zIov@>uFmDG=3KO({PKU8nAL|@KhsMJwu7yG^hdFEd!$EB<^4izH;sD14hckQ`fJ#a zn%-2D(L?z^_E9Av!!V7vTJb*_>_wPDmDmSDrZApRp}9n{{wMDzK4Sk`qI?2GfO@T^ z(#r{4|~9wx~|Rcuj6D9IR({bGcc{*7F!q?C~(Z87gOK zcK(BOFse%?%B!Bc!4phTjgWU+=wOsd{yc;&-62?WcL~AzEJf66KI4hya=DmymzK9_ zB2V`+=B|X?4bnv3E10(w@*bGX+lhG&$Xg@nm0vLi)~{fAQtJRPrZ(XF{~ zG9}%WrK|!t9DRlGbm*2a7o{BK4@BAIv604oH;2cdY;$3@hHcq!jcqK~xe~{U&)AFk zytk9_joCR3g4*bE-Zf_Ps7?#atwO0(Ep`M%_EW4aXK=4*83 zz=ut$2Cd~=G7jO`2e#J&(V82he6}@d%89n|HKiG-~}u|o?c=l$P0HVJ1d|EO-5xe9pkF&9`rXw@h#YFVkW*B-Cuc4sN=T68$r z)Lm%X#w=)nyB?(uqh(YHQJ$y=p|$1Q&7f5s%3m6P2lf}&7vwrJ zizw$B>+AJVmCpXk&`NT7krV~h5uPZa#>z|AuMVmmDMv2-iFx%3lc&oKM(PXNI7U9K zdsQw66Z(LJIBXm|^%eMn-d(nnYFfv%s6EjLaU!lo!JK;}wD#V_C`Zzj6@!NbX6{L~ z#B(xYx_5r4yfQIr(W7Rh;u&=|`HaJ}cv;HxgXf?n9NE7%?ym?cUwg3qJ+i@c27HE~ zzXmiSqda<hO%)_;O#DW<^$ zpTSOs4|y;m{bL_dWHum&&Va~?%Qfw^bnt9As2+g%l_C%5eVyZLrp-=M50M?Di`b!; zxkFa)JiEXm%?ji5cX%cW{DSvWejO%((uEAMLb?QUxCl}jq)YP|@;RgfklG+^gtQ9M zZEuZ@-7pU(2DCwz!VIAe#CGqI@RZqs6jHQA+E;}-W8YHL(0k+S{wT$nz+W@PY*F6= zoVXk1>cjw2k88Av=OU)O;~*>VrY?HKdE^6gWwTRDC6~|A3p0v^2vKozJiX6(n!ECQdB}G|*BN}h*^Em6t z#nbC+z;D?VJ_}k3J*)S_hqUtsUwNtl)4K+fyb1A~vs%u|!DKJ?eZqq$>{e*Av27qN zY}&N~A{Gm+fw|wQn&El;=^roar#$_2)FYrx#F^zqterp50oE3=b=Ei!QWxmo)ike0 z0PTAdYwK8om?34UNQ=OB0*CSqT4d0R= zo^Z4^E-!JFxp8!i=8dC8S=4HqVo`oHVNp&*Vq;zl5qnsQ-8aa;o8nU*0}rdGH#UnJ zwcN{k37_Pp&}y<5slB9^7Fr7&cUb&c%HsnITAy{k(7n*dUq=^>^sWWoPbz`f4-x}O zoHJ>gHi3_{B7WY8@w&CXbLEN>#p0cQXRs{S2^MCegRCf3V<%%akz_%*`y6 z2)EdHN14Ad9eot)?E>pCW;aT?;HeY|=&6**-cAa3J3WM6V~vf=B`R$%62|+pYNZA! zr5feHqPLG*^gQYA{*N1AjK)u(9Iw@CBKY!0LqtxbXwP}%rdQCC9|Ec{t=CSWvTlOP zGb89rYF0ZJcW6Lf-C4@h5XT%57x=*+zRg+BYcp8shkizM%So=Y8uF{1Fn_0dW4JyK zvHf`|cK=HM_vfg;w|h3wc=jZH8#{0X`0Y#TFFeBO${oV#rH2LFe*!8L5PVZaT%wlF zCb@U2>@t&kZMv@Io?kq=Vf8Od)afN#`==M4*+eUa-K z)xmSQKi$CC3@r(}pHa)I!5;LZ^giuL0BR;n2+QR@19Nh`UZ+82Dfa=y6B3p`5klVu zW-^!B#tJuC8>_!~V3X=}&p(JVdPF^%;}9rFXiuDnfj^+_*}ukCPpTHrNtNpesp86z z+CxI>hWwD);M(QP4}{b&NxRdd?DCMpvf))4f6(Vx&8IyW<&yIxDnCWYx=O#L6!{CW zAdq9YNAtem4(68-`!^t`X_KY*j^8=dL^ncy-pf5`2X1E&#sXIMmx#$OXLCR=klD9M z%Pw7MIC5#MVT*It8L&a|$dV|ZerR~fvisNt2Sf9ZudK;>8N5e>cUgZ*`XBq!m9Ll^ zbf=lvzcBo>kgNY87Q3UIZrOe-2R}Z~s|{<3U;qvy>)nmWllwP?`Is5W*QSxZ8XA zV%;h3L1MpoWW_#+KkPSaB@jQryxiD?eIQplBWQiE)1Hsj6W-#*{u~iYT#oA_*uvxc zP{$F)))eD{tM#)()6Zne?3=$voDd_JXSOS{XeF@+B+ITp4%)VHCDe;&2tw*3EO5f} zI=fC{?5srX38D9*G?|8RwU!GY2cWl$8o?gXJlRIhX94X+lV#p2)9%KC_VTK^~Y3ingt}|QlI9@HlB-E=rPsDGQMN#&NR#;jX3s$ zQ9hXrx54;D7IQCpsJxv4D(0oGbEKvp~V~H zG(8taOlThBi>=d(JJxim-i3?6%h^syuHpVdwiawT&=f*@*JG2ipN}W?i}>^z95M7Z>-KL{eNYh16S;*qyYb z3$AyE4G&KAlVAQzQVe}9#50MLy@^_HI;}T)9Z)Fsn?5Er*5AOX2gBB4YY#lFIefvX zdj;f;@!cU1?^1#FG0MAYMws%Izl_oC4k_tDYM&n>xjzZtT>Nim4qtFFSo7AbSDeXL z)%)x+_W7{~WfyhN;!s)GB(giF(v#Qd$%{zM`C)eFWQoqk3(;PK9Hf>vw>yD5X(@QA z^d*w}ij#EL9XvN^Nd)`CxX*W_%oC;ycxIRR@s*I*$w>zJF!f}>DD!CPSA9(0~YRpj>RK7#rER_xf*OUK=2vPrO2SNiE%aQsq6IY@~cq zW=}UzjTD`g7SNRg|l}<+)Txkj9FZ4M_P95pRdX%sJ zB`Nv<>*~7Ih9E6ErIv(gh!muSxgpCon?m*W^VXKt!d&5D;hxLSBrrFH&zG1sjJa7S zjPPP;xAPS#N#3F}%g30i%v)7<-SH?-4n%pjaFqOYS^qZ;hEeV!0i0?Aq`=W{CS-O^ zz%a@xU-vQ9^!mSqp6s2Z01Fhj?d_h}T0PN$-Wh0OccOL7R4xtag-~T}JWj<(HdC2> z9#^i39iS~$k`6q+1m7-aCEPib|CpkQ?mP(G8P`Rqi5MHc2ZMES<)4rPU&r(?1A5r< zs9{uh0j*3BXq6^VCE1Yh;vf5FDi%^heopQp_mX<@AbEs5MxG>_NHb~C{_UqYb0JmX zNioFCjUk9S9}crczZw~MtNCk2(~esZr;fAgBWbhJoYl@{%hFl3p&DTaYl>WvUQKi7z1&-I zshOD@{>iW-YWCC_|)c*!t%DAp!ry43sfN#VqnWdBHENh6#C1D+C*GKY$Annwfu!DjhqMcB`CMm;`nl^KsrW9z(+e~4a zAL%vc8p4)bHk_8rgssp5pNn4GhJ4ohvRa1w^C753Fg6ZFoQ&6*7CK&Uc{^dZ;Y~c9 zTr-3=i+?M@SC(~m%(4Oo8}qwV4{Hr0Bo~LgMT1Oy>s*ARq@OZ${t|@eY2IOVytlS0lok$oXCB(VND;z41K~SW&=`5B=kq zZqqPmLrke=!(S4b$GJiPp#xfcc4#VX zb2LonOwe}PT%fKt2S8z4LQ9Kep8GU-kBy{^Z11J{uY7~!fZlmi4d+6R8)=l(TXJwu z;g+1SZ(lnUlhJ!Y-byqHHG_mM)FiYjGbW)3K^JXx-r7AWp969ETnO^n-y3VA9NP$E z8$SqR>4|Gr5P6)MUHv7g?Z;qrZ$;WY*}E_4Mxk<#bv9^^|1(3|d}2kyAn{ z)j|^Mms_yaIkWq&iJn8Das2cOWnKMc^p^Ch%r%xO3$)=tjK2na8rUF2wlHoxp8E#P zS7i$S0b|3?n07=6dfgUhK{xc0Pl_)A?i<)dnM(?SuFoGOm#RidG*R9{bnC2Qvon0Q z!}*6V*#-25Z$sf;_7%#X1v{#@eFk>41mdC3Bjl5irloM*y{ra(i>EzbYW^VCfx7$G zp7*p>giRg-JO%TeLy9(rfXC-`tDd{4t2;uRp$J(tUDg|gDA661S;NCJ!_;?&)T48j z9%Nr0=bEE88f^upyR9Q`n^Y#$A=vyv4llI)vD{h5s;Fc0tb4@i!AfgCsGvH3+|Nt9|#^fj4FGv9|yG_TRrt zZ6)?fwSW+XZ^ZS2;Zx`Z&d+^aHtK=j=x%3eV46@}zU&f2hL>vA{KuuS4Uit$qD32E z551OM+UrBSXt0fa`|3d*XbYh3ebe0%NGh3S4Fo|}fkJL_*ty$lzl8YoV|?$5!TAYBZb+?^$i-)BNwkFF!BQ4p0g01% zF#&pLIB40;?U|EN3Hce2-w?v{s9QW1-?_-lRHJof3%J_LUl*nepLPaDBD-!=^9k(XkFO~ubgx8?v8zxXgz^z>OthDM z8DR1mt(AoC8m$e4*{i|W0sBJ-2oIQ%xX+!2m@WSc2?$x^`<=K`_1(9B8b#U=(PU5U zA@++9Pw7LLe+p)2IK+j)pB*KNV|Zs7=giXf0m)T+ftgeGzDSsK#&*53;=5i3Q&L)X zMh5;T*y>xOxcBwlx36D`XxE|di2X02g!mrVci+A^cCHuV=kVX#@5;moA)sb3q&;Ku z3{dPekI9}M(oRej;J#jEdWtJkQTx~8?xFp5l_R2meR}LX63ng~KaZ5o;&~+F_1Z7c zpx`4_sn)wcSKp_Zib)0!^LzV_hwG@!5nN|27Zd#zZZ14^ClO|-e|#q~6XNr~zmqt= zk2wCm^*9~*DRiV^VlS3rlq{U2WJW=LKK$o3N+ygBXcQb9?bEJR8YOWi9STEC_CHE9 zCa5ZZ_c_1-hxMNZ>i#WdGn7N;DL>rXkNT$2CYxBI@cYCD>}63wSqy?@OMO#J=p3}xeaEA%N@ zV|(dS*R%}V+d7(rKdM;?F$~hZU&JGV)E;g3OsNyw@YVkIZ+2G;xPF+r+qVQYr4M6HK1HNO*YqHqsfY|e+gRkM zMLD5r81YX>nXq=%qQzgup-n}DXCD6w@xb-jbCJ}~s#Sx>-~x=Q9~QYCTi&w)AA{tS zH#@9OqTJhvuuxiZ58}Fr{&;Pa7&>L6?ol~WXVGw7`PJ2JVLK!*8B)>?bNFw|pq+&$ zBT40wI^upoOsykx7YNn>x%lP72|LIfE+DB{VA0ab@C|PX{N#l|FN=*90>*xe^&O8M@GJDv4uyQ_sqz{XqN zi|w&yD!F}O|M>mvzH^bZRCp@ERn9c;smKlVWW-{DnItp3TzH+|SwA!0LTrbQ@MbT> zz1}xKhmGHpxhK_yUfh>X*!i#kEgt?5tdR|@9nAx)aXT#v4mWvDM)ddoH0xx9Hr*+l zjOae=t6VE0Z%GldlgecTVtLXY<&xMiy>V~UnndRmow+N*dOZE+#O8h@yRtu#%?f}Y zLQ5JF03 zYY~xw=iK9G2GAqN)$4TMAE!vRL&)g(V88(OlnumI#WR$r2UC2sB*km-IbcpL-k8iB zr~fuYw&Qn#94hs4uAs_xP(I0P-kJC^_-s8s&Ju zvF!lN!M)&PdcYH*4D#-~QJT0WN{p@I&+EyKO_>kLEX1@#cL4a0%*qP12%CiWpj~_m z2G8L@K97;;ZsCYfE!-*m1UxZ}UvAm=Mf`M4Qw>sj>iL?RN6Pn2>azIhnr7{EO*87V zE_LCv84#VuR(6+x9pC~S%-GE%KiG#fCpPD>#57h{3mM86T6|^_EBy(@H;>HF%0Ytj zGRpPR%rNF9YI)AOvifSlyCilBCAk%(31$pA>f>?>KG4nk{J0XC%ebbCxF^`@IrU(7 zq+s=kHR&br&DQ)WcA69rGzSqWj(H~&#T#sAS z)y?u_ERYrGg5Ts2DIhl1_wd9QTk!3h@$)Ac%6=K-lR*h*T=Ovn0_-rliUjeUq z8L*vpKkl?O+ekr0_3wM{t|SH6uR%1;m0+fs?Zj3C^ZbeA77twXtbPLIrBTGF1Nk2{ zffS`AD**9sF2*5NKSm+c?e8^?8kA)tp0&>5(|AJ3M76h_VlqJr#wFqvi7P^`c}e>3 z&YNW@M+f5P%_gLwX&;{MbErlO50bD_D>sQZOUU&PLs>PHJmrTts21W$kcc0HH}A=T zNJxWUJb-6W9i43P>=5;lf(}_o9vi9Ihgt*ic^tSQFtQxld6Xa(a0CZr1A}8n8W8W& z$*}{k@S5a5BmJsZx|Xr$2T;fUuorQevk&|G>mbaP9c9=Z+vB!YRBJL;_&Y1axiqtP zZ3;s^g0vVC%vMRvi$V|1AmA5WrU}lvJN;$#_$8ZCzYX7Y%eA{r?)b@4(d&=5T$Agk zu8?|5&Ws|iTT z&zoQ@-}HhfTt|xgq!RI%QVCm}c0a{{$xDBA`Nf+!ZcJ@sfk ztbQim&%2{r6^C*B{TDp_m{8>qA9H!?@tp*{lmeb7c$9;|WX}hT_FavQ$+39HEEuOi z8l?=S0VHr4o~#oHe-!vjf*R5XE8_4wJIq3A)pYG7wNcwc&3w)wdg_~<2~hXW!8({3 zH(*A2Lm0m>axP-lPGu54^D6&3t3v{LGWGM&ZblMAzL^$#_Yd33P=vvlUiD0T>jY1c zu5xYmHw)$}JR=*ktS32qx4szq{CuFqKi5SUUkYjSupMa6kH6RQ*J)Gtgc}8}IQ`Ta z&@adVI4?oD!~c4eGtCxXtGK3{@W&uN#4`p?X_v?!>=QGTX9o!1=ROnHNgkOLPQLx% z&SFjxUUV0RGn9u0M7O1r3J962wY>*m(bESXgzBRlm$b4>7;k5@OzdAsxSCfv{Az7* zn9^pa=@cnEt`hfWy=UU*MvnK=!hi4GBeN#lwd8DEKE4Uk=Wg&*LE8zi?~(|AD9R>x z0p6zW*wGB^>Wrko!7+rX%2&Fx z04qx~MDf4a7ZDLJDj#L29HgvpIKnJ=zk6>lVC7iVi*4e!XIAz?pJ0W{+@uw;Mw{K( zo1vVR`#>MP>@sA zZ5hhXwDNWj)?l?7W-aGmJmyzkRu>Xo+e~%lwYVZpasSv=(twbfKM{$YWi8q8GIE9T zpQQFe8NqU<70WHq&chNyQ?1=Fz^{`)Pgw3A^{BYdN7ZYR6I;q9hhPEv1^b%>vk&Fb zp<4Huy(=VySqLpG7ff0U5T)3cO6dJ@h1jl9%Y6s;+5u`>t4TFjg7sW$hSDAB=uGtC z$yc($Q<2(jZZ$+LiEh!<7E(!ju-*iIKQ}K!c_V__j}+Y{d>+aPC$#O0O@G zfl!ShCP*W2ABJ=ok{i-uNDCp&g>(xf7o=QBX^E{=h?{&j8rMr6u51<_u9|w% zmA2sfaHyAOM^G0L<(P-~)>X~0+XE%SV=mMd?)Yg=gtBV$*XfCkgo%)scRBUl6HkS| z4U1X2Q>bNF@=`c9-*``y7d$|T!e4;ZEY>(-w${>4=&eU(EqDrR+0vwykGtGr%0zEp z_YwO>Dc1U@ec0E>r^ego$U12MxYj;L?v=3h+a_B7E7ZJS!nQfB?dz{p;d!dLQ}WZa{I)*aZ77p)YFWBpp6&rjoTWsySarasG*D6vEs{|;BW zvT=wMp6{J&WXMLCV-BuuaCJhO4ao|Lg*21^cG)mt-x9U~58PSl6_$8TmAnE_u_e<&7plP6_81LID1b~z*veH z@=|3ZQx5)N5bl?a_*S^ZXKS|9mDj%^8x7A^*hzTY8K`Rn(Y+bSpIPsCb4u7qPr~0h!kZKM7q~7Q=P0Y=?5Pq%6 zDVQeSy>>65{J-~Z0NQ)>AgB6CALA*QgXbz|2u$t{CCb9eAYf(GC6sV%X2m$?8FiPy-`46s5`9DYbRQA!1t`CIy^t z(N$khFiz;)?g3hGz87Y>U|IhOhI{a*zhqlyDvu7~JHAihS(r54gO>YWl}%J9pU|xU z+NYlXeJ@h8SfLc?*^wx~Osy^vR%>TgM#p%@arv2qZ$rx~QYD1jBU1+8E*RuSqLaJm z9WHwDh1%sR_hWj4LuG$_IfUAl1<CcI)@yDa**sK59|?M&tL0Oq0R&pe3xq1ZMfpJ2%SfKBRefY0?8UtE1FaL=y66B7~z zZFy0m-dOGaZzT%gE|}vIRRt3D##Iu9P|$M;wzdLVd$F!WsDhqW4f6J^9=u0cx&@wQ z^x*6xECu{&*!w?&Ed|)x-e(zx*g-ZwhwDv{QX#?Y zO_5c7OPlaem0ozLa)c@}MxcA9i0|j+i}|AI`TNAvs_5bZ z)5Q$Y@ErIPA}1zz?+~hm_o9YmU%+5XSH2t|Hm0T7Ut5_!ni&01L=RYdv(3o1(Ca}L zpsaM@4wWg)+3X^9Kc$@nM*p{TG*MX}5=G3t+*$k_0gvHaRFBN!q58Jot%VcFM7d~7D?BeMmyh`cUgiBW5- zTgDO4#l(I-#D}rozns}5yeYF3a3D{SnM#|SC>sgl&@Qva=3dHA3&Yq&T@e?m!}Xk8 zTFAV(y=J?gvHdSRv+gzdQm61eFtB+EASjb$mqrgn1-NLbhEh9!sVm!lV+OY(@#5a#l9 z@?nNiN|{eMir<8t&k0(MJZni(aOyVzHi1o|b-}tur(>9DJgf5BX|(_Jfc|&iTzpow zw%@Lngbi}gfG4LuSJ~hvY8D~Gy`GnDIUFt3AB*z3?x@wc(KUBef5|+myHMV5(4AI^ zrzS9a)OcZ8e@6b<{f;@MflY9C_vp3@rD~evN5z}_b;V@(sONCBM0X_08|IGcE}BOT zm#!=Cj#>=Ni&)!m5o#9>?sv>84QzqCQgvqapZ5Q-c#Br|4XA5pk7{+<3s^S?>f$$+ zv99hC)XlrJ-!Z#1uodn~)#(m-{}1B*HbY%rZw5PnST88Q-AQ6SBEE$a_{Mv@R_pQp zo{ytEI~T@e9%V=J${$n`*w-_#78@Vs!KfoSHclL)y#E@lx8*&|D<45`bD?$fsQ%(C zsF|#PTdS$Zng!6`T&-R~sX7DtOV;|ku7_ehHdZgz<2B#wF&*!FZKubb2obWr}OJ87OEEF>1XnKPXQJd=&uC& zC*$v=j9j7Q%gdCIe--TzBX}>-Rdejqp zP2h;b+^-`rC$!IzG)ki!NDWVG0&_>HQWW-Nwy$pL%@uE+yF#5uZt6k(@)6*Qm;qi4 z9c6|s<)3Ra2!51;<6m2w8U`4cV=|1V=BPHySHrmLN?h(*8pjvMin)B9KLhp3(FmKQ zNY@sbd$d*1Q92BzJbu3vVQ2|o`&BQ=brfOl^q#!BF6a$<(yvk%xXmy#KNml8@TEGC zE}>sZ6p1$bSI9ApsJZt&E@wFI=~p{h@!r3_HT@R$o_%LqDXLAg;p8?_pA zpihe8`ovLuc7IHt%u{EEK?e@M7yVH(+wyEb1G?xBo=-pro&~K3dXQmW1l^??fy&N) zj?vl}zI(Jug?4gL4>rSpB--2T6qszu`_3*ToU3ljt%N!Y$Iu0 zuNBAeVB6k{{vdh2I@5jkX!Gz^t%Wvdfv3jNMD;90?{vp(m<7ZEYTB_4-LRvmd%vbP z>{$O=sBiTQ@5lb1=+U)y0k2{mBXDj8YDZnCoT!V{?2c)jqL|jf+Sfqs=@#t05$gXu zrX!)gVWNKW-`1ZySw9);yHD&#+!yzJrqxZFsC&)d)csJ?&EvHbuy$PYW%ndP?Q<&Y z>E4g^XOD6)i=Ze_2TgQSu|9-@j@Wj+0-7A*U`6NJde=nN(8e7jg!>&R?(lW@DZTKq~(&FBKF zt$*%NXRb(!faj`_=NOri+r^wf(V+#04oAqm(-HH0{chiqK0mb1>syI@m~gJH?EP{m z+1#Bce#||Uf!4n}LKe~M7ahtx^mQL|&$l7alr+d4jW86y%fO4TiVk&NHIciB0A7fp zm7B5-lMvUsRwZ?i1X91H`W(uWR(=iv z{%sDB+~tm^GHlux0{-^2mR0%l012FcHhuASH+SfTnU!Y2Ry@8&o^*=8f!S?`*54TN z`6=A-xmKlpNc5kJP-+NMt;{!M_fsFtg!VNN>`m9IpVAwv_4d}l1JF+~q;dajbB96r z!NhuecjpWN`%XUfTa?4yCD3yH(1uP5ci8Kip~_C^bv75qoMI?lHAFgT8?Krii`op7 zugm#AQJanjxdWXG9*6_zK%+p1LNW_-Y>qi_Z(HhEp7C_Qf!SXM_@>6NO_)M^Hh!Ya zs;mV1Fo5sIVLEY65NVVHG@`o!??Ns&hPz#$FItr}zz31TYE{RUfEzp;!Kmh}@ik26)MyXu5niNMCcJ!VzRfX`CEWhhFoI$M`f zIexP4vR|w-3l^}+i4s_4L!I!3(;g@k?81DY*U`ad|Ld7wae4`7ZD6q4&w>v%8~DdA zZQ^X7yWZh%ukVnLC9@MYa$fkKIv)_+ zbq+tS2_ki#96au)H(m_og}FJ2-7~{zVchlHjrey$+Up&q?W+^!n9jT^@^#Hlg{s)BdcJ3U6T#4RzDMbHEoEzlDfbY8sy=vWmQRNJX2L3y&e;Oup zmP&nqca0za4i@&8JJ<}77QNA+(!}A=j{$qWdsf&cIPE^VU<7zk!-b()xyy25ynN{_lg;$EDXq@UASzaFNJmI7;kpTOHwF z6X`tg1}rVk%jX@y6r*G_YOp{7|F`BR|RVmH!@4x?2r)U2X^d^E!buxL0M;QQj>@Si+}z16_a zR)|PrJDUc4bA?tp8~dw?OJx)FzRk|TQ=f(PKJdG^%}$TSNxuPm(ruWS)8E`{Vomc;R` zN85S?-7y&jcUhUHS?B?eQo=rC^!#NVv>m$C2g^NtZPqJq+}s*Kx{u5ycC4$Ai*H;#bd}i*k2T{xkp2?W z2VgN6Y*q#17NmQ^ywzSoXj+Pk-Hmkc)R7Hdm1`aqXS4GDK3?aR9jT)2&(rViWKH5u zK)GnoCKGJ|Pga5m{lb+`K69qm44Jo%NaadhS2XRA9BV@w5lAhBgnZ5! zkr|cM3?V_m*p^?F2|u7vz9I@iHacrZp%MkQ{RW^vq93AdfRuDERmN6+Ra~E^6W6~C z>RV!~`GyJ~`H`JKG`z+=pbK>ewA7TqCj8Z0wfmzYlchjl<7wq+$d zkZvA%Ix4$+MH&eTi7g@v*7niW82-rH_>)%YEm~w%v=z5y(OrYOoUw9hvE1HUmYd#R z4%#~ZPyOYtbw{-OR^`UT%4IW6w&O^5FQFPh+gT~rLZYB;%tmVhH5#!p+R_SWX&JOK zh0&Vg5j4kwt3Qs4>VP6r_o<=|(D>&)H>$?E$*vf0hq@0RzZG9sqI_-7!c8+gt*h1T z^T>R1v0si`yWePsQBqU`jAGe>IaugG)_h6}gCE_lHh5a9v2Zso^=unrze>kL2|qI) z8Y*n_A^SwozwRp-p`0b{2~!?1b%jueI)o>69>Wu0M9FAFjIP(ICbjnMz4sjoH6A}t`-+b~KlwGV2F+B~QG?O~St{EFj?>Gn}&h)%*j!Ag4RKlBB z2|t~Y@I>}%2u*!0gzh~SLYFstq?|e=o2m{;N2)`T#WdgRA+$om8yUz~Ny|dM{LLPY zg)(y*JS@`yBaa4;6s{z=RLsfGSGIE^N5IAET~LE9p7ar8Lqn=zWSw)(X*(8)R_=s) zymw__95QYSQ}Bq%*wsw3EkZYAW+vc*KYy1n3hF>S$Hs83 zWhC%;Kk&g_K6d82gyB$%KwWDhIcQC3jm4b#v=+emJX!-NwLDS-X|xuR8rVOdLN2u< zOQxm4qc*;KtmC@`9gNP4-vO%7BD?`%z=i>PTL@8(hEo{<23&5iLTlNM#m=uparHw5 zCdcq-+d}>BqAfej^zL^Fc^8gdId%YACvCV2I8^~|6>tq)?)Vs>fO`#aJ)OcPM`vhM zKzp-C#=a}oG<6x|U8v&AB&ujFANR1*;~{h>ty!d&l4uR(aB<8j=Kznxz6HJ0w|#HD zo$k>E7nl%CZ@rbxeyf_Dx3n_tv{<#Gx*W?8J#JDLHh1wUi{IN0Osx zr{B+=Z#U4lSSp-@a-}Xj0R=L|739f|y<|DvU-RA0gzuaxs1F|Fcf880m`8aDv8z&!n#udCi#x8j2sN{72*xJ=EgY% zE@t`D_%kmmqI*)DhP#+|Y{r=N=AoURhgieGj5#5u<9>?@+8d9)?4TuSnVD``<66U$ z9=U9E*nx0hOCeVeI6 zg{-*Kb*UEVeqE@wKRPib@NdaY&{modE{Z_|S0m>@lxx+Fnv9o>8gWKCEyS8o{&gqa zb(Q{BMSeSUbtu2r$+)z33ZZ+g;yQ(Fv4{x^E~cYU=`_|cLI-0llsPf@>y8ro{>gwS zTwq*Y8)yLA4}k55erz(pW(T>eKrW~9u|pEw zm6^7HIRh8Szrc0BE|57MouCQao1EB%iw_&W?>cPc9475c#)M8f*1ab@&@$E8kvA7@ zZx6hGt8X{={yXb%=Pc_8Vix5c$lg=hA?&cy_i&$CA1$vlIb4p=GttxUPFW8Ju!Px( zca-+FE^vgJbfvw2n?!nVOr-A_I(lD|zY_p+K#aevT753Ku@dcI_~~i7ha)R9^zF;@ znXaP{EQb0=y-ySlzD6tYl6Y^ETZ3q z+!K7+&=Gt9t>;0vg%&H*;`;rw=7dg){W4B_kZ~ zx6_@;_beD|T4XucCm$~DQMIA7 z(~U8ugt*Bm596^rjel{JyHHCK*dW%OH9vOE_6l)<3>HH@#zVbTP!sc^KGk$tpY#e~ zGXplMq))Z%Py+Gnlu=60NI+KOKt9W}vp4cmiKHv%IGz8~^~*QO0n(L$REwEsp$|&U ze)wq7-PbB!X=v9$K9y@}`)zRNT)1Uodk#EF;3-9fsjDHjiAD*y1r=U*q(JI-SLt&= zeD;dZyhB}~E@xUFGS!*(nltPixg@54y_@R>6xq77*MV+x7kA4iiM|HCLe8hKLcRp^( zuPbTK2aWvkzslPS7E4X2;JTA~gmLY3A9BW()-IvasHU2lI4>NY33xG~XLv+*Bd_G1 zNT6>(G~~u-N%>pt$&gw$OxF(MWWi?4AffZ~wUIMOk*2KbLCCo@?ff4&p^@l8dDem5{w_@L)x;I?FzG}+Ne z)?N%LEj=TvKW+ak#M(+pIs85;=8(}GqgXpl+1hh|_4IaHK22E8|2f3%6W>D7o%N&l zg#D5I5d4LPV#AH63h6s~8oR~*<6QIqdg8@%{vW?Q*|c%a81=yWeeOOLvF1ShiK{b8 z3J^$I*;Nm}(u883g^{@)Jn@)NiC#yr>vZ5(kUpIM)SZR_CVL;1daBlTpfm0yTuf-r z+d>(-6*rK=d?Q&=Y|*t36$(X34MCJY(~U&M-(Sg!K_~Ji?`nIJoVGaJ*eHQfP`!si zI>UBi_24AB>yW%LGb6=@`6N^stSdbiT(y=ned)$E+)kFST?Ww=Qt~pKVNmR-G8CFP z)0E)6wIBEXCx%=hl~uwQ&)fugI77lS)drJEZMrA;1@lf&XH=W&p^w+>!n{!YWk_Ac z7}9nvG!&U~OjCP(%RdUfot9$)9@sBKX;p|nmh)vO!9$vz1`dd?;fgY^dW zvoj6n=?YR)!#`41W%^nHL2PoPHkse2P{%39V8RtSO8~p;lB8X>x z(o1G?CMn;R15p@KLqy0zJ{?N5oz4*R^nQ-d_F6R@@Fv9!Ut5h0^$>S|85*umZB%9| zc_~D!9G($;W&L}-rm>k&Pey|H(KD3>;E$^`u{7u(`v)TP_Zk+WDG8y{Yh}xvJ4Z$3)PfTkztx?wjmRA#4z@wph1#p zLVRE*rIBhFV=6PG?JY8yduQgo6TDZXf80!KDS@_}0(GAnJi7kl-q(SmlpixDLTv$1 zqqY96Gr*{LA`}g1;IYPi-ksD*~PU_qm@0KQqVE zGKe)R4SLJY1Q*VZr=$l;?CuQH^j>M|2fbepqLn8~u`(DM`$@0A?5FiI^=>0TEqFff zZ18~%MW*+93+DmFGSCyL)Pt0@rtKcATw+=XEyuQvF{$@n43)#!Ys{F7AkdvilR=#WG@1t=HSUJRLRX0W{(AhGv?Yc~{7-I2}$+G}TC1nZgxH6+2D^s~Kt z<`#i%BWW?zb8K1K-rs^>DC<>8Kk5B;ZlQ^CG1?^I*X5+{rL?B^n({sfo-#*h<%+Z@ zovFP?Hk|4G+xQ5bg(4kF!_-?n{*&OFH4z#Hk;cVPnw3(S(pzi(u-9G_p|V7zLTTL7 zd$#7C-d1UZ#_WC?#^B8jANTIhi_kFk(a3)v?vihL+nQ@43pSc96 znSqXKPp+xZB-?}e^<~SzQx==18q|ApKzAmvpY-koZ!6)0lny)7wAi2n>w7otY;e&W zU5pC5$TSCZacb`l&?6Oi(kOOzjLBq3+?xyf@O$tj{s(hS@icM(lXK2{!Oe39D6ADJ z+|xT_`PtsX4~i6)F9&a1W#GULT}wXc9l3-val8a8TUows-U{f6vP{*6v{ve;(?)*~ z#G^Uz{W89`bP0G~$?jT%8f<5O%9&nnsT$}Dx@s5$k1$n&P2JPmunR*a^x5^Ud^J94TQNkMuEk^a*(% zBkUx@$P0ZaC8eG2lUn%}>XTjj%sph^KBKI&3VQRQ!h_uz?T2fWt}3E{Yp7uA*1_M2 z-6;QX4HBNQ_S7ubpK~*2c^__X|Nhy>Zu@e7poU2(G@pNeLP7xw4r}?2OCoo3h(;-tL_vivkEcHQMgXP^(Bgw zIUX$|?~?MH@m8Z%Sh)$o(-ET~xO@{t>_p0MLHo0-LhK|w!6>fOZTdQb&xJ1FYjCOY z1^g?_wo-iCHz9qNWmZV9GtR-IpEib^^IXx|NcK7GYD_hvwLXX4Uo4` z%!@*@qEuCi0)^PgC?p-ZN=&8YQAjd!h9{*AF1q6}K$(%>IDhjs(skD~e;ujA%uVCy zsQP}C$Byg}vVi|H5~N>mPU1H+bmqe*&R!fKbpGNveU`}h%`8B2_t9T0qz~5`4Z;r% z8pCp85Njn$CrN<&(dzGf3pe*q15x|zM~ zsPQ2G?e#Aa{&ED$YxB^g1NOxeMR}I0VkATg-P*2}cTz9M^i%1Ote5>)K zc_Do@w9D%2&Yr96Zpyc5gz@N@trvm6Id;wmbKaXR0b(t{YXK=ZXnai zEU! z!?c9!&O=7J<1h7}&qw^{M16<>{~2;c{L4}(_c$I!{fmOV3rZJ4*_7UJ4lU1p2Yv<# zUt3lD;|Qe1ETcFU;YqX&^za0@)_m{mbQK5^ZYjB!%m!@lVH4R9;bZcO2)-SFy%KOX zK#df=;o<8bZ7JZd=AG}E=@+6&{8pskw=zn8t3<_bl^x+ZDNve%f2|L3Q|3kW7N}0q zhh$TMat~1c$m`tYifK%vHC|)3S$|$*KnF#QiIm#tKzfO5Ih5INtaZ>5A+f~rK{_T` zJ;O7^4A%(Ix5eNY*7%*@MlsA4F+`}&h{F(1(*RG`o!+aol?p&UTbQwAiP%c?q^}#$ zLXTbh!%M}$hYTc4clBM8EtZ}jqkefcsYU?~eeb;8^%4Pp27jr>D4<`F@S7!aezOev z80kwqi>Z9@1eTVXVxk^+%4X`#rE(0BLX)!c}Rwk-W-4_fHEOetq{HS3jpKlL`UTHy*5uy*Z_`2beH9D7O?! zcDT_Pm5x4@-av3AD!tv@qg<~e*crOlzZ4izf{}PV-2jQswm+CTg z5RU0Ti-W>Y0prBFbYZxCe;*R}RHCzM(yUWg;Z17 z0Q9e^BWBJx{#qUpn=ezj~xDeCF_e z|D11g!tcD7<2U6O`VH?t@1K>I?9P}HQ`w1d`KA+L zWA2IY)EAcfEnAoRS6Ei~mzP`os}8U7-`}nC3woefYIm;i|8L9v{#WW&`CmMIr_c7` za{s~dAwJt`i~oq_S3cYJp+4K9i9XwuET63i;MoA1B;!@`aggU+D9tA2IYk=eVR%-# zFoS7++AQT|670+*X4hCq9}Q*q^GO`ypGKLaD#We%lJ1|Tmsr+iXJ#|Jk63a^& z8Bf@9`^N#4R25Qdn+bh088qolZ1H}+fqj8DGiYSFeYp`8mD}G9Ao+1#sw}sE7(mKh zndSETjM8RF3+>DGCTSzvywLPD83*yTN?BpLjlAQWPZpX>>}pbF&bL<)>7K*Je0x5r zH)fviGFL(LI-f*R8cBn3$oYJ76mnOAN5W*2*${loIsnGu;7fi10hlSKb-Q(w@=EO0vYaPx&XG zOZl94>bA{Zq-gV%D?Vt4GKF7xP5%JLLcnpCh$Dt!vlok{sh`etK#7xIdB^?(3?+c! zj-Oy)L<}cm7><7B&HV=$G62JEKfxeToTJ;3K8;WY~NxyzKedqU6yJfa-1G~jLB#pOG zyM0Rq+Q#-s2`s(YLCy#$B$+FS;9u3@%RG z6$I(zk(CzF{GURUffc{>*?6#FT>%;8v%T5jAJWm`Pjytq+qd{F+WvrA20i0F!~L)7 znUWDVo=W(O7cC0=4_wRhzf|y}|KK;H{I8Vcg@#%Tfs#*R$D3Z@r^^n) zxbbTevGGivm1_yz>5E$XVxx4=9htJ-(p|Ki?3v?GW-p=nI7m;}&57v=&kW&JEKSYe zS*|pI#Ba2ZIl0|s{C2MUdo!t2buac}FYk4KzyN`)Q$Eau# zP*4&=Ugzb~^}Ip~5NQcLDen%}J{w})|Q z63K2^>$cL{J5TT`z%HSCg+U!y{ZLnxN5ZHacrVJ4>`o-i4z&yC;JwU_L_*8RL7$)V zE>|!%=tcM5?qc*zqqHfLqzLo)ue=LIO9U&eq}DjtQb%-u<((tKFS--H8Sqv!x6Sb+ zp)FC5rFH1YTEI4nr#Zj!nw63!nip^?;_;;mKl;D&8V31{Em6NE**)JQMcEDmDF93g z;A`A1`AQcq0G?4l!4vUb@}?>ne`Glp0G2D@p>qBKme&D`b`Tb3_X3Xs>Lr0Xm6Mr( zXr)Z|T5SY2ZqSYY1FJ;Yx!m3$6^&k74Blf&^iRMX(Cz(lvf)1RdXHHr7UbT9U#x z;OXefPc%HE`J$UU^h8ERV`lRVPv^Rep{h2c$HXity%@@el19&`>#~}&c{z*}tJ+HW zB%IYar1@f~q)o%C@Q}u#%?nD0HfoyX8JfoQ=2D)+>5VDP6`8=Z=lt5lJ*A%M`2JE< zSnB!PT6*ppUF9>w^X1x7PZ>^aDfM(gD)0emjPxWNzcu0 zI-OkVG2>FtXKQH+_2sJF%BF;7b{sCQhjG!D*3=z2mUB>-bMEBHDg5QNGd!0cs`p&p zP{Ci`s0N8Dk8JT=uFKw4->s2{d;gzTV8s8{FST=eZ1w8~wlV7xyk#4Wx*fMhTB;fcyV+0hvuexs}p@86qI3VdwL zEakslJA}Wqaf|2D?@A;0^>ojMnP0pH^ud;8=R&HE8J=$+-rq;(FH~!oU5dt4Jik`4 z2gdw(iHU7&%wEY~dAK5z-BZa+6&l{NwvzX3pzD}C8|potwTi|&KV_O;B{Kb~fJD~O zhYNrstmT#HWwNhN_BiUpGiP{uHkf&SgS1bUJtw4IoY6chlh&q!|MtNY@Qfz#+9~`u z>rDJN8yY;{tb5$^&4Zc&&FQaw_8w^ycop&Y%OsX^5$Zrsb^QEFWL0Mt=voF{c_`{* zRD#1ZZO?fR((iY;_d}=(>Hgq)&bz-wJgsiWWJjB?LUA!vZUf1V+2UdF6CMWPmW%H6 zr@1m}17jTH$O4ZHS-?K^gtqiXu+m_6#7eg-6K-Lp9o|Te&EUzBC%-O6UbqD?gpiF| zzRX1_QwkAksAy;__4E)6f|o0DX!fAJN3-jo=g9Az>*3HZ9r>gbM$?q~u_*P`x1`?S z{mQ#S^btc_BeV|?pxT<`LVLCnm1{iNO60DgttjszSnEZ1>hLvJpYtwm!8-c(mK>c1HDHCljfGRKJNL-s~Y5s zF;Gx!V|7n}Pu8{#?A0&4{rCQuGg6;C*dOn0+d*w#MLlv7QG!pN2|ig@lG2FvTDVf9 zK6z-=2UFji8TH9SqW&3tvJ&Ufxiy?a-->59K0rqg^5`g%T7?dcpw^__lh%9=hW`OtA zKue{7Po~~l)93`xX#&sr>00V#&x7Z!1P_byNfi1HTdekP;ZeoddopW20axYhWubHl(O5WjcBcpm(8u-84Y~Y30 zCN&!2IUD2%zv|ApRZZdE-@Q`hW?Ei_$Z9FXMML;+!83mQs5x?94|!#*GbX7ube;0} z`-jy8kW{hrNT2Mqyib~5XpR`fZ2;-QU zl0H29qMJ>hR$Are)Y8T@Ud|@iqz<9rdD+^SUG%KoEKVFP&Hp=V1)50KePeSosx=TcgfZj4rH3({=^zdP&r zTRh8b1h1Rdxfr~Dp`)6N2Cu6TLQ$`>>Xvlhi$-qCl@u%14R~FJ>i|h239gIow9VAE zH-fMIt>|mhHb3WGxQ*`5wher3k?3nd55U*T@Ex6Vz}F6kST~b20|#$JIjFoP2V0^X zlqnhA0kPiG$zb1Sp!Tm~!1}j=r>zk^ZPsq>9!m3(C{3kEa{*9%G)nP~TT+}JrT7c* zwGoQlqCI2(MejLGSoFZx;o&?=A8O1UL1I{r`mJ722= zU%L%_?XW?%$I^TSV$G9xSW`FP8 z=)wgLe!&(f`Y~*dV&DM7@98-TNhgJ>zv5c|MvH}mIFrdGNSmJ~+Ql4UTG-XfuRC8Wn0`Hnyj zqk`MOZWuD+_T}_#>%AZuOZ_g6?z!W2K5hQQVG&Dp_-7+JE2f2#HBeS1E4+Qhjje@j z$AZ+Ll)=SO0mP%75$s>eT<$ssE`??XMR#N|83K z2vPlMt}mu>is~*G{k5aMD#T621tu|Vg_yRLrmevR4~S{aV%npSmeGR?0J7L19I{e~ zSA?vkki{b8NrUiTB4o-XJ&dBXAnz;UE^wy;OcLD(4!BI{bv_AE*Svbfe*`W1B($aq zL)juc0d%YAhO_(<_SvRD%Z`lT(EDsfI)9ty3SiUw+x0PgkT$wD{7ycF(e#_)oE*Ua z3!jL8ME$Iu(Nq4O#`6$&0iD%pq}Q2xiBbV-tAC!-^8vM#Qlpe8#hM$RRG#*C=EW%1 zLR+r$f0G}hd_|<}wgTnV5Jxld)R{4?Mb^iVDj3Of#dyLfYDud{knfxhS5+u&jl^f; zft#t66HMPvfaG$4vmF014e&JuXwJ;_AJ=OsXG@}-6#{f^lru#{9%suTZRWc0JC=07 zLOD&N^#g99^b#5^N+BI=Ps+l5qyL-oo8jR`;QpRZMeer&_hT2$>lw`tmYsSep1@k@ z8_?%@sq_G!Bzu-nY>|585wCL%t;a`Dk41DIJBvUqwzISjABuH=dO!n_-wOkC$DmNcK5@;@`xr(T`wPu0BR@2wf`Pt((Kl=2MEKwaoFt=cN=)<-<-e&m0!#bv8u&T4o^^1=_o@^ z16aykap|)|To1z=&_3|6G{<+&UqX&MActxs%{80a^)F zcEoBBHjDmem*|=HTcK~r18rQrBh$=Z*?=c}-Oc3nI-iMnz8G}MeQ36uU1RoiZb*Zc zH}l_Y*pXcWBdSP#uk&}1|KTrSBxarg8Uv*+Q=Cxdi31ex!>4!-#)NX_P@k%Xz9CHG zWl4o5Bd=ia1QVG?0L$fXoeKcVZzz_-fWbZ6&5bzcW=1?9NM(S@h<6<5lfCsk-ymaJ zUgBA)lqV{>#v>bg%&Gjh8(!xbrDSgnFH>dlNz!tlM)xDi<{wXbp3LwxFhsnC4=JAS!HR&h|16HDcD6SW zx_1dnu5~k5Eqvp99dclZ%Ei@CKCCzhzlEq(#lG$PtDAX`<5jY~#z~!Y1}PQz+M1*y zL&zm^FG%7<0Lx#EP+l0Ld`QIe0*pQn@LbXr^H%#6vfDVMli{D1LTQBg%^2}dvy|hv z0$4@A4SEK~YO(T^51(_dd9oepjG<8|^3C4Bmsi^eaMjN2SYT>4mUT80I*P`&bjJu^bk8oNB%ccx0MOL&N)byc?MPX(l_!R&bv7*(!WY z-{+xnax%arqu|hbdWt7E;*Eej8H?w0D+oQqI+c{&IICy{nY4Ow)^%UUHjD4D^1API zONH-4|Gs9fzk zJgL>o0v;*F^TAZ#kx5g1M~kNVUd*2Adr7&*cX$fmy)eypWXd$((Mi*MFBVPny_7x8 z_ebS@zQc=Jy)r19K+Afj`;IJ{?mIeVy6?qF(|s=$P51pVd%Evs<^8_Hj{uIFU0$?$ zhVL+3M;@8sJGy9w@5L!Id@oI!;rnCJ4ByMyGkmWo--SHyc+n&8dlidLd(o8ly|PIi zUR3mc53B45E~`HbeVH<##u^w|;u4LQ$8O`Jwau9+mQauL}4{TAh1|&LOjo8J-VtIgDTn-G_OFN(pq3ZV_Q!xR@QQzpdJw z`MHYFoV^YuZ^e~l9s&LonFP2|v{PNVs}pJjzp@Oz{Y961n4<5TuB!&Kv@1w)j59!w z?z6tZBj&n@0p(5?G3W;{(C_Bra9jZ#!7kU;lYvCgp~tPF*64p5Jp(rW`b2?rfRjPF zI{;&$h>9d?^(F}oxsz0ZnP@@vhxkV-x1trJ>Qd|JRDF#OJqPx z;7VKq{DI^#NS=9EkCxhpk<&X-nscF z-@6n4gWC)t`L-`7n(56@T(w%KDSz zL0gEO^em(eyDh$@1ScNN7OM-s0a<90v%XO477!}WDPnm;4lVz5Mi(tTk(Q2?{hGEA zmE>rH`HC|(YcbF_zS~wc{kjij)48_>@~G)f%bgbEv&_rR5V9T$a7hj?QsTxLo&_>I zioW^SYKLd#Y4b*h96ThQPl6bU{S@d1d`pWm9n2r+km=<69%c%@3sNT?2ym>)_jS?t z&UbwKikKsqf1f=OV3N;32?ZUiDTlcRAT%|{#r*L>axeJ|tT2LOx&PIfM&ApIzU^Ta z(cebjfk|hgxOwNldES4JbI|f1Qa&b?`JNY{f^W$r=8tIj5MuOQo&+2f;c-*V(5A|E zpF#At*Une_UQkMau60Zj)KwAS6qm^NK7%sgQ{My}%EzuXyn>rLLa4(Sw~HBLFN?}C zRXFNJc5(=#{DXMV{5rSY)|R+aMjE4gnI+p!9G^1`>#TmYbtgeL*&9c|@4p{FibPUO z+Ir}_;ttwsidTKr+bR8WdmB9A34-%ApFvC+a(*W0R?^Wl;p6V#l_xdVnNC`kXNm7m zEos%rf=lWR@0D#frd?!-x;ov(=#rZsiaXO+-S&157k8p>s!-{D(ugl$o#!eVx#UxH z$8R0yLd9*CVM<$vUyYWR)Ap$|$!*KTS{yD`*p|@mRc%-YI(BrQfzF-L-m%aZ(YRvY z{??g2naVU_OScqyFU7^{z7G_tCWs`Ig#R%V{n1T-SXc z0%le7LQ+PM1$awCwir(r-3oP@kkdV%bcuX@DDp*lNo*~?x68u;70EN37stR z$zyVPab{1JGF7gzn2S#OH#w(dm(*mLO8tc0FK*hd^14>9FaCwKx8AH(Pc9CvQUCg5APgChxz^aP{{`-QCtW2Z zZD}#mRY7VPUnVeSn{+Q-gGVBJEucg5?WH$S?|krzmmYQwwPi2i6*yJ!_2~1HIv0U#xd6*?&2-Ol zYj!T7(oSfqH@7-dU38aYWx29qPj;PYhUe>bOJM}?kcsL6GpmvuE(Y1HN)?5KeS=( zeJg90EdI@`Y2{^iO&Isf!aQAey86=xszQdvkN2_W&QP7TDl~b`**?xZ4^m4ezuPA> zr^3@XNRybm?ym|Ji%&b@X{7ix8=msTr&M?v9?5qf9UF`J;7J?F2TvK1eD~403o+js z7)eL+!ILVIZw;MC67#_m8_Bm?W)X9(CgOee>Mi0ub@f^C-nB}$QiQJ};(hk2E#f_O z)mic0wNkc9gs&vxefG*N;yrccS@GUwk*yZt79!qfTegVzRLfcM{&7Gj8-6qCJ9dp! z+>7@V{GM+&-;{l4zL^BA?i>2;&~LI}oFz#MAoDW1&+V_*2f||$yQYe(x>0~dk6j!4 z3U;Bq&#(6HR;(L5a@gHQTXJ8+UqdP@o@6aSJG1FJT;=UWD7drXoIANbIk*{PeiKe= zMEax_+(`HRM|w$g#UEYiCyTE7qpSbuNet!r54f`w)-5 zKp&zv(Tk`RZAXuw)o2l#f+nFNq(j-QRc>9%7Z2?3le~U$DDIOkVfrNL*)1bUm3x?W zCrz)p0O`!kY+j~R^+`0PJS)p?83%B!cJ|M}mz3hh?3OBbmNqpGid$mvnWc;f&(L<= zKLBUq;1Ur&M7uK%E{TJ)B0NnyI}Xmq!KETRMVlH2m&U7YCP%aD_HC4la*_D?~V_?OHPcSH!`UB3!2383$L!!Brxh)y|HC ztK#5^BAn5t#=#Tg;2MBC+(_HCdH}A8gQtjaM!Pc(o)QO7ZKUTCWH*(G@XrB`s#2RN z9CQbjm!{dWTe6x`Ry*7>{MpcbeaxMxPg&)^seI5Hx9K#ruJ$&mp$G9!Gfbhy>{FEqz^)F-Qo z<;ZL{@R+4Jc0i64+(mQLpgu_z<$~rg@NAOE1;EpOhK7Mp;wU^7;6v~QfJ>k}6D=u2F|oZXt$vY@xI>vLZ0kg>rLUIf6t* zV+AF-B7U8DD!?Y)2rtd0?`e9S)es*%V}|H}hv;C^dXv(D`6rW}=T*$TAvPNol-nze zN)w)V-PsY)$01TO-;`X-hqjjX?<3zcl%5}8zKOoW zIw%`N1!C!7taL}#Ql7=JH<~HJpX0`@+uLN(7`M_v&(9f%a%Fo@SRjT|(kPcRDVnE> z5iTCBXsjQIa-S8WTsayM``#rIFR|Zk8EEeUmug9X?Ymq2-Uq&T=d2NdD zJHI|Zv2y`PR|%s6uDOWlOyy#HOHaz&w#Ovk`}j zxt3JnVRtOL?R^%b`X7Ic#kR?f;#fcqGJ;43jG0PFx$|&9jS_8CTAVl#f%!G69BzE!(~dd4o=*C+i3{Ig+R&-f|wKB>H_XUrn{*6l!_^aHjJ?UVIM5#zIc znCr8(=75!~1FQK=lzd_!4WyxLqITTc&B~z#KCCx#B+DUhem=mln72xl&0H4!di%j{ zX5|M%5B1@LvOZ>(mRG3ceON!8$4R)v=cM1>prkh~Fj7#{vm8d=lnB#rN^T^9tr$EE zdt7Xp>Cu+)Jye}h!C!)wY4hdeYsArqvQ-ahuG;QyMxEHCXy%F&ZK@_*yw&*FwWY6U zT*~q<#1>LSTBxF8Pz$M`g^qS>`t^9uE{xd3aG3hG(w<^vY^LP)i@iRS9sMGvFn%pf zs<5v6D{oqCElu|K6b55)TI#S}@GTK@S>L9ZWcwns9$Ybt{_$igLHcbGF~O5KA9~GK zQ54C0l+b59J~l(NwDpKt>-Yl7$E4uASnb=UtoCgc>jB!}r!uPcZT}ewoVlui9%Nuv zD7ph7a)rZTf9b92U**9J8`;APp70pf&cL(PbE&r8b7?Kzkq#Bmy#mC2$OWr#H)4t* zZ+@f7om zb-)$f3ygkaI$TFmgx7t#6Dh*;es#O`p5CjDw<(SvdQyZPKf!Uc2iYHn^1p+N(n=95 zy!GuAVK3x;(ocKtYu9V~QiNS6BhYPr3VphV`nw2p9zY*FnIcSwYbsooaBYUG;bi<7 z2ASn5!WPt-?kABQ`J3$QaPeH&Ji0emAz*&cuWN_W{yO0J+Evtqo0ETLzyB1Wj;D2b z7C0E`msUvQ>hcWKrPqeh%Z(Vd!FARatJ7T~*W;i}PL%7un<>JtqIEKe(BS}0i9&k- zs(?C-JgEVA0X+Qzt|IYQ{>g}}m64gu0LSPE+o7oIOxR}O?3G##L4qQrBaXLeJB;zC z2;ClJpWHZbrh=4kL z2GI;(|4TSV=SLst2he#B>Re%wi<0BU0A4*i!T#N%Kg{e#zrBj=$yJ_l|c2!A=u);%e#rVFptisnH z?61RasKYF%Ls~yyKdtY*{du=Q-pP>n%Rl_{^?mgZ@%0T~-xiMWNVNb1rEAwm{xFJ- z_;dQ3N@N%t+4m3H476?`&yoTn5!U^e4B*&8|1uVx_K7{Bk&E&5k&bmHZFJO!(P%BpGm8LSYs|Z*RyaExk@_YqpBu3Hz zT&aBuoVNN+;pIKp6oo9DckTH<&2QS-l7)BSTO~*`Vgq1KZ z`m5dq7%|M89y(Cc!ZN4Rm)L6& z3aGI%UoRw!yI3gY*!6&*mX`w^m0L>Q5Z?^ulLaD&jQ6i@iGD)+Ln}dwXyQuh?`R6g zGw39b6mkbm=b(lh)|h5N2~W?2m5Nak$9#BZ-4j;QdLw<6>0ouioC;{2Gj61QyPJ#F z%;UZi4nH>F=VWzhL;+(toQ`%55wL`{o3(4Xe3A=3fepocSp3;4$>q()Qx;LnZ6$Uk zc`vKYxYgdAS(C9dQzw2QSK`Ilhaq0##Qkj1D9+B27$#(Ao*LNyT1rv_>g3xx)5un$ zD3W|$jie#0RJtS(CF+D^SutRHxO=Vk&u)}f_iSB_jU8E#~Xo-y^LAuP1Z298( z)4A}=))PA)<-h0h`FJk>r!DrBiflJh4K1-R){$tAD9kjF3fjZD+Yl+f1cm4k*y!2gv=v_F47$__1D(;tnn7m_|_h%Rw$@z~AVqW0p<7KtgtE+mRO3h?d~% zrme?7@5MTqT!t_qJc)7P9AkBZ1?6gLXV(s_;*pY+0b48_utm1t7B#}FzQ4D{qn>zM z)Ced2A_sM4mNWq~oP8O$-6%c3$$|-SJ zgzyyn>&&|S#*kC$!eawh#WT5G>q$n|dUM2s#@m!D`H7!MY?`@3?3?BNeRGqjlYieg zC;hZMlS!B552=Ey9!RIwZnYXp6;| zn*nXBovQ=?lwz-`w!jZ+r-Qss0%$*I^0dyJx=-Cmnj&BYXP?VG1-(=OQH;O=HKQod zf%I~OWM-><-mubPC&fM275b-*zjP=@$($ew8%TkpUny%`wc4kR%z2G4&Wl|dVT{*G zD4dp2QCZ1pu2tHISIcqJbgiAP@|o;ajAERWM;34xfa2zEJ&ySljWFD&5sW_iJJN$x zzZJ8^&22;IUiov1QDi0wzT!Hn*#eNYm5JE9Ri$CKSU z5*s7^*gZgh1CTS!Q z(!S}Dz(}hAq&Fb@nQ#89u>p+>X!M!vpi#o#M+j@W#R$PMI6@e(Xen5r0+`|)jrTC%>*1~Q_9I!ee@}dlh4g zSVLB~u8wh0SjREiM^jHyn>kaIcv?60BQpC8PX*ZMnGl_gl!X9Ef6xEVYg9!$mEZy` zF44D_Eyfde8gE7-tI_mCjsd%lc)>)7hoXM4zZOTp526tRxkUEVSD7OO5uEh`+&+0^e)1DR}YDvTD|a-4k?x6P>UqMDx(z*4Q_aSI)v zn6;LSnOXaRjtXomlb9ezFp6K0B0YV^dhu+I(bS`IQ9IBG2Yf#l>w|Uk1F^nF$np=0 z_5VR!q!E7Si;mpIdi8i1(L2z!`WoRkC;s&aggH-V>JnY2Ecdpvq$VT7v@^2+D17RZ z(2*Lg+t>^;*gI@#lMzjMVnD^wvotfo15qz2wHbiM+)&6;K(H-`ykRLL$VPqI>ptnA z^Dbp=F*$|Y!tXIU`}+YOxvh=vnKraMEqaQf+}7nrV7D@cdAS(y=YBuWlw+cEkeDOTri3D1}DQi@oweXEG`JDR#J#{aK(l=l79wWL0%An#u*aOIiC; zZi<%fc-QuJPCK19(FjdF*0*D4cx*MQ9I85|j)lCP2?@@pPHeL@h@-L&xZ2*Pc;#b$^{O@_cm9y2kknLDTs=J$m%;^JBS+F$ko8peNK~mU9Y!&&h{F`G zRqwdtwW{Qe0j<*3(Kz*9s-IwsE4UTrl&&8vhj^9j5Lm`IvKMR;J;T}W)yjC41nk=z z(=?4R%sb$(k;O)6?1gJ6$0?$xn8(pb2|PE!CHr})f_e*uuFa@&Vja?hr`tc8P@j!+ z#!`ReJ!naAz0NC`VRrD{xLm6dWZtz}q?3jeR?8e+9rzz4vsXYIv!r^zh1z8W!Mp;+ zJa=2v*1k=MSQ^{;eip5 z3!n7@q-#MNV?IbN?6OB{{G&KqR!=sO1tIp|24>729W-M`-vB+BP5sV5RE_{(K%c+$ zDw8R0#tfplP4lC(W*Xs6?|j!6A4~vWc!8*(DB^-zLtN$#!Il^JN+#->VRJ z^4x-DEhAk`Mr|j{KaQJ?8RrqCH5=7p-Z4bFlj@(enMUrimSZN&Z^d-pOxnnbGiUTW zQt3{>4>3Renqmvq2seAAt(0P>*yjX$TA?QKk^gr1O;2S9PZ`pw=abkMB=aG6S`W1_ zMY~4$8`xOzdUH7T{+EuUN0ld$(yT_~oGyJ`t2mGLe!@XZK?P^eyX|SCDsBc1-=?Rh z$>mco%qXx6!FXOrW`Z}^@?Z7Q8MN|fuegej&7jGl4ii^Gf569P(B9;cQt6=Sh_6Rd zs6FNz@Iojxou9B0kCHg@p=Yc1Pgq%Ms)UwYVA|X*VUVsOq(=iI?ecD4ID9vZwQrB} z$OL*4(JRjjvA3h0YSAl?C#C;zE=_%lxiprqmooeo37t;^dObM%3H7aJ)J{GWAA6>h zZ!!)O$M85hpTr4g8)nN!2jIW<<9*yv+Y(X6t|2f=|U}))r``x2KV((e(DCne=>QjbP*1yXf3nn>sRb__?_? zwJ_56|AvFO`88`Jk^Udnz6HLCVtaUI^XTT$HZ289DUbriv>>*CS3s0Dl%*{oIenV5lk}aHw8yyQ=OWN{xgI z?pyi2JO4t;V4)JcO+}eSk#Z zFi-Z%#2ObD>t33;)hPQO^b-Gk3-N9leq~FN#ma#ds*+*`Rov?w@wKv9BaPMR_~}ib z_+xcC{_#3IB!YU;m5EZ6F3{tr*6a9!dU7v~NiQEk&cJvC_O8YX@&o5RAp0mtnN$Njybd)sfDWvkJd$U4?w@~ zK`mIxgVymgnl@4kmIaOy8|OwZTF1`}&UYaGB-I{sBrGIy*X^9ukAdiqBBpcnPDkcz z_&r7)uWt&zZvtwpgr4X)>TjD^yD{e6M(Sm!aMlDuixk7lpn9`kO@`l}COWr0;87n_ zaOe>Z_wsm%-4xZ;a!i*E&b>BEOf{6`|7!3jyCZO(cqE)%|`PPsf+lBIm>< z81-4w*_N__jwW$DoN{v=JznS<$J6nsW)6_Yt z=LRis`rx~q-a>q)7fCb;v(9m>9!8%~WDh6h_L@Z_dm1(9piYrJ9pBg}MfNo6KxFTJ zvXei0h_=F&#z1T@7y2~#C#6I?rJ{fy{J)d4e1CLLK4K$EcZ{7WANjgb?kv_7jQ7%! zA~2JT*W4<;hi@6*)A7ZPq4*wXRTZg;6ywwJg^eMtDnHo$No+XP{;4AznNd6<2N*f{1P2s)4+tqW&hYx z)A1|nLoplhNE<>in`nNu^M7XtNzuR<_i^(Yhnyq6Cg^uW{LK_7Yi9%Q^`fq8R8X?Z z|L^01Ai)hcj|)cg^-UKRU{tZ-|1~a%`8PTOaY4+#+9AaSb$s3};)2m6V(wf6<&9n- z#Rb)z)&eatMv8dNV>f~piQ!IwtsaOD>d5;(8Xa``qk|d?W0CP#PNIW5Izj}Xdfgiyz`byA;KyY-fZePo+)ScDMt zZT%lb2vxxdp^m>?8^CpAj8MnR>u(k#)bWP;aCyr+9+I~{mPBgNPM2(jC}Jx-Hm=(_ zpZUEyt>Po-Jwv!R&Ey-_Xe;aN&yl@%bo`ff0V&k+>bgLru#jpPJM}?z)JEfA1bEM^ zm#io*rxzYUv?bP0u6rU)G(Ug z6&4%Of~AD{`Zh9CD!Fcl37ahN&)?g_&pMbOlV6zgt@Q2!vwX~}? z8njbL29#X4>_{%qcVu=mr^{KHrQ=U{q#lF$7hA!nhMdK_Ui(WzeGj3fy*Vmxek0Q^XKrqpMj}j$}oSiMe%lpQMwje z@HUmBpciXm%3yr*C`&Y*s13wzqj`M;fmi|5{!)*e#Xx7>*FG`66TM1qU?+M9P*dspuOy&u*pIlcK5JP**jzC}?|=BM`qptq+( zQIY}nyF~NgE;Ofw(foNmfk*_JHv!ET=3#UX&^&)$XC4pCw%MI>lhACxk;lXu zm|IJDT+9KN?E$J&!?--L6PM4|bmelUpJuXqLo~mLQhlN(njca>0BDsT^N9!McINZ# zooG%5Y|;FMrcQje>v*3hoX@9&d?qyOD4&bDa8A2~5T~yST{&&3>B{NObn7VH^MUTO zbp=3g-cg^}n*T?<2FsOXBH948UlGlH%(8Aig|nh?ipBBa?~^75HLdz?qj z{nRjM(31cDw2Z7cG1jMRt~Ji>MOK@1cg6ivtp2GXc~Brld+~&*>p}MHb&HjVv{6!% z!=@AMHl>^FxJxw=Z8uzo_!3dRWA-@d`iOXl#$9&RX%LBQV5r}HO^T9kVO)rB)Txiv zvk&7iPMohLkg33llG^ZfMf_Ss7rZo$N65OC1Kxc2l}{|56PnL1hnC&w=Zze6xFn6k z32#^;jasWDjk5`FN-1xwk~AjwmX#AP_mvu}q+4mLn!y&mQ@;)johXja~o<{uge%^y*kK^jYS zJVR)%l>2G^3TS>NQKETpO;8pq15}^)$#(4ZQ~jIoFl`4@uK>lXyHHGIQ4!Vxag=H^ zQ2kHO3ZT{cFQ3Rf8qQ}!_NFkJr5^qtG!x4j^QL6{pw|5 zI9R6>FqmtQ^C;wh`*(4C6U2dTf`bDb7RWzqmQ<(WN(oDnqNiU$fweVuKbSTUui;47V_85{5>33YXdme+yuuW zz;O!lzd!Sj+U%b}JhK{ZY_lkyYg!FB9-SGAr4XCI%B5y&gEj%P&p^g1fvi(~OJH@f zxBuRFV$+uq#<9TuW#Rf(rFZohn(U%QK!|WwdM{7IrYp2#J!n6hBBxAbd!A9}*jbb&i zZ8GYhJsO7s?H__2S@~daZ7Ba}JBfShX+)sZKTne2p4?8>)PaVEoUb(o_Y}|s>bx!J zu}QTi>kbLMIrZ@qWN-gojStk{s>a>JY8-vj8b{C?|4@5ljip#^6u;bG<1cHY_`ftF z=V++wr=R%5<1>PL3;a$UmjB5*$KOo6oToURh2MpJ0yUHJ?^4F_PlD}P$duqd7aWq`NaFC{~7sP_9yEQf6F?o4XeY-o7Q14 zt%KmXbsf&uOLe&7iK6SLg=rWyx_#oRY0}#1C|+n_XkIoQo2xF6Oo+q`7)< zWp#+zWE}KwHjB(FftFSE=KevyxApxvy=yktUBHRG1RUuaj||scbniN{@}-Z%80pdp zOrjj_;H-6xFDJXU8JemtLSA<@Ne66f1}D8g&PX6@=`&OX<44kHvWAPXBoOs;paf0?+_j&W(PP>lAv_0*AXs!R96}udGXeK_^OWZ zL1*s4c{pSzJRmu){AksM`0!;IAHaX~5I)F<*8m@ir(m>jO30>W=&o(pzMbn6)Ln8r z*wU3Rn>T~~9L?v3;uZdQ)??HImyWcG5A47^$)>>jqH{5~DvRjO@dB%l;+NHv_1s|J2r9asTjgKR9mOxM zCvW=*1TKr>w>Cxb^1ACseBuhI-|{Ip;_FNC<@)g%Z@|a-@#Ru{iviz{wKd0m;wZp1 zVoFGM>|7*g0}RE&5K9KP$gJeC2CSpHLay9Q=2ys_3lf*J{o}K_et>OgQ#PH86$KuX zBq7;wodMV@YNd7UQG88fc>n!4FVL@l?3Dgm-p$&r?=9MGaZT5Dle{3(Ztj}S?IwCN zsc$b`&l8z#JsmQC#OBs*w*mZk<7Dsy?#BrAi{j55CSTr`rILNrGlYIRJXEMA>0ca{ z_86FG-eP3W?Mm2T9O3NGsmw81Vq%3-qwL}YJ$a+8bZ5W_5e3#Qol6X9a{un^1a}6Hv1+2D)z+MGwaR$1S&vs| zO(Ea>8miGu7y4(cGZs6Ux|m@z6J5tP{1DuONVBsJtK?kIW?gi@vt)<*!_Dew`317x zM=oGZj9VqG$pU>1^6p`q>1z3&r6xHibN2uU0##vbf%@tTBx*>H$gchWSKrI^|J(X* z{oW^%H{$>DFk=Z#EYPHMq2&3-$LeIVJCcF6hrv4uU*8nrCaI}FV@W-otqG}=#;|o!Kqmtfjy=#?cvU}?d}$A8zWQl%2BV%d zi_kml`K&mNqVAOP0bzLF3UfxUrFBY>8d+WJa8si6c0~PssJ-M|_iWN;wsC>>!ol{E z)Ao`>d&yW5+ZDaVzVH@fXp7-f6~yaJFtuscPC2zF? z^N$hV|Bx=n3VxRwtUM(r*)U@SWm|lff-_##Err8Eo@ND~jv!AnLm1Kyp-a~lp*cmy ztI-8o&Ss3b(7B`|hx9>=YCbWDuc8Y+_K+&2*a_)}($bNDdxg&_)p9I1KWQ>QpH=g% z^#Kgqx?tFRC=@r2;_k^&fSs3-u%a2Eh_l4Q49s*By}O)u<-W)b%PC7(QHN2|*|c1W zvA>3QktjD@i{95nWm~TYTBzfu|058b2k3vE2C@r_b#>VI!qY!|(N2xzaW@ z!?vmVeN=~3xJ{kZBR89Sybfbh&JGX3t0dT0sYz6bBP|c@BIqWjaI3RQ$1Br6z zsS9g${W5EjNuHCPp&J{prjYagiDS{AL1XEvr5Dole1u%jsrX%O3NFn-WJ|;UR-#AtgZr^ba2AqB6F6&j<%Wd z60-!kmT?!FV-MR*SnoO($-e}4$JP4IyafNDzR-Nu5yM4Inj#p+Of>gWudj_u9$PD) zr=B=3W6)T(YT814&$PM9s%{I5Oh~Vri`}SSp^4=x0q5MN5Wb+yn8|JyQT#Xc-E&ga z-6t4pm2+*iJ(6Z+#86sefmZEcl#0HaYQ#gc;F>XSGA25)DNE1|&NSnZnI_XhxtlSk z9>yj)a7xmhk$fuDz+T^({uA|NHb|)hbMd4GcTOf{0k z`ReFJJo03|##MQYg*N)sL-c0lF`Rxf|N1%W0-@TrAjxDG47$xc=;5j?vx@(%m9(QZ zjm(LN7oy zc}uGg*nV^CEe>QhJ8TO-T^JG8t_s?&a;Mcy{08f>8+jbXC)O*>9ge}AZo+iI(08sm zi5Ky#jK~QOWlX7Ml*%g4f*hkKAGn#GX@V9@!X`b){E#L+7s;<_>~Yu(5+-knc=zq?M} zY*1)&H4{wv#A`p-0TNQD0;@G?o>X@O)cQc9fs@+suSW~UV}CzPhQ3MarRA`JwAKLV z<(uk2LJ?^#74JPfo%YP7g49|&>X0GciT%5!M)B+FSVxAxg`)VZI+98(OX3fZHsdx* zw!D+26xf5ctV3b$efUmV);oft*#LT7^a|~j2g$jqJt_BtW}R$GhCVkhgB=^^ zBs$(~-e(>!Xtk5CSxwk(HZ$dTcND+6R?a;r=(NbO$lN3QgJvy5O2%f*f)uV~e3|1m zC_`a>5b|O3Msx9a8P_eF%tj_9&lR-PAJkGh9u^|9NxHOu7~-b^KdIgZxhVkpx4wW; zx3_%a`*5~==o26LyH7j?*ZBtmJeTKKvQ}ncQzU;D^zBS|_UT^WSq?I+%mN9b9Eza6 zCvYwrIMt9+tZ0`NtZ0qsy!Dd!@ZSrl~weica^^F}Hc>EElZ zOqoXP|5~-4Sme32=nY(!L@nQmwfN2KBzd+x*OIj=i`*k;BH*1@<`XTB&|ateuD|j~ zQKf==Gkd$=`y3B_=MIOR+sHlSAaA?bDrsMkB|L+uHJ$8p8$j>0*0B|aEM@Z+tlg|^ zj>9TD`^FiMI)>$}Nou}FgYvq);7+@;xfEHG&Uo%XYF^tw_mI8*`O?g*B|7ExZ=U18 zrzQKSibFG7UREow?|qszDX(u`y6`nJQ#&t)r0x@TB`L2TSw^m>Zhq?BGoHEd?BQWX zxF6ecMxEdCKk5aXJbnIakG!7Q@~Rry7v!XIE3-BwB7R{@+Tka1*s_&bU%}nH7Qw-m zA^xu|N)8#8xh{Ix>3fa$xD1vdh9|3La&bwR|5wNCoFWc9#I&zH%wsFFz7}*DZ2H$8 zq*Il!_Nnd%C^mI(#E%Bt)976;+}+jkCB?mTXt7Y#O{?K)k?rXcWl*=xP`(+-q9tOw!NdD0V z%Rw85^vo6#H^lY@+)S{5vIR2-o(BJ*AkA^WC3Nk^Tc{st|9B^~y`E4zU3d^Ah|L;@=Mw@o~#C4$C?jMHHg;JmBfcvTM z_{4~VK5^@Pfq8q#tmT*m2F^a-yb>E&XXP=2If*kakVXL=wH!d}Cjlh?i13{UOti#> zrmX3qxqWhu$Xjx}{Q5U54CXWkHg7Pdm|9$syu1FlYNVH6KeF_c=W%piFykSnVq?5B zl0RQBzy5v!;&p-?Pr?T;3}tE6F75G_N=?zb}$6_8_PFUW|Sj9pE=Mk(rW_yt-DOWi|!o zND^J~YNeI?NznP<6GquDKxsDP7#Cxio*M5QpE{~alkl`_d@AA#tf7FuKv|J7M`uOWy7z-mIJ?E*yg2X@b6ifG$LzcUi8at$S|dfTouFBM7R>Y zP7xW#yObPz{hI>PM-}bWIX133oAj^*=J2Hpo>^pdpuTyVjn; z?GpyrRTadWY!z91+CgPAi)EW3N34yzD5RZGHp?{^Jkz44CT!Ks<09Qtg#H<4J!t37 zIyLj2`J=j>(dx6agk(J)cG+~$IO}Y|xI`yoMya*ri?gbzlAnacxbPVT(f+7fB>zJV zGpPPtre$ghc&^qNDOaijwWQy*Pyr7_65S2eYg?NK>gA#J>c&jE^iz$DmzBpmzeB;C z{IFJJI2o2_ZQHMq=SP|+mKk;lQ%IZfZWpqw7(sllEY(mqu6qf6DJ;T~WtczEf($z; z^%>k5A<_Py97$7wz2!p&=&v@8n<22#OU;UXY{EJnX=S2yuw@+7q`Qr@%}&6fu#dU+ z{C=|91M{O=>#ETJ@?;;-xAxl7{iNM~27F_#J-I(bt)cBWP+JMqvgxNmr^*k0LPoOY z7~I$0l{@`JlKcMss&XZrG5$?vJ(ls#=Vzf=#EbdEIKV{bw}MK zU)uganT@Txpv>1PhvJ!?p>p__TL@&YF#0aEz6!9#6qaRP3WI%?!YaaGKW!}p*yky% zhFNg~{hvbZRluW$inTea-5DIw(?eU%hT)tidNG0c&{Q<2UF`q zP=|@M4hAMJj5bjqurG(f=ChSSmH=Lb;joLgKjf3!4_3*pAm?N6OZDpx zhYF4zpgY&>^oi=e7<~-q(yc!6)xY}0kKz8MdoVJ?7}vPpCw{BP=rzc@8^*cc;Q2FP zcNPwk^lp_04M(X7T!iayMy)HsvKX%A&RL0S(-x!L)!j1c-tHs3aiK=ONeN#k^A#16L*!paGx+%%bo6@-S z{Etl1h(PL<+dK3=oXmUqNjRhmyiY}X7-M3wJudCP`k zg?Jq*f!}Y@S@!Tazh_zV0auEipbWO^}$ zIE{IXS;f4{NZ58Uhna7gpO|ZmO4dg2YbZ5%J^KWhd%Qg>d!XLS!jz9>v50_pQ88ILDB|dkTva=@%Y; zCQ!uZnJp5Hx}iwH$^E_P1L2>!{{-1*npxRIH)A-#KU zjR>RdA;FQmpVFq5?Z1Cb7W)^aDbuvx^r=af-$?8KMNuGk6CEp@MI>G-dTZ$2p&~2A zMf&(KLBRnhbv4#Vy%rCkVjuZUYVV66zc~BsdG|{o7-SYgqLol@{3IiR%R|Oy%fl`%a7|!Ay~I|Svj+twBGubS1&P!q35zK z)`QCpLtJ|34@(6J@^Il6VOQW;zD9K;&Q~7`EZHtGNDuf zR>SHZ0jnW{FD#r1wHfXXJewKzYQDeOE)4SS!Vv)Zd5|g;_AI4vXcsIB-t7!P?&=s+1W`@%_B}nJ=Fvy>6WcKwwH*#(VeX0Qc zwHS2pT+pj&sTfU#1B0)DK_}l1>4SIp#BV?cM*!SDfIk5Ka0%$*H|##~i`RYPsyBUN z{EI#@P!b;(DyhQu%MHByN3hneFvxATA8vsBDhRnZ404^Vs!Kf;{Kg>U>@dhBwlBKi zl<^hE1CS%aAZOW*-_SaLr8s3}NuUK9S}%HJFuEdsq)k??=M?;aR&3vWC0LVgAzZ_4 zd%NHQ8}E|Jz@Z99JheHV|1OL3gV80$FmvR=u-A zPyOCjzwX#?#pSZyB=_CI)79kM+(vX!j*aZeK&_H0<0V-|5{LSs`L&qV z^I))^i>n-PJ+fL#-yck$TeT0aBmC*xg6T7={tDL<{ONB5({rm{C)~OsrEdo36;>tEs%|M9j@JoFOuYv{YXH~Yj7 zpnpF}d)1~y(Q5`7Sm=^kJqzr~^pwnVBU5g_5Z;3KwUHK7^2bFbSb_ zM#=b%uYXyy$tRjXcU6Hti$VzZ1l^VZ2hnk5f5GTT8b)?FMh(NL4;(Y#STz*%V>(7k zI39&-BOLK?RKWe?02_ZBM(f~u791xay%vsLaP)v@pO*SW-w=2Pe7^!`_b#w);jDTO zY+g9)K&QS9=l5RliKF0r575>R&bzkz#36TLboy1F*aOb@ZS;v7ApKs@w+BEsSN4Ol z;BXJXXyya64n{<_vB3jOLr|IF@3VTfwZz&>FQ>YTXq;3u;_5wZ`FaB zTduHgc5S3~>~wjgKUTu}TUidRJTX=7BwTmhuTEH#HQgn%%o>#Jnl>ocb^oAARmDcd z8;IXw#g_Y_KbKgg_95#c6b`c~(BHSibK>dmu}%i4*C`Yk|I4h5ewVx0_?%11=kv}4 zyDP%}v`fml1j-+my2vkOD_yac`KgHiyY1=Jr>U$d`Sk+Hw;SYL!GG9}?N7Uz)a-K^ zmgRbvz3wv{K9ESH*ax;*sY6faMjeN;?sF-*fW(^HF65pAIox1khhjUC zLxo#{EElmo`=De24RjIvr+-zza+%PY0#+WnBi@Mt$K&l0_EJZr1MAHNxIAJv;l<7l zE#0r-^EPZxb4Y!SaS{D`$%XXnwnJh4J@C~nEuUNSnFHy&Qwl_Hsz`KoHRX$veAUGi zQI4tX-z{9MCfp-s8kQmbfke4oQ6{s?!E?iMsm{;%432jwHxU0uhujXdD6vsw*4&IYT@GImhBD{GL- zl{07#NX4_R>4R2Pk=pL-SOsmNK4-LyO(tmXjDP4QbQSsUp1L)V=;rI{Onr5&wYeAp& zNWiEB{AXl#(Ow{ZtqWVQeh6@VTnefx7(WND0&{2sds&Uo4v*og6SSa(at9^2CJq|r znmI^O70^0+#Jk+GlKXC4txfG7)+u`X9lFOoko(y@LtyVDx$wU236pDgvc=FNNepj{ zi;N;Cp}${nq|tVHnAW(lnn~54%e2f&=}k2c(N#=GHtEf=n~e&W+CusZPERtCFI~#E z_3au@hA~{jn;K4@mib$)?G*3(@4--B?j)Q!47L=oDbQ06iwXcfKjjB+VVu z@GiQ`aU@seVYrjm;_|D;F5_^f_1|%iY`Ez@P7W1eD{}hn5 zN@#l&4 z=ZU0w)D#K_p*{d8jH{~g07Z$i)7)mV+DXGd+JKx1$r!ydAhaI=^oF6^je1w2C2er; zbE5_iaAuC~IXM0T8t}UDHdk*;+8uG{M%}@jUBcg~RJh-3&QvB0(ZJnbl=8Y$ay(3C zQr4}L56-Ht#(E6$r!{rnM?}s|zGlr!o{5{I>0b32zX?khn(aM7pPtM3t)*atQG1T# zS)}3r)AXey#f(QX5$Chx}bH2m2nlS6*pI)y%)%9=L!%rxn;SpV*M8nR;# z`R<(mDDb~|a(S3oGP?`$k%D1?wDXA1`|c5dN&-AF1>-gRir`N74DCB3(y4ok-g9D&IZ);k zN04(S__2%E_(TmqI&61+IX5;qLtWBtLt&JemU<^0Wi}giRP#P3>(-nQJ^XA8w0Q*8 z0iY>KG}@&w_j04;1*Dd5h~DmE>0t>=G?fo6)lLA*plz`UIGxPGXU^scNqL|tl5t6L zr+0;c{Zur3ZvDH>`3y>CQ#BWUs$qC#tfGek{u0nH)ueQ!@8MpO661P0bD6?!E-%k&IA%k{`kXjJg`w<|cwuDRY?W<>U*#0COuW&vovUpnsMKCHe1cILu< zp}F99G{kA-+?&C5snPa;WfupY9Ek?$s3&)Xes$oyFcF^;@-TTf$lA%9LKf_#D{bcm zJ){r7f8(r8dDe#oHd=GN%<^wRu45CdI&5bvl+B4q*{oG4?3(Le7RcbYvVhF@W=qd^ zde3sgN=oYPQk1Ceq%=j#tDDYv*atGk-R6?NdEPTU_LMNu1~kAiJ)PYX@sFO=JNOR9 zK9YR-YI0VlqI}A%1Q9dCxRTIh(+4W3yCtlIN3lcAE`GX;Mx(v?*YY z$rzXHy690sDS0vHh3PR+N)nWh)SHvs8RJs92$zO8HnQ90C8s>ois30%ZPQVWKB@hp zhm9q;rY5L1>#|5`5pMEcsl#y``R20g5M%iVO8 zV$1OMB%n~&ahkxlEkS$K z5~vzM!*8txzZJ%`<4NF?#bR_N2BS57CH*eN>~%?TQ0nFm1(c@9Ni|F5C7GeLq{?H- zkhdZx=pXgR+;;GRbe3fX8F)Z)y_$~D+o2`nEHef(PV#ObgJ;lo>M2ba6i5LK#{y+) zIa0rw9i-oAcx|0BSDz(^(fBhDlVRZ4p`XE6TnS?l@hRU{U{vJ@?h0s@bCFz{xx`^I z!#EDOMDOs9baT`MZSHf#`M1|xMh??;}%D1WkjIQkLJFdrvmyRzBsG{=Da5D0N=ddBME7v`)p!uOBI}jmrd! zBpWOe4gXC&nS*jlFyrx654fRM>M|0@`X9~NDMGT|=r{|b)6|5IsV1Ew49tsgpBIW_ z%E8CA>ed^hzDLQjl75}^YG7YI4ga=BS%s45{DsX6phnwUZu5Iat`-G%xf*Byz*+TQ zI+Xq$$jKWrBaA_5bwE-6LS>!~4&NhU5pAr_uC`|vT*6{a({0?9kW^f=nju(`L3JyTu;`(NO)z zQeLZa7D}TZKgvfW8i(!1Ms~l{@CMK7M2whcz}H9&$c#H$!S%^jT>oYjSx-|#S7|f$ z1p1bk)kt+P88A*L_<6!b4_a{lIGrnxBkg*wjZIo-!b{1jn@h!WSIRlC z&lVV$p0r-9Mgy3zwKUS2t|;2a-8ENxVU!Sw(bis}cWA`Ezb8Ag(<}`e_<6|kfI(g* z?fCq3d!{>m1Z$98AaOEQ9OX{)N3c4)5u)YbK>s^=ypIKIzLX_U*L~Rj<-s)Sm#l1+ z_;;1^uc<0zV~gJRS|uA>^qSjx<&s_MsFi&eJM^T7x$7iIs}u#0@iRrtSsk*HZhk%& z-o(F{=+uA*626ZD(udjqHOM&x`tW?HfAUK{k<(&y2z;WgaA>Hl6b}?xJ^>4wSVu_( zZdkImZqFqn)uwv!LsiIx`o)9F~ruqS9M3^1v%K0k0d^h6s9OsFN>@$OZKs@I0E4AU?g@&)F4S5%c|4jI^-o?%ui4P%tY;TqHaaPUm>EkFATz9@2 zw5r~94D0pF-Dbou5KvOmaxzC(zg*4FYPva;sS^};3#bP>K@AT=WgM0u81q@PeLR7Y zb7Ve~1g8(ek=aWD$XIAO@;m^gy(!exE>NwFYQE~2aV3-5$9bT&kgg^GTxQ+)!Y*l3 zA??#HX|F+=CV{M?k;>9>Nn8TxypL@Vx z2k4KJ5kk85-8wD9q(+}b$vX0GEHPXY2fkXa<}Wm=-`F9by!VThcNMDNvH<*@6y9JW^T{LWjG}>LT?X7K>0RIbN>1U| zj^7K9gTM8-^gf1@-p52&1I9Fz{I}*s44aCso-QQs=hf(Tg$k*VhW?IFd@gUhqAm&5 zZhr4j?b<8I8{C+m3B`H6Z{7x?_k~ZYuMpl%25xOARF^D;^RmJvz_o8HLU`lC@VYAQ z1-!pfj;{dduZz@gFM{*vqNm{Z<)S6s=Lcj=c9+M`r}R zRbl0nz=L?;!Gl5y@F0cG@=-I0S2zj&X8_Bl3iVqdUOqu{S}P=8n&{oi3MIXJh~6!$ zuu;yE(R8rT9k6z%^S%gI70oQN*5om!E2^h`;`F6HQL)-5R=?;I?}0JHzRoAE1)Jg* z(DmPM^of-#e4<;1Q5xvFaqz6B$S1}Z_{6)2?gRT{D4Z1>(24E`S-f|>`mJejUa)=@ z{64ULKK!cJFNWW~>zBiC_x1UPwb%MrNOd8ta;jHIj*=@h%?v}nX$*3AoYb92@mn@^ z36WqntS#)lLT#b0sL3JrRVmGZlHUbsQG>J$wh?KGL|UYQL;9~#FiI^-QRZB^^s9P@ zB+bMNJKlkkwRXa}f9`u(7?EeVL~uO|rB8#FT>$4Tg`|CZ6fJ|_ib6uqiG6{V6}@kF zY`i>=J0lFRb`$XAGoFarg29N7wkf%ZgV`!ss`A_pfzgi|CCylo*RxUUfxG$wIJR}Yc~l>zpDs%3};&*>D|4l z#PNkh)}GxLkdY4W#ST)N#rq@~xu4!Wu}|u6@ku^!lDGq;Vh5Eq9W%^L`feWd-O6?9 zw`Rfl;<{DvdwN{~{LWps41WK*ZaMtEylx)!Y-3nSWfb>Fe@Q**-O#X->U~8}lFdI? z&qn924KkTc<)&KG;QYn;iNBJXk@q)Kprr|a2ir*h8D(1xb&A|a+Iv^eKzon5wql=- z=C~*rtobQXuEzlOV9^usdruLOm&HXE_#IdDEaYz7OLjyhqeuVNB96>!QzbAdCjchH zl22!7POi=CY5w_<94XSHVLH4~t)vE3dx=~e01d0PkGb~MUTryfXXx@Fe*EznfPdC{ z_1mQ6G3)2SZ`}HPDDBYRaGBcT&%1Xo)`LtTZTSby=}goff@fhn;`<*b@^8L{R>J(p zpX~TW>h*)UI`edaz0+XQRs`b7fmp@(A~KV1dWTfjioMbsnKUw1;=1ZidNDrKXA1Tv zL7!Rg4n3V1_B4NQFWRHLN5k_E4ui9=-V}d123aTEgN^CFsK-zSwhyLOgObG)CIP zblczee4azB>L_>U`SP&mAM9~b8i-Fen&4oF$A90zhI3X9q$WDwtd;y0)jvB$xn`{+(3x$L{4Wf4lWVU%!t$b5!VKZnMFskj`<`%RbyVc4T?#!MPF zg*z68T?m7?ZO>}DvLw8RG#m{ZN5Ww79x{vJtHih5OLNpEk{{P!K*@wJS9Yu4-YTri z+#sx*LTJosvvSA)cbQ|Z{cAUHNM{4Cw-k|i&KdmmqT$?sY2U-?=_al8Hb-Rx9VFf%c%|L^V#Fjp>z>$^lh7l!X9cirJG3hwUMtu5~k9_`(ik@LV4KJja- zPu#!8C;k9-#3JbL7r@qd8SIc}!45eL_C@4kpV(076Hh+w6Sn~TnUy|qB&3yX@QI*} z(HPJl??U?fYklH&$baZIjEdo)lrWo&{Y;>f3#C4DPhqIfg!jef@_*AB>bWJIYG@9# zDt;@&;5nI>zmd)#-&rorA5Vm~ zncb$~KFR(h=jeoW+|lgkvQ}ovn$=1RU}n?6Uekg-{Fl!>^32`&V5uGsFYCjEP+2dO zS5Rs)IK^c$<9ts}2KD0Gz3=-n4wnb--l6v4-12eU10aVB%-V|1>kQ_A6v_$-rNdjK z_MBL5pks=%Rg%I$>4U>c&o19gOSpK6M0K=fb|L&zuE!i{F zPOU)A?!?WPLjB_lL8{qLW!iEwdOp@V`*P^%A^%fX*&NVX1{%9N)IQ;Ihyq<0#b1}P zl!8po6h?8MmUK6PObdhjxQt-G?eHIu>C0^f;9iBFj+e@$F`a9X#`F-S^ZXFcmHov} zDcQgLH$NR@p55XyWO*F))MeXruqB@;BY1VxLd!1;X@_2|w}W=L9kfH!ucYtZ?Z-PA z_|p&iZmAnNf9ZkI5jbYR5etW^lQ=-gm8SKe_u+BpTWLD0#%hV6` zc|WCh|K1<6R^MoGgSG0O;0Am3InoD*LI2Byeh7V0KG5g=+!-!s@j)A9&9%?>Kg})W zW+l#c6KdY@)3y(&`4*@lv_!WUE``Pftw73tYd`S@ectCOC0q9gyg{EgujM3Aa+2E3 zTINI-yZJLA&g%27b%sXE;@}+NHP@{BKcdz?;lc4MI^mYZ`n*3JC#`VAC((GeB5Jp`tXpsL zwWe9E4Q}ws>;btm=Sa0~Z95~xTXhaaGovnW&+|u3+r5o#@ea^R>Pg&V?m5BKpWDm_ zl>m7cH_I(?=e}$2RA38A9+$G_+ME6rMr2IT1Eo_1LfJI(t)B5$~gRw3!G@lbQ0_qpTaf+bb7)i_jdCA%ezQP6tC|H&5vxCcNEg8iZ=N_Ixj+F!0w zu?B7$C*x1I`n)rae;35Gkva)X}=$BK$j_{GzA6o(arDUoh-Z{~rsUqopX!_k1 z%58hNbVntp9Q9n?(z=Aiqz-SjsNj^_QdacJ`GmxIgt*f1h(w0U=e_N?1N!`c ze4m(j9id^+=Y>ao;+Un9->~&6LMaP;Vj9>6;&VO`LE1L}cQ>5hZ$^md4gEEQ4nY3y zkNCvR7SK1dePT@`LSKPCYK+3@TR5^4L(=h`Pm+!fZwXMh1;(h)1lIbkf4xNHHf>2t zevQfJ{py%K$d`^*o#W+6&u8K4mxF2lZH;$CP(G>M#eS=^-CNUYaBKibA|vV@k5sP< zKKVD`u)&jGt8KxwDo9%cY1P$hf@xns+H;V0vHH1S+A&D8K-#%#OEB#qq&)*^$Eu$R zrj;>pApTT+Ma-XOKT=h2Yo5Tu=iV-fVRpP?Up z3;7-e{F|XK{R{9v0nffvVN?RgayWXx;flma3w^7k2_dTzt|KtYgL5jRZG}Up#;6sJ zSKzo$gV9_#65%M(f~^2Y7953uqn^d6A~H00NqQ%rm=p(n^@|P?<5cmh>(q1|ohrDF zj;x{MO=Opef_$7-nV;rgAC8>F)?k39ea{5;U2sQ}mDE=MN@|9zq@Mpi_oEK=$G{|Wtb4BUi2JPOWwu>H=GjSZ^lM4eF`}i>?gkdC_ zQPWYxozM9WFn;Y}=CkQv3pzX5l_BDC6}9@mfqRzYbF3OAlltoJKzvVIPeFkY z|35x_7cyM0dgO*2)4Js7;m`50KSx%l94g2$3Ua9ZIatWi&7b2fe~v*xnm-7md9ZDl ze>M7h?!fc+!k#DEcGCK*xvjzH?}RG>ytEh6f-FJ zd{)@=oz@3Q6rguv@TocM>Fd_VsVx{8d^#`e={l>4Mi4T%aM}Ot6XfhujgS%a(y1DR zvfz9K^!6ICdjv=y3-_aga`q+IRa0G<&aERWp=;ZUxQT8kt90i$&b1xN9A_|ADVw{Y zK;O_o-x$`G4Sj>GjY;*xrukt9-vHaY?LL6*39xZ~*aSap4}jHlf{kcP2UuO#K4<@0 z(3VO&pfDwa!{%np^v}&u@v${5j91xExA9~?F&tAxghsR>H1GkRxD3XZxiHp&Vnk6e zR{aIem*)7yiR}nI0pr#RfQyCnnQ(3gxWSO#0ppt%&V6BAMMojuiGd}1Wf|ynX*UR0*Zx^_UwzK^JDK4EDoiA zb#jMS-5L?VGLs#u%n#s@W}b`!96yF|9Fyi>>*)OJ4sU%+1bA~1IjI)O=Snx*Oi32z z>CC4CWAsSKzxo#xHt+1wU^|et#Cq=G9H~EKbQB*XTxQy(Rl(b=(tI8rUGEY})_(xC z=USzehnuVnUB%Mj)wMHrW*@GQxGv4be~PX}?%z&em%B)$btv3V2FSYVj9~jkblm5k z2TblzNBlvlL^+f=YJ{#?9i4iX;5pnzD9ZD%b<3pm+~=p~MM{s+6`gN zw)W7IY0CwWCC07)u~_;KH(Fm z{}z%Bsa@lfN@%;H77|1Gi<#v=Y-GV6%Qr<(dn}2}uF>i9^;~}bDG&3QBqPJR$QhZJ zHKxinys&;oi#Yr3U281%7-B zzU$|}lX~!3t=AE{aqox@?<9dxcn5G9HAdv>v&brqw0w5x=N@MI=RjS+(oH9GLw}a` z;gjYt5i4nkAcwvu&k4+7wV7Pd-}(XUfuFjTC)wED1)@b*{;P)nI~$vnowHEdFIp-; zF{$0#D5{`*Rj_=VPh`Yq(xjiHyI^GI<8)iT%a3Cg;J~2c#0An`(t){H?cT3M(qb&X zrs4PIVio?-x&H0m3h`$3&j8q~KZo>OzwdRk_jCki)NcxE zy_Z_iL8X7xYV$tT5txtviS1-zBs0PYK1C;^)t`4&)-6b^aY>o|?oafInYb%1U~40_f4{hU_W>r$|!q zm9-3a-v17Nurt$wl72Wy{6Tr`p@SK{aN2Med3*X}E7>gu@x@j$v$oy4yW>Lj!$C^= zwhE3=&5*xl&(d)`me#9VNnTKsJX>`?%^63(_bOR%J3m3mbKG(|zq8%@QO89g&Q503 zGfpy>T|RrE-`8EgSPw7@;SBp9RO!7c-z?%RtVW-gm9f0IPkpdChj=8@0+%%l94 ztOrq+fhX7o+<&)Qrbzqa61$*s7xXnTXsA7wCaQu|ASF zFF|HdUI=}Az1^!k9?(q@KtujFqF4uZ;*30>$nuL1)Bd(f!$nMFbC6+mRy>{iA!p>y zByMhRvc9?9`zz$t0Q`noB+vB2rOjzroie~#+MJJ-Dflc|Syu%4DJ2% zQl*wl@pD(R+2vvM)&!iUGK@MWD@S_P5bx?X`)|#o6lC|H5l+>x?(t7q-fJGE>;d;= z4b}+f5`KpAt}I2JtQ!{aJxtm!)v_|Of2MpN*~Rg#2ALWqfpl%oXgQ;f@8@kip~fZy zjLc^mkVZS}JXG5D_ZA9n-)NwwXS>Ao#N$d|2eXT|oHWyqDn#~2c`T?%tfFll$Vx!81M;05EXBmsef}jRa zi*^77Z{8`bZBqqp z+IDY2J6YM*>Q%y6DW#Da-|gP{M|tSmbHG1~;Lkgc()9)1vk85{FU~Z_p(@X`{tC`* z+MY13)-I&^ThxI41N?M>Y!*Nel#eVnbn9mf?uJTSy>IHHIyZlb*!ug)FO{2nsv~$=;NH| z{H-9X11f@L9qVKbr5!uRYG#N4F&#>s;cNEuyZ7ERv)NMr-uHh!?|VJhbGWX>tb48d zw?6N6uY0Y%_S#7(+ljWw{InJG)9upylnCozy)9o@i*@>~d}S3zLMj`{d_y~E59WDi z=neaB|KGY6qh1Sz_33E4!~T~2N%umjPM!YY{BdCE-D?iWB`&h7y8OAAEf%>#cPDlG zpX@^=fcw=W<^QGY&J`WZOw&iL*uPRcv z+%7Z9m5g$gVx5@m=A=2|!A{X??ecJp|6iS=4fD)zp0$6) z6|q?jw%FX*d0HFYJwv-g@_B^bV3;V(8=)B?Y37fRzq4TG&)vSz;R` zwF$RH=g_s4FjrvX{DPGFz{W(KBo!Mv6Laaife&Y6MYCR?!L}VjyB65Kmfl`tzKa7~ zS#+-zh)S~eqkP-2ejiNx9<7W``I50<3;W&gPh<7p>kS9Y&%NGoSpPfUaM1fKEn=R0 ztkyO@{N2*1(h7&AN@do&ILx={v>WCY&;O`|-%nF=AZ539wm9^AY(vMoUT@D}v?dy; zXYB(tGi-}aaxB4RvBeB$BecHnT3DM?#a;E6wk(y-mgTdkG|>E~n&}P#lGZ7mQDp;U zx=Jru#Zf-hnW!>@to?FqmQ*oTQ%616>HlPZTQjR$fVTT-agg6XJ;<-j4e|$?F@H6a zZY!?6@C^g4@5vUrw?r-M@FD%t+8^b+vo`epOTEKj;~X&sskYLY z^xjLizl5i6*JvagMAVAk0DsGj(6?^rotaL5L3fLTbIg+7KQ_*c@=cZAA&w2dLri(^ z>(I6v7+Jjo0^0D2Q!(8)k?z$CgZ#heiDkP6WgAo*`Q}CubgzN#M%JU>My<7d6VR|H zvr=3~1DbOBGST%mT?3t-zR^Z@(UTp^9p69#QXx^JFzS-J?T+8pJLUT2%d+*jD zKlW-W<04so<8tGq`ihpTICBK)&j&Uw#@K1t>~+wM_Rdo=G0FAs4ny4radi=)m2n-Lzl^BLg?TwSl9hXatZ=sNWZR; z(tGaR{@Vhf*qfwSbr*e+ds-mPO}|%3p@Gg2pDl1v7;O!-IGj}?M{}6tG70ltXN%B= zfA3V6#e~OyQeBiUa0>QKDy^FPzbjIayrSUnb4qBBmXgO7>1Nl7LX>yE4zONv^h}}ouRgLo1pAz>*ijnsFpEEWu zyhq?nD7Mizs~TF>Wd_^9RyCXJ_*a1Dv%5Qw28@-1+c~Cj(f!|v4a@B@?u+JXg!P}^ zx2P?&{@c$V0$Ln(ar3y5k7wU9#!@kh7%wqI&OuIo(eO(~EZ)qXhsg$*I(MJNB(zU{qgMz@=xsPR= zs_se=OYGE$6igO6>|G8T}Gj;hl9{2|H$G$~` z@2&|wA9yj@Y2%=4wnMrGqTfdbY1cN_Hk{rg>aH{%Ze><+4wko-G1u6Ko;w#XgliNk z#oOKbKHKe1l%;Mz&wn*5$WIi?^iGX*r~IP^o4G2KQkUNn6#HVskS>3E(DA6bDoUDv zy8Od~71A51o&NW$2SO&Ir(r#Aqx3e^h+}_f&V}#xW}64O7Mi0QNYj)Bv^G_#yNd3h zP`z=HTV2*)u~RIz->3rv+=7nlp!-8jRpJd?UH%Q->Rvakh)-oOcbzQxp0PBzMc<3h zkF>Un!eybeT8#l!X+@RBc1$Q&qVP;0zQIg49;S8qok6a2vamC6M!(eY8oEO%O|7`q zMa=8|lilAjR{b4Q+*8oyzv@@+DYyuFY|%d=_A>3aPgS&3@YgS1IEs zeMu{lnfzt$$U5bbfOvOfH)?r!kJct)E)!}wL3r{(`&MzN<>Ec!^M@|~9A0ZnLk|qT zaIbz}=pE;q>V^roX)NM(Hb<4X{^|06(rv{27c1QNC$7q9Rio}@Gn#FoyY-`d|H8U* z4BFsUwAs;b87qaYSn(;^a7vKh@NMrs*Dn8#?!NYkN6|jJL$`6Y@GyPL}Rp zvnvR{@I%HTI;O)P#p}W&^3Lwid*2=Yt^=%YZLadYZ=LTSb&>CUtrPC|)98D>WpB(% zX1*)d>Q|Sy=c}1%;28Gv#I^K>?L{m@{ckprb>ep*m!ZBHzZvRr+&s?CJ;fEWXITaN zSO|NCQ>EAYE?%3nn%*sZt(e{{e6#pob5h+hGo4bQn<>?0-v`PrmYqUa+l=;G)=0?C z;+)ls%yhEnA^fi>UXK4y716tOZx!bwg_GeF*2q$5EB>d@5|JlJ%=h?yO7Bagw;H(5 z!?@Kl+!w{4BIlchoNJ3`uV$vud#lHUHxZ*TxZhK~r@CDDAI-AWp;Rhlsq8M^i&Qj1 zDm#$hgUIjsaDJsS&Oa4bg7b`*IM;)7u9?2dZt!bGEAjuWq7~^@-;6LiMRr&5 zjN%R9d?q3vy4Bn3ybu3>a;`MfUB-Q((u*Gls#riJ0Yz_)?s6^%e`1(FAiIUQUooBd zcv0YI;HNfl6%p#2q5|RcJGBQ_Oey$^LMhbCQfM!tlN*l-DSTg)0}aM}=Zff!%ph8k z`J#$fmW-SlT2M)e5cvrqWp#ma&;h_N0}^-x}w<^$km2@l-ggA z+5(`TbKZ~t1uW$5-&%W0bi8rdmcKyK< zO6jodT-NF$TI;*QaZXv>T}5>E;e&8owJdH)(NM&_8;*0x;^q~R&ihw5E>9L`E$WB3 z&pd~w2KkIDVNYQmAC(j2Jy=UufKHzn6g)I--?d|8lpWCZzAtmEPlzX$8E7oq1q zf(>}FCCK*&UnmdxEzR*o-yn}2;ryq{;#d*A&$HM={d3wWavYPL*6}$hevN0&7_0Al zXD9_GOW}Lxhv2w7oVr04*Wj#0+%4g_fb7QQ51oHQ-1u@qNN=#X+|i9=WY}xo}(Y0V#ZEIQ+&2I@|T56dpOrHMfDzas5RK z-xv;`*04b#`9VKlD{^fObB%8(rQVa!&xFH=H&9BCxNj=yX_-l;^5&cuNDlLHM8)UKkEP(Lkri3Z(EA z;c!>OUDW?l_&wq9Lk)LdFy?pHh+OtCS5?EU@Lw&VmxROr+(2j1R;_dmMVx?TJmBrlu_S_3n_**qrRgf@9Q~sjnkWc43k2UfF*z{8; z2l?kQChZu*CoaYK1>GNZMeyHPH~*8dhheKf34h+#j6Iea0Wb zU^DOKD;(lVP~0hGbzh+@I$zp8`F?n(e}V8!U+2^P=6Q#$HNDRQbowVrxRbwm7T~6u z-u7>&Ke^9y5*peIsZ-{mk@Bei%{dc{QW2 z7VYY%Wp?$jy>hF3YKOR=*y-ODbQFY;88tdz>|XhLJs~&Yg7tcbe@I7U2cks?8PMl0 z$KNoH6W-+cx3I3>HkrzPqNev*z5&8nzD~a`D4yk;Agwk>3#-l4)!eGk^Y&b{Y2ks; z+Jdby*z8p%+s&yR{?>q4sxRs5ZJ<4YD6I3w!*@c8`6$rh;2zGsLN!|Vl~&pV==7U{ zk=4k%)peIr`8G;>3G_{6N_(G_!fOF(A7%*TG$_`2hm_wN0dqkp^{8;_?LFpjyRNTh zb(t8``9dBy)kJ#2SVbv4h?E{hO6vmR?ne-7ER*fAR7pyzbv|p&Z|@a%_^%86_FnOb z=|TR{|KfS=3@SsCa8^M-hqdc<+4eOB*?*W?p!q|$V_p7!$Iy?}(s-q@IvMT$X>H_u zozupiFxV_rqjDp1`e;vDxZV%;lm&(`bCoH)o?p>JJF*@Afq~-z0~uj~F4@oDCQp5p=9g%9|f`Qw2kRvPA>0qy|;_lpvDXm08BUtTND z$jPYdJ>!G?%NSdCLVw?qO?7^;y7%3#o&I4$9|ipH@yA-V(yn)>e*i{HN$8pUCWCEY z?!dWDsl6Y|u9$gT*waj$M{O@XSr|g0)N_?H<76R5A%u3J#)L-3A4C1I2f9A?uRK3- zGtc)+5Ayp^-n*w$IcHUe_Ix7y>?T`(Y1QB9KhzzclWOBijXCli=%D}WZuzc7Qk~AX zt~DeRq22Dk?5BNb-4V=#ki|^ssH2}T)}BTA##ietLH{p%b-w6o@!6G3$3X5&x~#lpus8suJC<+Bm^)Q^mUJ zj05M673d6=+8rbKCaCnbLBjV3I{gDt#)T;3sgXfG4*l{V))kM#mRKLwsq{7W1%Xs! zz(1dN)-L>jF|Dt#PVBQpS)Z97HCM&whGyv2t~VTdM{xmHQ9AS6R!-2p-*?G(<>*;~ z&iB_A*g^kbECXwi;s2ntv%>8}cmDRziL6eFE z*&S*(mp$5ki0-@WbrL0XSFg^u3VEIW6!ylak^MYTFNJoMNp@rCJeBkmPW`LT!P_x6 zCBDw{6%(j$TMo$eXuyAQP%Dk`4?9R}ic(}ap!I!qK>XgrNZ~Hefel$H8?uJE`mKJY zeTuq2)}YB>-j;rGLoq@JxcaYtwS9_a07CnJ#Z5TZEZ%#h|B5hrSHA#v<&-P?x5p;< zkGpDB7J5GGs=aNDzAi5%awn>|zuD z{vhPG5q^Rzvt>#@O!xE*|OGGExZFzp&v#GvVMQ9Tvj%BN95z_{k^R)t#3(f zZ@o(P#EFiGUM<{z$26twru9v$CCxmlS{r`;A05`W2I$*;9if^G2t-ELE&E>_LFF4> zD}1fMB;IW@h(DBZFCvakgm&jn13GizNc zb96(za9%{^j?EQsSLyU;byO6XszPNvwy(1Gb=X%=L+1Z{d62&s<6zZgLH-=ZW{J>8 z<@G>AR0fC!aSuRjY03R$qxTD<$2&Axq;*Tz%tc zO`Xa@_euo(BY9?i*hSA<->? zhkSh?rBd@y|5^H6U^7E!<+<&c8(!7qPs@L=IT1SC2$#S(w(KLuwvWTufNd}sde#hk zYUz+5|MlM(%ghY&FF^LjUK8X$1Fbm}dgk996}h=z8@{=pTYdfLONGQg8U@3(@?la;3j|2d)?jPKP#-ewZ4kqTz7w6 zp~M}Ra2tOU_hLol-M6${jU>65ou;bc!Y$J}{YCE3nkL{kBV2es+^*HfxKqO6DV@eW z;;qygeUVLkR@vcyy|b)JXFE6}!4+z)XHcfZ+-UJMXqi4^e7^YP@iH5I0iB+j(_Kq+ zqUuC5tJ9%;yP($+uL$x#K&Dp1-3FU95&A9tZ}Mli^lndw|F&SX!mgn{Ip{Q`a`!=+ zSItMiQoXPKTWF6$ZohZHw?zE9R6c!R9$%qd4K{^&5dOnsElo!UhG+kB8=)L}|W>m*$%ee@w8Y zAV)ZB6!2FCS_(olPgym!<3x=0foPM789}}qeWb$LYCZZVX)M)wX^nF3c`OjNtN+`% zXY$2CzUBp<-#ePpnpz{bHEs;(!fjB~Bc>wu)H}6~iu0fv?{lld*cZ;E4btL2(=RTbM82XyiYumZ#4K`Da823eoN%+d%ER@pNP2VQVvW4Ez z>hMRw7KWnWZ-;L3K;G945AqvE$aMmI!ra9ue?LvTJEmiz0B%v z%b@m}RTq3u_jaPRV%KhQI=F|UM#nXv{ftN83T&~$IlNd4SE{P$@mF+<^J|ps-5Fga ztsB;1eIJAMW<7Ms@x6>u>&~i?pRaJ*GQwwf$^u5l%rhG}Q=F{?ey(Dga1wlXK=q=< zp(>#F%1UQigk89dj=ZY0IjYn(*)+2n&kRp83wGN>U1EGH>MZ(3fipSG*?Bs|ncu~g z4a~i09<1B!$Fucy@iscKsP&yZJt2v#)((GO7c)^gdY@bgJ$1v{wMC5A_>P}pb+4d) zyD&x`!1zu;-|WF}u%`EW!NVeN;jgjmqk5WF(fbZm%PRuk2J}v=Q05N;rW(=r_ds4% z|9Ps^$FnUc{myXNR9$oqKC8egv1Cc)7 zSgEsFrFzGf7VH^lyJ?T!MjG#;fYUb0r)MhjEEi`U?h4dcqGlXrZ7)GXTww8;Yj zeVN{TrAud?ht)z{D)W8WLEjcJEsb(Tm1)}3bgJ!|H}h{Fdt}_lI@46Q+BC&Ye53AS zzS@o_v*H^BZWnRtO#0GmKUX~;RjR3oulHT7E*{q#YTS;2$aeyCwl{@0k?6#cI(55kt4;i305g5us^v0l(>jjC4D@ladb_@% zBFfqxbUH9gnxnMGRHjMpfxh?ztM9DypMoT*eUnZu#+u1zx7rd?>3gFSJ7P;I+E;wDKdZy7%=i;=Uc+ z`fRmZZ>r2cfN>AZ&H6S?bWCZ&7K*gUAW(s`O2I(223gR)%ItdCdW-Sb%DuNHnU`puFSIMZg(vBuWO+4 zm>Zn#_uKhv)ZgmMROZx%dG6@4!&Q{hl}Kro3#m+HV+vg{JI-CKq0__B;ujH*cPy^b zA(aI}D%ytIkY?UK^55!;+qu|g*v5Rpj*==(8`D#bb~S6us5UBdR72EuW;)o)n9fv^ zACs?gM{U=`XDk~b8}p$rL}+}6e~Q|vE;Hr(E*@pqxTAK`$$`{-lihC5u^obJFyD_I zx>S4q%lY3nNA1v=4BOQ&l!E(%R!znSt<3%bcwEg<+jS=Oj)Se5w32+}z5F0TKWdIH z(`;wHu>rK7n=|DKRwXm%YbWyPC1Udz!anY_Yj)MCffx)f;RJOI7)dCsk$Y z?b=>%SvpbTdw=@)UB=#eH?;3b8bafndU{CJ!oUE7-(MdfDx|OC=MA)}zyZCD6;9>M zbj->eeoc_`aRt1;j%PlOC8SzUYE{bTP~+60byhF|>jD!?<^3P=TI8lVZML2F|D#7| z%c(jR$gKQxyE*#QUDvnjVy->ewp!=LeAY&%s!;J^*z#} zF4Oo*e$x6TwZ)=__qS+FBtIHE&3Q2n`V!^gpR{hudqtc0_O#Y#Z_)T}J@upiif&fN zjTtk2=Da!6PowWnz%7Ck->Dp#!;cQVQQ6NGXG?WxeF=>vHOl6Yy+%s-2VBBKo@hkguyxVZKJ&^=te19meLoBg&b|!A7vuegST_#`lDF zmo;kQ=;>*?^G8*7y{$6*j#7P8=-onY%kVw*(@DFk+MJplwpQCH+hvYhs_Zw;vKS@9y*g$MW?Wg0rWu`CmZ4PO)U-N|L01^KC6s8=P9n#+Aaxy)oT>y z3AT;q-B~AFQn*yEt(3DnQt5u#nrnSd<5IiPZnUY+v4_xOBlUYKvxd25l)RCp`^xB2 zm#Es-YSlKgt-blKWR~{qJ6TRsV1$*mt!3IF)0{DGt|YcK%kmD0!d02&-vp-rdG=p9D1QX?;I8I1HxjLT9`?eULez8hb$a-kLSpsecQl zRlA8Vql9x(4F{rzk+-PneH241)$aY1tj_f|V_W@C ziH?%!n`;Ef2zN=eRZaIgumNPkPL*HHP zjg7nyud|KF84&(@XO823M{(9T*D&G4yxKuKa4H8q1EY6DyIb^o$adr2h#A6_W_4_e z3AurGZg|{QjNb`ITGdO{j^U1P3%C&#Go#A5(6>4UYU;FQCOhWTkqHWkG=?SeL@TRf zZ!wmQzRH4o=1s=*1$kAa^i5q;QD72N!79kP3;KO&G-l^N=I1Pp$uFE)QlK(x>i(Ed zy-0n$5w%_BD9=i&bJfy*7w}qHHobi&{pbCkhMxmx zJJPF0_WLm^rS2lfgE!LY{@0=w*QrJ@M|!r13(dIpnW6X0;%!emoHnathwWKguA?L? z)unGYMrYV=vR!A(u^kT>ki!?eYHYuMN4Fi)=-W-&Key|d7|;7Z;GakBOf|H-_vb9F z&G(h~zL^+b+rmj-JX&2RozHp1U^DuX5;zw(-00q2WyoP8R%b&$3q3|7a#TxR6?+6c zs-!w@IIRiAS~ecQP_JcmThX@L-VE=_CTxZ*+@E7W=|yRXFD=Z;b&$U(E81ldcEAtv zl~w=72sBlRbJ0KG=SIfMZsw!@ObFX)XItz!q|a7oyD)pu-C)yf@Rgfv3!qbvo?&LU ztH?$v8j&KUR_XXApf{$TWU2*Gn!1wdMxPyZWFtRo_94b#%yP_j?8rLgnlt;+?7{F2 za!o^8Y=p-5)|o(6#v`9ZwYZ2ce#Z=fW3b~{lqo@WkKfbP?=)W}v=&MBG?b(C49Vz> z=pD#|nGd@r3wB+MgW`(MlnJTUN~uoxl~n&){gi-y!v@6-E`>$#eqa3M&iQxO(F3HE&Z3 zQc6?a)|62zUe?U&e*Q1UW}$xryAg-qYW%K&OGo%p{2u))V=?$W@L!RAd9BTqLuVhT zw`lC+xAf^fdg10WYUA$Hi}$?Y&|q!+>2zaOT7%n>tkT#LC4FR+ z=_8Hr-P4P!UU3vTzMP(zrENIq@H%oG3ut``c`?IoU0cvr zG}&msH7zCl^eQ_ah$Z{SRD z56ukntKqEhFOrpgE9{F5*{vRlrDjK7tEyhLThngTC8XJ~lD2gmieYKc$@7*sX>1?XO3~THg70Ur9)%nt85hFY2eboZxH7US8eE>Te55o(9>INJ8W9v zE1yfcbE@o)oEdcAsJnnOC)FJaq?GS(#zRT6(pd9E)EL@DPt)8h$Rb@|Rjr@}K zT*{y1OlW_-1*y;)Xjy>LuCCb@xQvxnZ3|?wysC24=o**#k!yV%(%@p8QQD(dxXq8$ zN9kM?rl0h6^KB>pQJC)f$~MkjAC)MS@Ay9ZU7UHct9J?K3ne`L)mS(2PI?7>$ zf^(|C+4j}NZjI?Ho61c&sZH*slv7;8QTnEJU_dFC@$yp6tZgV4=5BiXVG$HG9on>ilHem_d%SXO`;c_dn3N%_o>O&dXGJJ8qlvN;?x z_iIr)$NL2+V?E8~mAz18*@mQJpl!QNC-nZ@E-R#?BFoeemW|S6$c9CTy}FBJqaurB zqarJ@p(ZRFdUqwp)@&Q+c%~p#XqC#DqFpNVZmGt6k&Btq(T8Jfw5L9=fJ;S9lIS!e z^`Dvg&!{#fR=JmYn>A=VYPIG0Vw-)_tIfU)eRDPTfJ2n+GRXD>l<#z>Rgi6Sr1Z|= zf@D+d_nmH;WNRC$!=*R6BYlL@Iw?=Cj`9?9r1FTmI<(L5&w!zv^1jG6#I_dmG4HSJ zL~E8VuKGLv(|6p)*rH_LZHzrY@|kMWl`<3R>zp98#J@2=YD#033 zV{FtK){}bGZJosd-Hw>)$KKKGP^FLi_mNiZ?XehN>KD{)AE-22A(^Ir`>qh=eyHn( zfS!@W4{|3}k-t7_fJ+=X7SdchzHQZx;Qfb#l)8Qr<)qmWH$CSh(?6f}%~!Ounp8*k zcC14Q**I8HBi98o^P(i{CL}f8j`(n^)4SOdJ8K+yRW)|{O2KrjwnDQU?=P*ba3I8S zeZee2b7t5oB&i6=?LZr)m1663@cx9(ysBzPhOOG3ZW|Ha;o<%F)QVC|_CJSN<-C7g z;Kda2Op{2j2|V20!xzp}`;P5BdX&Pj2B>=yVQSxJdqZ>yFe*VK(^J2~nUBAUm6dOQ zINlZP=@q|Jh~FKKpBN|JKccZkg?4v1lTB66eENV%I5VgA-LsF?9epLT?yCFc=FQx& z`7P{HZpyS4%&=nu4ezw`*HHM0I>ThOZ$}%g%cw3`4VkAt zD6h+r*NRuf{b!BuV+p%Zz=~n-N@271iu7)Y&XVZYBzlTO@092X3OZe)w@LJHiQXvD z$$P2oFGkzT+i%n>)P6tg75B#r0^{!=v}{%d-Ac#h3) zjAd-fQ2tVmV|gCN$|u5~VfOrMxkPzZ86xjZW*h z)(sqMb2@8ViW2fXs*JV6ixR9R4t@@P4*sHegikx29^sP_u17fh9Q+*oMNtU90^v~zho6I=gTF|J@F@t_Asl`Veh&U3EyAxvxEA5? zbMSNU7ikba72z6$!_UFb!C$1#^EfRCS0fyLEBqY%MXEfHiNaL~hu=uy@E37;9zBI~ z2!~%o;qVusk5rufcQjnjA3u)25<&E6(V|3eA&7{&LG;9G2??S_5PgSAHVN^-}5dm7w}_~9!j;wf7pkDL#xU4QbAProu!{!wlB-_?@9^)TXhATlYh~x z7XmPk$MsGpAuAi*M3fdQuw=*IXO z&W=hcPP9yofpPvBzyj;D!s;bQuLmt$9$6T&tGO_-Ker*l>c{VT?+ol%MHhL}IX4@{ zXjM5hmwkE+&Joq-=Gl*MA0i9`d6U|rZzTqTla>Ajd>xuFH9CyL-8JlkP%~X!f}DGo zDy@h5`EQZy1O}b=HS^!=gsT6r*i~QMJY@ zD{f)*Yp_?4RMEnn%F3;ab$Nfs)z_b^N*~E1=Q(?>gC(7}a{3E!lGQ}R?1k;gVoYFy zd1H=?l;baXy4X|IPk*YEX6HXp+ipzq)hcmprQDCG;{?4?uI5m5oBwC7|3L})%gh}w zUVS%W`9xASm2S7#=xLK)nJvgpG|Lur8o+sY)@ih!oH-DhjLlS(=D`tuB^+Kae`s?( z-{v}Z^^H?rdrV0U(tG@~qVyx!HJv|$d-nTNSTc)ia|e3_xBeNCVmJ5s_uOKM?gvRE z>1oRFePI?nv)_7}UGK0~Is z%enu7xCT(n?)RbZoS$cY7`a}5F=$3*vk2nZ??YIYCb3mV%9^u6%0K@iuDnX8*N}V; zKXp)PFrr86Q#kS{*Nn)mRa-akNwEp;3&MTo{F`3F{dL^yw_7&A$0v`By{$R7B--6= zGdpW5es9jMHGD3}xse`M3{msKL9RUSN?tNJ1GEJ+ofkWaEeK1jEfWgQyFCn>*q*oi zjJVn&p_?;hw4^fmXa6$6XzA?gq0S$-0)2Q+n6!D%L$7QlZdB;0_U4ziTfD^`9(@gO z(i+vhZ8K*Rl%A*Dt?OoZs|I?_WX>C`#-Q9av(js3?52y7V6kU-euqWFOShQ&NcK0v zvO$MIIVj^)l94p6##(|I_e=E9ZGlagkBN6nM|rDkCG@~DuPhqC{$>de>h4H{ZlqUU z7Jlo}`5tkRFd;^Ci0 zDj6_8H2Z+<*Yji8+yJgfdhpzuT<#dLo-V$w#Oz1bLYJ|1w{_n{>W<#xam@;ogejgl z_}-sTIqY}i9VqM}xzx_@2;TYZ5HD7cB$<;*Le%r`AAe-ThL(F~^_C32M_qB3VD$Ub zKM7Z()Lc87;RG@q%~X5#qEC7&029FM9p&ELVg%To6!n+#Fs zu0s(9y;9nr&YyU5xg2l>tPe7*7c`V!rAPd2Vp?tNP#W0gakbLR`QKyR z{i4^z?-~;Kx(F&KfQbdEOCnd!n0m$ka`3jpz~+N(-t+lh+o;>2esp?!{&FtD#dQx{`2KF zooUrAe*boE-`fQl$9i+l1$Wfj3xPzt`e}yeZ?@x3jGoqSvN2b(@c((G2ybf0J>vdz zV>qYtZB#{^K$2d6t$UU2?J&aeA5(oEy~=({sWe9B<&ZQdwbEar$)ExxzS-Kf&ve zeIbq$r$Pe~`*nS)E1ynpj2Eh#p@UR&A1q+U@_vxsHJ5Jjy|FiQ=Y-cf{v=y>WnGg@ z^^Dxn)+s=UdTW|ao8V}lN?Z4vbVNh#s^15xy0Szi*9~0PcF73YE;&8FCY-I--zo@( zpMK_is@Opg@6hbzdET@rK-0R-m!M|Yd2X8!%n;(-*=W9!^!Om#ES#wyLW>dOzyuiZ zA>gNeKY8do^b!=!(-#dB_TL=zMD4RCC^~l9${)H$DqadV6Ysb}i+PivC>TDiyLfM= zM;rkzZb(;gs(c@(FOa$T{$G7hCQR2EfNZm@ZWVFP6?c~G=k2X!^gD?*i1Gc4tf7*Q z3#4b(v9JFP5O+R(>>d9udCPO;vswP4zb-|5E9%Nx{{6EIozHo%0?ljB7xXB+N6~Sg zevF+KE7xC=0usU+?05j{ad;D8+9F?~@}DLNO}KMHi9q*y0{wSgLB2AELV*~Z)57k@ zTboB+>Z6mRQQ#e>N3ykMFT~t?j;)xhu&Y;EogiWx{Vv=-47@y}lLD`YV?yO~|5*yI zU0{w#oK(hzT(xgvKdCwFHq5s8eg?~zR-;eW-K;Z!VM8G_PIbm?m(3`{(g?bvevj?6 z-QPsb-y8bR?g-hxU$!dXnxg*|T+61zH3>tET}`P)@0Vk!Q+V& zz1GcsNo8%tdi5#^ju;|&@kg`cYk{ty@}3_Ir3Sl4_YrUX+ehyz{OK!5TP!~lt{+ZS zdO3H)sFlQ^&`}o$j5Yg1dj`v^QHF1fT>79n`2ib!;+weZoHg4g-hh_4G23UZ4Aozi zo2Q!x9V_#896k>A$EoW4{i~>bEDP6^o#9$fv<95=B5Pc$isR7xGBV@Pm$5@wyu-G$ z>=MFp#d?f?W-*T-kkDv2s+N9dGVu;7?esRh@VRI>KP|?HLwF;@B{LaVu{0-j=%irs zW8gG^u|qb2&EKTfx|gSPDeU=5Yt-!2wdFr@r$Hv+W?xxc!^apq+$1_^vm8C66%9KH z^}pcyTfa|Va)$$r!)>j>O6)pOk^8y{ikahis-prcN!AAow+>6K!DHzzGh|n5N0;vfz^KvLHL4(G%w>lyP(=!@qNb-4z^{6%=4xQ1y58 zZ<3aq$l#sWiO6=55hvc%NA)9jK18cOK4j?HQVt2>V!pRs+l>|4(G zF41agoml_+yL3r7Rs3I_3zJ4uTg^BHh}Il|G9Cf8ZVseQq130aA9dc}8sQ84im_f) zUhRCvJ$}wW4qz#D7Zvp5^{5eT>^P8_`73-MI6@#9A62if8me}wSLU_Z5m-xAo7$MI za*d2#H|4xxN5Dtk#%DXOX9z<|x67G-Z=hF{JoS|U1=f!^Df;9R$BXiqVmR84h%~od zR?GEt%pDfXdOtfS{k=*&)Y97iW!qQ%@QRx}|4S0hcE@sl>GHp0E-4?f4dtV33z#_+ zu0Fgq4}$D1!>@o(>BDYMzwA5^+wEf@%$04lo=!&6X(f$oY1^j+V~ci zc$ax9O2b$9HtXuopHr$^;+hhdB9#8#n=`{>)J*_aJ>CulzvisHDLLmBwi?}RQvbLA zWKQw0peIDwgKz%9-E03QKK%CdvHs}mW1ZpdW6g>y(!ze2QXTsQcXN>X$6~&wc)a=k zEe#+bxtfh124Wje%vo=}X|HV4JNX3MU)jC|F4vUdc~L%Wj;i)wmlkP zNqwAF{J)fOgPW3gL;Ew&VC`k)J9G++Tjixe;$bSDOeQ;*zx@G36*(;n)3rA*rWUWM#|mpxdv60DrpK4hRS2k>i7fofoMig3}+P zYoC7I-+hV6Wd1`@I3~mDQceI?5vkdFKSmfMrdm{oN!jMPwB?jDejIrn)mPe< zao#UyvoZQXt$5^)0_E0(FT9Z8j?43i;)6LJ$5ZfcvjW?;`ecGZ^DW))4Y zW1Zu)A{s!iNl8CaXM*sRF|(=TDH)|zJmVDWCNiGaUgk-<(T%}%*qsi&t-(Hil^i{Q zxBGyIPCwXKD)H!%yY)wt@I`}A_vE>cA6)F`tCDQRZZ-Hi@7E7mJ*y~bi>^sLD8CAP zaBA_pJQ|DQjWcEfCym&I=!I51G+2C)Jnb^8-{5)j%?k1M$=Otk6K8FV&k2<$rMgvu zWV3t*cIV92w%<^*czVyRygz)Bpkf&UqZFjVE&YJbBvU z?bH!U(?Xuz2cOmeA8ZI_$83k@+W~i8AvK)s>t-9xzq&0_3k#AiI?uGOEXI3eL&^)P zRqqylFR6>J9(M81`}FMe7!}MqCpsB=YuoJT?_!S7;@nI(hV+&VaOT?^TN4HQ)VW>i z=DIGsMKVaS3x1{|ZlSd%ujH8JG?Hd?0>wF0@)&>Ty!Io-ejjpYaXYXE%%&aoNvfw@ zB3~^vVj|#E6(OkNsleeAhanN*li+wn$Odn!>M$)x=u{6~3N$0(>hsR8p0~eM0={9A zn|+`9@~yWj;b666YZ;@Nssm*{3QZ}IDgiTvJ>(rkHBxlK+WQCs#~9>SXgqa#o0?Pk z{pRBI|9Uu1g>R!Rs2bm>soErh4Rl7udAk2ZsPy=AroSo!+DRhJLF`X?h5AgnWakfW zu7A#QQ-6GlHA0*sq5^}Gs?m(kywSxGG|WZV==PiAhtEvZG}LRVUNlOA9w>j1m_-+b ze=1k#R1gtRPo1PbBuc}$Ro^D&2Ibs{LbwE3M*eA)>4gMYw~*!Yc8MnOKqJz>D8TRI z>%;#3xfd=*Pls--S~_{Tt?(0#Hx;zzs)UqB~{iRGEzKm z=l@Aw&z>dwuUrX`WpvQx$UF_Nx%GE+MtNYv;bYQ=mtMu#q9PThS*?l#`P>sNy_4QL zOO%(#SJxl0Lc7M!54_wSD2S3{zE!y^VCff6}QPup9S3Ks6~w0HdCrd2WGjfF|hk87h5K;E9?Vr?ATj==Dx!9D5&^Z(0?woWg)7$se0E zeyN@rEPc~fag_haj8(&OF;M6{!aN!NGPupX-Ob=zoxAVnI`^mSUGmeR>P$O8bk=Wk zySPIX>nb$=4kaITm(nnP8uxEv>-8|%>W|Bkq*>Dts8fl7pmO`M&qmx$Z6CJKu+wI{ zigrj#9N?UO$m41llhY1+&^B+tz?)th!!m;F3$vTv)4rbFdStUl;H_M_zw7wE*os)qE7(ZQOVgJxa4lV(=$gcv?%eBoRxb+KrY; z)JJn!c4;xDBJP}d@7_F{k4}@(Qj)Y7x1k*-e4|Boc5d7CD%D*jFY$1rqK8Cp67x_Ja2B)KX>%ZEUsN1Cpb zi_)T@{j{@PW$MdZ=({0ARl=#%VAm~W)YA>M-y{=dl;y_sAnD--+gDKtVhAI8)apuE zRYsX@0OXG@Ui^RP6#(2or=L|RQ(bZ|n&-JfGKUc2M*^;p_93(j@Use6ifJxNZ$lA&Ht0gJ%0)fc2p%9^l(kaWnSyx(P>*8m1D*z4}4nD31@pKHNNn^ zy8J5vwIm#)@uX-zY#B|vr;!zS*FkKvv14!fxirD)-t=!Fp&vDI!Y41yzU=gTDc|FX zX?Q2|18=hrf*%aB`A4Gv-ub(G1zDFcb?;@{k&zZ^ExtQ&x~WgKMp!K@PzrIV@K0wnz~-<|$8f$(pxwHap!VB6 zDbn0})P!-iurN1LXKpgj%W_nTtKLjk*lKi=5!@K5U^_a;6_60=SX{`&jaQ~^x6?&i zjy|EyFE1Qs)G*K$;I22+rPm1iQ^r+quPgj`G>#h&p?zLn2(lh^Wds*T2FY$dW}Gc5 zgiCK~(dJhbO37{3FwT|~(o4T?kKEhae00u*XJaHbMN%H>PRnfua|gskE|wRXapN^; z+n?zoEk=WA+bwjl(wpUsv!4t9a0O&U-j#m+r4UWWwF=TjSd1#rMi}eb%55$%wm9f| zTaQXGf?<&gHlu@F_?NWWM!NRg0be5T%58GdzO@qSKd$!!)h600K@k%e;H zcn`*whq~T2qvo{jrn;J3cn;e9vcfuUybG=N6Wu&6{C~7nwxbJJ?u!pn3ky@_Hql%G z?U4{$U0dnRe8v_VU8MDB1vj3N_O0zG6L&yLWWl$>TZ|fybfsioM@7D3B>q$HIko-2 zk?e@<|0S^~>5GOGU0q?@QH0DUn3nuK^1H>TCU-!2q+?m(g}AsGXTKNn$Zp=C)qbpN z&lT`7(%fqF;%EaG{x9NxjQ(Fp_O)#?ly>*Ys1Y~biMAc2Yb(1s%@vRvx%jm(pAnoL z86>?a!KeY$b(7ot%?PfHyeqSLiSZe-ur(s_5LpPd813W6yD_#n>Y^$N<+$+Xw9mg4 zPMZXTrhYs=F0&<#gY0kiB?@C_*0OKR*$vg;>F)(;lAP82|Dms)$fw6Ope2&eb02M=D0KPE zwWePWRRt12Z~U=4pAPckFb^I76+ezx^46LbKt1oW?e}Rt{|RGCY?6x<^#WV`3UzG~ zCjMMEZK9W$f5yNAV(%=y%@X#}wx)48r8BS-_To(r6OSy`^pqhuo4N7YJy!_yZbjJS ze(jj(9y_0xvv8A~A)%MsxJk{mNk3xghqTYpAA@-{WrtwlCc=2w>K>oi3I`^BNx7NH zmjK&>4f4vZ__;Q5M=U7_RD4B5%VjJ(?$88#g?rC)!xNjdmFE+5eE4z}o0^!}r{%j@ zES|>;uyj-?i;{Jocfp7rSJcYb928E4GM9gt-D_oT{LVJ7a1bHZ9ig}!^xNnBe8BrM z(NpgVVd)Ax!JBW-Q@ANk3hxDv;_zC68u&O*M$`}fT5MY9EQP?AbB^E3Su8DP`c$|! zg&Uo_agPMJN3g>~tl(-?3Z*OC~h& zDbIh@@exzphqdneu{WB=hBdOyM={Q;aqKj5-$bKfDs8=0x+1RmC z$}3r-)0g-;sqRO+G=##-jb>f>@Z~4R@57#UMclEj#Mp8ukZ=7ZP1Elo5=kxM^HzL& zaXLN@=HBx#by2S*W;n-WoTJr}hWNY_pO=$xlMwA*jsrUuzI^xO{UCZ5HYd8=+jc9D zO8lu^_A^v`o`dguE8G0fr|rIduM(PI$_`&Bdh=?GH`MHZksrm{kI|cJwyNzZF-*&L zw@=Z3H-4`vwH#|Ruz}L?5oElfxcnWyO!t6bIilX~Ta1lwDsyR4k67xJ@fnnuZ&jXe z(ed#%ZqoJdIZRWs{iQl^p|+pT?=}WQ^2F!kW&aNwpDwGtEr_?5?uNmduc`DRQKwbP*8RV5^hR96ZJX?ulb~A+I0MXG}&m}B+AhEv`a8; zdGSKb|457L^9sw5^kwto_j9E`1=D?;3H=*@s905zPd>CYRtFIeH0^k}c=s(O z{8#}K7^}9X!QS{d_Cl?DWdh(;LxtS`>R83C=OX~)CW32|*OJilaZQxy zC|~0PT-d+s?9&R}u9B~3)Ee=odGTfupu1EF2fGot*Us}14Tj+SXaR=- zr9PFj@)2L>MML1W;Qg-8qw(7*j8lp;kL#eBIubHfMSrEOc-A+iUo zZ5rISBIFh*FI*cY$L#~csmtep0!6$LaVaoYr^&vEZ7m#{d05qEwjj5+U)w|v5raVo zP7{B^Pt!g=Z)NnTXbmfyu3S3b)SY?E_VsU6Fcmxtq21st3ByE;9-`hSw8%v^zcJWX zc92h+D~WG?aNV}Pc0YoIIiI%uOH4f9$5W_x_NfZPRK-Jo3gd4ehpW@}!wq4tsgcY&64E+)O5YC6^1mSu62 zCgkIP_DvPk{u+0JLMzm@Q#mu#Z-YB0c~B}<_0}--#hLP-fv`seH^*1a;eH#nV_)WM z*DzsaTSe;xfW^Cm{d#s&NReTR&udnS!g{$EQMm8xjYwtJMV;TuzGs4Fw;C}8-pyM6XWX2U7bvOD zxbY$8!IC+tXE^w+!J(lK;eFIMYb2*Uy3e;M3MucN#AJPouh*0QeD%_ss(Kn(Xe?dJ zf8{ohjXmpG@BR|?741vUh8w$UKb7@p3WEL?fR%DLIs zO;4~W^1|uN-}=lwFgZojK(+Xlf7t||!Cw1qMz!{PC&aZUcc6xTPvN9~33vJ30z`R#q$)YQhoM;KkvC**D4;f2FnE;x~P_c_%CZ zo|0^@aoJx#P3W)pc2XeH8m>HA71$k%x(E!irrO>5iY~!ctkD{q>@MeZGj-&Iir_ht zC)IN@3t+s&wBpQ zxlOygq>hN-UHrqhSG9Jfad2M2A5=*kj!<`BUr^X^KNmX>%!W0-^gS4GBitbFx->^5 z$u0c0-pW+*Tksxz@#wos-K$?@4?*5;3t$-C1~<=}N;);=MYDgKlm zG;4%#C;5_v8O;^0|Ngsr^}qlA)4jO=`|tAqd0rG<Evu`Zld7t{jVMBkU2@ELTet0Nf?y=63sJ=c}1I;=&HfR zb;b2E<)Hv)Y$44QFF;<|Mp;(w;*yn>le@^}e^qk$dBfo3!+1-}N5J3hw`$79Prko;z<=TGHy?Q4 z!2u}oT~B3LRL8)dXRC~Y&R!Ql9XbFPhq~G&@B8K*sBld-9RWD^5)nY4)u}IS$is%u zd`?KQr~o0(@2`6Wp&}1yk2vxW>+=1>R^~my*#5C3Qt8|zaHz@B*$5^O*tKl1sK-@A zlc*fnEALu+cIY2u4(OZmBR5!LxV0UXynYSoVQ%}`6XF<7(!~N7adGb#aoz3h>lKl0 z>vBZ!V3J|TV6#qbw~@kO`o*4;@KA)#Ubo^w4(c%dt>q%lO8D=V7@XKY7_sINu_h3) zCM$f_oW6*Y)A4IhonI0|Va1@EVsVCHgAK*)>-&l)Il;i=9H99@PMH*VaNv%2s9{E< zL)!MOybbUmgN`3Mbg_2QXz`y|+z25jDSVABeCk$ZZDx<{Sbb35epU?;LZ#{#luWv9|?% z*FDQyR=kLl4jtELC?+u}yK59$D-@CDuNm-pvsv^xvO|X~Hk-uD*z_Sn<8u08uPKAK z9SszwWk9!4V=7~Gm+003@@N_}CAA!aO`lUbieAtM^G82BhI$@$v(2CkUrGu~`AqW5 zlWG@O0)(WNk;saA5ae`upxm0O?^P(V zU(3$5sgP$Bp!LYLLHIP@RCEFJi)2j|ZdcCmd&t~TiTlO#E>cyjLuC^p!(Zz-4u^~) zHS)|8%Fg>dLA~uMBwYp+A6uyT);wTK1x?(tbT% z@jgk{ilq2?wSI3}hDVvig1wI@InjvhaD1+oKuqRKCEeN1W|~%-9%Az?pou;)H@v) z5WHIy@ol8uH?3C*q=Z7mC}Tvu>s91>k$k`x-AnroHat8;nOM*KDR6-93-#toMf%D)@2WxF9pTEKAvGr3 zP~p$o$r|l`tbHEcxyHb4sCO~MqlEZKH!RM0S1|cZtN6D{L0d}7l-3g@ssC(G!-jNI zrlaw;&K+kRKP^j}AQ9Mle zOi6tae%ra@Umr>E0coe?RKEx`Cw|709L!)v%}XFEJBP#kcBJ@6JPDioDw~QhD`+Si z&Ethea8D1897WpCz+v9uFTf)pKTk7n4uiUXh0CXU_k;4*s4gM%@{`zm@()CVH?vhA zBCl~iKO=x$pTCow7Kwm!Va&&Tl_-Rl5O_W<8IC`qxA8n9gV*)8ZLi@m_XbB7JK%}O zW0!$YBJq%WP}7eu)VOWk15OEzb>TEo1Mec|X-cz1KYaK&B9Y);Tn11oL?Yf_k^g=2jEG)?-oar+&rr0xZt&TC znq(eBcTyNZynY&{vHo~F>@FTqYlkEa93BL%H98LL>%XK1H5Wy+YhFL*tDonjzL)^+ zn1D;)A4(+S_PndMMp2n8rtEX)9(cstj1GHtP(2rB?jU4C@Y0at2@h}ow{e!;qr-eV zZw+zs-Irv`#<}9uDrsoc1k|(|pvK3%yMx`eOP!wQFU&ED1_*978dJQZ+wKpb%_2q@ z$HAUL1Lp^%|BgjWqN$$ua8bvx)~o)&v-!(c|oz( z5Z>cDU4D>ZQ9GO2VMG!!ux&XP_&KfS6}}854OJEDG}PMS;O_VKQ`kx65^YBqdOU+Wl0|SMx{X0a9N`sEbNCw9*g9?^+A*ys4 zFOu9)nU}~WIolrBd(Uz308`POFiCuI^HVwGRj9y}0QLncjFk+CKWshbdP^?cLsnCh zKE1GTbUKwQLf_mEJqboQK|Jb1Pg+5RI>R&j(JGXvmsIsJPKr}9TZ1lm{b{do!tuI- zJ0!&MpIVwyD2^EjaXJzzMt~#r~lRRid6DCuG21K#^w>SC6)?215x^jPu6wfE z@p#DHz`I?9yVBlP0F2>jFFTzp&w`P2`_m{g)#3|MIF1+sM24eDsolT$6Ud{o187MR zN)-69@Z_=36kh|7U;(bxp>xvdPa8Pmo6_V%Z)Tf)z}RfUpKAc^l1NwB2w85IZ$z?f z+XO#GSrqt{Yh45e(`a@Ohntr|{1581KzXE#mrXaP* zA5amU_=~-?Av4iaxY{8a@ zan2$(02sgAAwB`jb8L?C6CqkJglT8Jm#12lxb=ou84|9P*g|uQ+Qap4gA5D2EX>^^ zbYf11?_mnpRNC$lJbFlEDKhd`_=m$UWhlW&>Nh49Q_cl+RM_KoY@3__dz*BAA0(9` zz)aRaQ1*EQq}%~#x3(2IcJgDgs0=52=!n~+cUf;slXEdwv!whwbhm~{u|-e{AqX9! z#<>L$EyX00GZ@ZG6NWID*C=BX5cYh?Ev)BM8(3CiJrXB+$C&tojY%f61T}rN=k!sT z%{WyO4}`A?#o;DwH?w>LpbW$?yF_m#WhLxBp3fMbg5H z$wv*uYglf~)3!cB>TT*r(00+NGE}qbj*6e*!n&ciH;^s#_yLC;05~gz zvbu%vPC52M&=*2wHutr8bp*D&Gx298E}ofLj)HiZ=i_AyY5N&n=)auK*T_m%+o{5$pD(7s@OL=x91<$_*sgAk5_b>XJZwz? zjzZ&#s3)ab41&#Gx#po+yP>uB_T&2?xmcOhgk$WW#`^V~eS>74&hgNV3f|Ny(;cKr z#1xY)7kE(dgd6S6Jn$$hsrs7xVwrWXl~6LV(oCfu2>=9zB8*N9B6>{L$$~pIOF9Kd z!4)%TXB6L~GnP$)P8JAA{cUIW#0j;c1LV6(pc?O`7LmQO@ICJpJN*N;!RGni%T(vz z)RzGz%$33OXfYi_=mnY}RGHE0<~qTMtAvG^GeVrDC;yn=OF&+^IA>i7_&#$UIvP-) zV1+5C8ly8gBsB)Amn?`dE_p3^0@2u z{^6g*<8OzBZsBbQ$`ex8-%~9Rin01d{^9J`>qQ$8&jFguKB@kCElYzsEJ9EcH z%&QeOCdhwZ&NYUh$3|Lcw!QmgsLUkF@)P03ZQn*hR6kT5hswgSb5)vy9c^z-DSCr9 zQV~65hOIW7)j#1tD+=}{B{gRjd4*hMp25=%_5Mv|P4-yNpmg|Y;H7Nl_7m_IVZh5W zsNAm+S4@PW&n85As7wP$(rPH>P__#pa|Y$3t;$#%tftP`?eTfXCJ_}+?TgK6juoPb!V+RtTj_@)rv`_H7t zy3v4yG<4y9dxmMK#OxeC!?78*N~OaSUbq83CY(B#o(lJNEelreN6J8BQl>&=Hp(*V z4~jOu<4UpFJTIMQLyR#L(i+&85}fUrd#(5Q3p(^n&lhm%+?84H*RhH^TX&ZslX;O9AGOf7vPIr7CT-%~b_C&Q z$iot_UKY9rR65Kn6Ueeik*fKMcxy$P*2l<+lV_O7#yKZ$QA$2PMy49jz@yv3d+EKa zPA{Qh-*uI$DbzTkrfuRrH}gZ-_|aNByif-)D)&~NO8s?|5gL1P*rA9&7L_4sts!kP zrgbqKrPzz;y}oWH(;XP0cSQB9ic+;JIw$(yTj%SbLYK&nQtO~ke+JPNT?VIq=%YGR zO~-A*gj{1S1@hpGAQmptjgq?S1$94voK9iUAW#K&;{(OTl^WQ z#XId%vuO+P=*^GM5B0;I_v5{E`-Vd-?G?YR(p(=;mmV&ANLAgm4hW+Pe`Pj2o%TBZ zgcv$_3S>6udz-cG_6oF5-tm(LhsGFDqsFfBechM;(&$);DY?*l;fnH&g2UG-_h!zS z&Rod}kZLvCo&C=dIz~jvt&S1w3^m(y^|;{#duOc4zFSw%8H0dOrCPqz5~bcNXKubnQNzV;6R)f zpw{Do;32d$;;2^LPVrs|c9KWLnFV)bkSu(R*v|Hh-G`lh+w*SE&_81c@$7IclXni` zRCY`~^t^?J*4-Aq<250dTS+yp2d&EH6Sed&Vs0P)^t2Fd2IR#aRyHrH6#N~lZV(RJ zK#cJ%Vx`nA+GH!$76=|*y3j`%dsq<}y@%~z`GIJk@s^uv_A>u}h{wTk8(+83Kqyk~xx1z=4) z2f4Rxy+5rjJw^Ns86`HF^NkSyU4TJIHK6PIfh}v8aeA_)1|ap?>TiU`y6~4^_7%l6 z5{C9n!vsn!MxnF!ke;!N@Uoo=U;StR_bzwa74jrmi<9`nasYk&6)9QX;nho0c>;FC z7lr7=k)h(NbFjnPb{Zhyjh%=Ni_{pjO)CJgoX4dLmqKxT^dMTFZqJTvsyQQ9N|`tTb5+?fI(M zcNq-{64B^j*eMdv!9uQ75+9XKSPF@c$RUqDO+Sj%$uY%V(!bnuR(xR*Odg9%`Z=eY zhbEp0Mw7t}HU-9qg-7aWlIAt0qS?m40reXwid=!3$#KN>>%9$N{d4e(%S5w=dJO9N z#lq`OSUTY-tMjuc>-KD4BQUMtX$!)Kjp*E74}j7g=HKiIzDxAapFjW(bG-|C!S$>} z+<)t#FN^OzK;Ai-UaP1nkf!P*v#NCz?n8OSdN2aJ*)?-2h;^rqdb8t6`(%P#;DBNP znw=&cujS3Uk|*6m_%wK~rFLyg0~a`;l0_rGhz*Wm-N%I|yz?l9V3X7+8I zP%4?YkgPhP{L%J;2oBDwRfTpxroOT}R|1T8V2XuJ^re)>0XMQ=Pz~WIv$1+a*@>iB z94<6IcpvOVMYC`bxbdd{Apq!D(Y#sw8!Go%>-D32l9sFHIMtlr8>kmXRanQ$SdMyV zpSRyXYI~~tE2O6@5#?0uLYnn= z{HV;b8$((!YGyf4=wB>2k3~A^FvIZ+DdY({8Ps4JLfZ-w^g=6?9E2vv!mAek}IKH|>o_U{1i zO1oKM_BoF7@meD6>;5K(#VuJa;`v~R7zxO}Z~O2t8p?Z2&Yma`7HNB~p$O{(hqi~K zZX8uvc0ggry0@;YT}s{;{{`aluuv%c6iwW}xsLVSG)oG=G}F;ow%^hDQ3#-=e)Hbn zH@J#1Vx+d%DN^_N5F4r8W5Ur7 znga5+bG)Km;=c9}+b~@ej`yh1SPLY)+KeW1;=SPk5URq8n&~0vLQox$!Jd9}P`jVl zsvJms!wgOSRQ?)cR&wxm6d;C2ipQUEVlTBbC+063i2wpE3vkchANX3GE`I?28>cvE zZB9~XM{k@X9Ldw%h`w|5&rc)_ih53fQYrfwD)}4*mBIlnshElfmecEYU;^Zt#|0kzJRZ;nlx`(0**xWAQzq zGf{@|(A@uGo%Pzh*gX+lCD-^|VEYp!B;X6MhBhrrQU-VHC-t`TSUgWD&vn- zGHdnJpB{z})Tpo;c4dDHsAzU8wB&K&!rnFhUGgh7I>{*F{rkl7*^i5Z((S=QisP^( zc{P#6B2oL5mPiu@>HRVno1fp?c42IiSLjbTYzM)m^s&)j;zOl zlo!s;EteUXuQK0dwpPx%X;{KJQxJD)+v8C3|6%JZL7io08JcL^=-*5XpU zxNDG>7I$|j?(Ul6PH>0dPH=)HyfE`TGyj=+zvbk^-FL6G*V^}N3(}9SVwB`isC8ZZ?BQbf|F^aq+&Qpm5Ud)k)rb)i0R9 z4{O)Zh)W0&&rfcToAvaggNMFreKy6dZ>~f`mp3u_J`Y<<>*LHTb9c^Q&ZAvi!p3Cx zvQG?@_A%)0$c*JkpP#+Y!a=QlmD%01@|Ce(UZYXGKyxa)@;OH2JCn4rQlnQf1v&*$ zZX+1pE{#uzJDX%#iz3oB-X+6+!zh%(n&RHz{f=M_wono(2K+q9`*`uKfXl+7%=?+s zQ;F=_<(zJ8WG>7x!EY_BN^B*fc@=rZpVhmr^c(kRMRnagV)GXZ{FMiOPD-9YJrW2s z#=LJRl&WP>QI)tGus`5YPrWX>%tP>eCT|^esO>|4`pHi%#jhyS``r&2li$9~zbBT? zOWwxeU9UtzVCR=Hu|+BEc~7#L$wqIt`F#sdN@5lQinJxI*u^2L^qD)OdShaTq=#WX zy4S1+bf~Q-BZMD(HBw)fT(}k9n624v{xC%Q!o2vKMf)D#6Wc;Q*en-gUrnkO)v1S` zi%>!=jN;`Vy59g5b;Ta&D81aEM+Tza^^hyQFRhBAB(j;r56qfIGNmXE=p z7)(-Y1m>`!sIYu|5sMsKKl7s@LPan4h1}_lIdZ&KzN)A2kEeNd=RLMqCi+Kx?_0Hm z3&NI5GIGACen)RE7BQ_}{N3}TQVQjT1k;jtRxq&03+MLyD`&Q{WytCGk886^8(=C! zm1;b9c8U|`>bhRmszp1_o>yiC-R4Rj=LLOAofqHKCK1KONGQi+?+&-WS_I(zl-Fvq zw*_-0Qslhw^%G7KbHrEtgs{an@Am)jiEoltH0+!1j=}dR$0zJRrJnCAhZK0lXvOho zZ*7#R1v02|Es1WR|ETFb7A)Mm{=WCMwCHDHsnDb_UT8diQEagb|IeNLfw@gu!gmXy znTJWb?VsqL-jJ}pdYEDsXXx}!X&{$|n8sm3mhi<-akgjJu;VSdx}eREVadMF{aDz% z3gQ6cyfsUtzOV4&5PRqNg}>rD1IoM@U3^BclbB{cw@L;I_&Jmg9h#thruPspO2cN$ zgFOs=*xPMTSri?L7G4g4$;gvj6)b@rfgIdyZb8+yC-Z4;J#^64KuQ{Wh)PS06ZVjUc(HTl#7t*k zE2zPiGQDyMnQ~-{?;Hx{vKf$Y%PWzXm9!(!tZ$94tbySOXI|*%#F+ZX(%=gvZ?*|8~)!8FXhPP*oNX**Swcx5@iy(SnK};a$GMwo9x9N zAZB%K4}|0v__@C^CDvYHJGM`xHR$5IuVw{zTK#f=yJeE-k$C^d>c<>XHSrc@qAsgE ztA6_(+Lm@AoE6e}_vQ?FsGQZOJy1Z^5Cx~$Pmi_L;HCrRJKsBr65$aH*jIxN1ONRR z$PxZ?4%98fL|FrxcB+n?ok{hCIH$yGR%C+`*0r)-j6{a7!CzTN8D@4SjlOR3hbPYb zAF}^&x0GI7dWuZNa+gM|XB(#=^)ZLmH9!BBvQ$%V?If(nLX4MFx zpv`A@elSTav@FxQo*WG}d^qGUu5jkgbw@p7mPG&_e5MMS!>Dy!jk)B&*=MImrQfv{ z*jGF7B|?SW)kXolR-5Bu5cFxU?p9Mh*Z$zZuNCAx>#`wNO5 zNhoj|0SCPveDRm>ns!K>TbCo;E|%M#XLXvj*o8kmji(I=pT8UB5z-MTE7PqvLxhZV zzji<@pANQG_mFCpTFCe%X_a4d}u9y3DsLTr_#}|~B7e_i53R7Fw>N-&{mx${KfKJxWw6kGk zD6i3=?h(9Yt+;x7W*dg?QlC3nCey@nUQ#RR0iNd~FUia`i@VoAbkTY4g}V0*FGroL8!3un?1v~7w#FlkmW zPuNX^m2F2!a`~2^)`{gGd2H-raBzUvLhHlfey5Y9F&60|LG_eS-q|$%>-0bPs-)Mg zic1ZI5jhvdh}S1WsDnP2gMj3ih?U#VS9C(}K`f^u$Mox|6Ql~ibezp@>iHVGxnmHW zd;`)#TECWn&drL=W~B~LV794l_Rl5GS-z;A>kyuFc&hg*)wb(Q`rX__zQ(y-h_}xl zL(u^fuwU1{AxdP(N_oj;rmE|(XWOOSXHX`~T}=&mxXHC`C&qD#g)VR#@$|W@>GueD z-Js6>qNZst9Se%gP=>qcg=Kk~9(;oo4F0uH^Aq1E&(}wOr}Z{nNYRJ34Xpus>a}nw zB+!&gUt`8^+(|0yk=}UJUi$9n0M92|^fbU)hBmjV)k*xOvBt*gzz zFQH5<+&$bj4K~_SKb&pBI=VI7+nAfcP3oNQ~i-8H7|$omu_?6))Pm~x8q%-LW-$%xu1Qj0$_pC{;e!hUUSpP534J&gw>L7EUfAtqM+Yi~U;?I8~o$Vlfr zk%X$=k?R-LXnCZCr8;>zUG+O&m1a1({@Z3!$TJBq!A>{P(;u4*;KZw~oPAnxuS*e? zEBaZ|wv}U(u>eW9=GjGfFL#}uyR6RqCAvNe-h24u^>7Syv4Qcc9sC-=QqQwwSMeJX z!=o-ZdAtw|ya5YuPuY9D?m9}3yck~S2BpKVLlorztgHP#&?_(fO-r_vHbldDS??Y} zh|T?}N0*Q5v97#8uixScf;xnD(eZ0$fzQ!ktC?x3W~29TB!K4St}W-%q|a~Y z@zx+npoOOfOz;c+H0qc{pnMls@L*6N3+m;xl4#$(po)=yciBIZvZ{ByhFdRbb2Zks zo3BbL$7?%)3tlGC)U4kQ!;a{c|KQHN8-;5ldR1LcSTWRiwwUoSzO$P$W66F0U=^)g z$KGV6RgxJ9D}xCu6ziHK-ifYk(RJ5;%n-7=U+oXM1Zf;^oOPe5>p6h3ca@|+N5o6s zUwZ-B+;G8x{#?%^~79q{ry1O3_1#jzuW0&mqXh9NMJln6uLz{OR0W{c0i-@r_ zTw#v-t4Ssf=)LiNF^C)yjbl; z5{okmh?TR7NOiS1>Jg%POQ=w%jjz|Gbls$|YW&c3>E=?5t-t3Bg!jFDujbqlNUOnP z)!dPEX@b+z3XxU%aLx_2w)*+JreEca5>yxR$~)n>?+3s&DUQR|6Se^;C%VxyU#S8H z2)bP5$=(Z^iNXD=e_Hqu*<7_A4QsM665`y$qSJ+L)f9AEbJo^g#~*r_xnBuD;OQQ} z6(_ekzXWkz1Z-z~Ud+GmLYiR!qYykCM;*efT}wezGd!P9onr>NASAgr8ZYVJYU}5j zgO>GsS`XH}9x0hmRY_?qTPx0qOhE3*%V^3xtc~@a;&7oQIP+<=*d^pa8D#UhKpcx~ zqpS9~Ll)nk?1*m1-XBjNpa8*u=MEl%9v}F=>AD~6PnY8enuGH9Vr5m2__5ALTRRoq zp}ExzpdU+p+K^_lDZ3Srx-J3EXuJkzuBrB#iTr}M=GO&>>t!xHqd`5uksmrhli9~w zW?z5Mai$R!v3lonF>`y%h;UE1KJ=kLKpih=fgo|gnRW$t^Yf4&1{pRW5(tAq8J@#7 zodR4Fu2(ofbU84gj&kpK_g&rQ7rf?b>0_&}X)9G5EgwKNblznQ{c8A3qnUv zt;QDUF$tivgnGDTRUFx`mh7dZ_`dSdywzMjwY?7_bUYhvnaTy_E~3=`eB-DG637sq z@mA^iRdaQik&}KIRRP+!+cMnj>!`=jON`7MVz6BNWzT14Hhke)j$o!Chi~)90uI<( zm_KYrh^p)BuS>ZPk+2mh4rb!3N7$A`NEdy&G9X2D|2lS%Ah~!M-#_Y8ae`R*T7PEf z2-#L-=uf>DF}TSRkbmiO>3RC_*G}b8f9}p>WQc+qfkXiO^Zk<6?2^;8_^qgxt1YDE z!`AREk`umuT}|jnIsvx!vC8g~^6)H9*Vsy3zMLsK&nUu6@m7O3?k)+^;poQ6uf|0T&8Up|emC9KfI;2aGnz3m=*r=g5GsT|ocpuX;Z-Zp~S|Wy^6)@k8Cvv<{eV#}BcU zaWXi|4bn-;-!0w;JI1aqo&`tD=9|hIbQ5Q@|I$QiMydqFueB_~?t(H1>`loomsYBt zSc6(V1y8h}g_&+`zDs8*aGx>Hk6b-({>f;1HId_1hH1tT?9xLfyw+x%q^S2PQPO@S z3)hV4s!cXOEhJDC;^>Bh@zH0ExNYc(;#K>V_ZvjoQ<(rT#V_=7&1qS8r1-rYk<<(+ zn>EjMheYrKLP3Bu_@fSKrwwUaNb@EKmCu^)YNecPYEWQvP{yCT zVb5hn)REpXI2DC2m|ksWOI z>1$WBr~#{}8#kxi4#m*7M7)QW?Z8{!H6Eq>4!M9vt^Bs>-^-4APfS@A*Q`E}_PkW3 z-#lX~9A-YvE>@1#x6i9nIz7W#bm_1We0+T0HsBt+f5Aq>4uySn#RMHq(CrEI$e+84 zo92l!M@56GEExEvqyuhkq42Lpvq$zz{f--ADT-p_&EXw&6N{oxkM zu5xF{X)YrkG@JF^Nj0BmpuJ(h=QWNi`Be28U+ZyCOZg84&&ktVdLC|=`2F_88j5SU zV%r)z)Rm2vW2pOzdHIYv2ZU~jg1x=wgu6Xwzv4m{W}C|^x!SB;p5~V@0ezLh3Gzcf zBJg?(JV_Zcqhvl=Ad&ofvoB;evgTNaC;FA=+1+~@newHK#8)uYV<8fkHP^4v&uh*M z2@krDJP{~>BdOis=7Nlq+k33TP`5lh%47Iv>6vbU*8!fV3{bvcNls|*3I7#HH(v0@Jnp1>_mhO5)Ori<;sem}|YtJ0sf6vCeO()mkAiO*4Sj4;|l zT)%W3Sx@;!{1JS6h9w8+sz*%7~zh6)OUex&A9cf^)j>*Mme$udIIoU zn7Cs0?)}vAObwbX33~a>?0QWz+6_tuPkM4O7k*pq4*`ECJtZlo7K!p)m}`6O{A1_q<+rJv`B>*PPgk96}ssoikpZUdBm11EHC zTQ`8vs@9-4w9S15+ zJR(`s$7~$rM!E-na@aU8%DM*cI9j3Q5do%C3a{C!qhMJ|d6F`(nWp=w*KE^eRuq5N zeb#orlSaI$=1`e|xMx`B3pnj$o%z#(eEan2JV%K;%8V?%nfCWf#vVBOkzm^v<~^?P zp~@j;`ItF4rt4?{ny*W=js}u^lp|De9qr3 zG~L-k)(yl4N$%8`*+CVH^nmF+sV?qmLrcA9yZ#@V&iU3~bOu4#@OzFVNbAXIQwc-g z-cyv^VtQDy+=CDrce81iEbA@1`oFNjh=kKyYhjzIUUYDxPgKB8c0=R6)31g#UcxPs z0n)20ZSi3Bgsvl0?~V0kMlW1fveWA&c)mmz!-w3v<#}m6i#{HzX6LjkXP;N}^J#BS z8k6oKvX*VnZ=(<;F&MrN!zPA$q2S4pv`GQHgzHLNK<(vgablMU^WK(M}jgh^RMTEjD!x~!#l_V@UNyWq_as?TbG)M;Mv ze-0O)i0{q>-AXmQ^PQ8{ypyPH*pJ2dy=0FLv+DvvX_{asn7tfnwB_DiLUDEgcPyBe zhfLL#*JR9}*66fbgiMK|o4w?>)Qp<79k4_b%Nv_RD%(AQzH?x3Y>(HKuyS;^=Qt=H z>nIk&IK3Y$9}Uu{x^MR$V1X{&4IJ^j`UV4z@XKf?N378ZhdrH#e>g#hdEH!{WY~xD zc1gD2dI-Un_SSu3sPqSp)~TCSsX@&K_eE6sA+=<1h0_@5J64~Y$_&PC0H;WE=O)lZ zYjI6)*9e9)MC9$W&}&V;9*e+=t?IO<&4s;d3jr0Ac{7OL4Fz76##{(HkB1;r+1nPI zeRr9WzN`Cj1up_q`#@r%jyY6!LV?x+NBOV5xsG%R(ZWv8p-&E(C;jAXvE9fwV(`kM zFV}=XpUk7`#AO^whhBJ-N&`CS1ibdd?u0kR&pFbuVC`MHB>>NXREA5ZiUpdvh1l?n z@_tvZgKpcCAPBBaW=ngoZi~PNZ(EaRokmx^p<;n6h?aJEwq;1z#co+V} zF)w0?P>v(zvOWg75R()QnK5`6Ooo=NjdaNK`Wp;OF}t-vbsJIbcrVoR3}JGrNvH2a zDHpntuY5qa%S;A+Ir5#(F9~ICgww@$%@wO_sU?oJoWrw)cj0u-R&P@kje+XgKF!6a z{9ztF{XFCyRt8H_{FT=rxC^10zjTcWdd&chOz&m=(m{IX$sBi2xeNRed)Zt8dcE3} zVR|bzAX%VDVu|M+m9^9z3s_suzB-IUqZT;fA!HUK>9U1|2>b}a^QPK2Y&+ts>5kU4 zs5Rc+(mrfdpZ}nxTQ{zgz&@-MdNi-^$XL5ESLINe&8S&7s+G6mW1rkz zbEh-K%tlJibao~izjD7@ot-7lXs?#QRrqWD5sP|UV=ZwqjW@;LWDV!>klPeU@H3KQ zag3tQFu7dVpUKgGs8}euo|qEsN-<)TMVb_JAvaT2&hj5>Oe&+u9~OYud{8VJlfu@l zH)XCh-eRmZ)^`jB@xJJu66Nk5I*`F0A;=}jYH>9n-U!{jc?RtKYg z#=b#@K1De_ENoJiMIF8oUyP8Yc+hN|Q6*p6O9jZ#6;{6Xe7R^8HAwFQ8^BymM!Jh- z(j;arFFRFKGMj1>K=^4ZZYhEL-&T6gO7+M#`QzL*iLE+EX0_Ys&i&0jeH|jn zMLnarBP06RMcdFjzV!T;QK|vM;TGP6RbB3u@1m!=!E0)~>e<7RFXjfG=0%bQlG@_c zH1RYnLH@$&Za2(B?B*7Ly%y`Vx{hbSN+#J*erCevrMDxpel>y4-P>ll#`%SX&gnJD zYeCdp-FlxB*R_kYm2pxS5HAQBzr482n6u*I0x4KT(6dbhWQVW@x#9Sh<)177hch3?^s(P)SR?lx_*V-0pZ6QrpxoH;IycgBLr zDO@aJh^fYypJ*p_?&SzZF~^`Uy?qgSj?pG()2szc6*fgkM+VS5E<~!vWbs?QElh>k zx`f(0b;3U`k_j+@a}-YA#w)CKzU9`i4#LzPq;}RAf&&&LR()QFAu4Si%D>| zM6x9j7B3p1dqQc<=ye^&J*oz^#%Yy=k-FKS⁢g+{Gpd$0#mxDmU_8E^>M$sy)_* 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


n?jaq`s;99qmh~C}b-3#` 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

R|35iy9@g>^YmdSTC28Tyq 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 {2P1@>C>EKmB7v4+Ivh@ZymD*1uw-tXgv8 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 z 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 Ag8%>k literal 0 HcmV?d00001 From 258a04cfdae2ab1e5660ea8aaf20cbb9b474a390 Mon Sep 17 00:00:00 2001 From: lalilu <1248393538@qq.com> Date: Sun, 13 Apr 2025 17:27:06 +0800 Subject: [PATCH 211/213] =?UTF-8?q?refactor(lplayer):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20lib-decoder-flac=20=E4=BE=9D=E8=B5=96=E5=B9=B6=E5=8C=85?= =?UTF-8?q?=E5=90=AB=E5=88=B0=E9=A1=B9=E7=9B=AE=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 lib-decoder-flac 从外部 AAR 文件改为项目内部模块依赖 - 新增 lib-decoder-flac模块到 settings.gradle.kts 中 - 更新 lplayer/build.gradle.kts 中的依赖引用 --- lplayer/build.gradle.kts | 2 +- lplayer/lib-decoder-flac/build.gradle.kts | 2 ++ .../lib-decoder-flac-release.aar | Bin settings.gradle.kts | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 lplayer/lib-decoder-flac/build.gradle.kts rename lplayer/{libs => lib-decoder-flac}/lib-decoder-flac-release.aar (100%) diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index 25397545a..6196296a1 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -30,7 +30,7 @@ dependencies { implementation(project(":lmedia")) implementation(libs.startup.runtime) - api(files("libs/lib-decoder-flac-release.aar")) + api(project(":lplayer:lib-decoder-flac")) api(libs.bundles.media3) api("com.github.cy745:fpcalc:1.2") } \ No newline at end of file diff --git a/lplayer/lib-decoder-flac/build.gradle.kts b/lplayer/lib-decoder-flac/build.gradle.kts new file mode 100644 index 000000000..6d92cd807 --- /dev/null +++ b/lplayer/lib-decoder-flac/build.gradle.kts @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file("lib-decoder-flac-release.aar")) \ No newline at end of file diff --git a/lplayer/libs/lib-decoder-flac-release.aar b/lplayer/lib-decoder-flac/lib-decoder-flac-release.aar similarity index 100% rename from lplayer/libs/lib-decoder-flac-release.aar rename to lplayer/lib-decoder-flac/lib-decoder-flac-release.aar diff --git a/settings.gradle.kts b/settings.gradle.kts index 7bf8016b4..cb8e22e6e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include(":crash") include(":lmedia") include(":lplayer") +include(":lplayer:lib-decoder-flac") include(":lplaylist") include(":lhistory") From bf73e8722067ef0ae571bc6685f6934bcc1fc780 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Wed, 16 Apr 2025 18:09:05 +0800 Subject: [PATCH 212/213] =?UTF-8?q?feat(player):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=8E=A7=E5=88=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新播放列表时增加是否开始播放的控制 - 添加暂停当前歌曲并记住位置的功能 - 修复播放历史记录的逻辑- 优化媒体元数据更新和时长获取 --- .../main/java/com/lalilu/lplayer/MPlayer.kt | 78 +++++++++++-------- .../com/lalilu/lplayer/action/MediaControl.kt | 10 ++- .../com/lalilu/lplayer/action/PlayerAction.kt | 3 +- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt index 4857fa774..db59cd63e 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -33,12 +33,13 @@ import org.koin.dsl.module import kotlin.coroutines.CoroutineContext @OptIn(UnstableApi::class) -object MPlayer : CoroutineScope { +object MPlayer : CoroutineScope, Player.Listener { override val coroutineContext: CoroutineContext = Dispatchers.IO private val sessionToken by lazy { SessionToken(Utils.getApp(), ComponentName(Utils.getApp(), MService::class.java)) } + private var browserInstance: MediaBrowser? = null private val browserFuture by lazy { MediaBrowser .Builder(Utils.getApp(), sessionToken) @@ -48,6 +49,7 @@ object MPlayer : CoroutineScope { val module = module { } + var pauseWhenCompletion: Boolean by mutableStateOf(false) var isPlaying: Boolean by mutableStateOf(false) private set var currentMediaItem by mutableStateOf(null) @@ -77,7 +79,8 @@ object MPlayer : CoroutineScope { internal fun init() { launch(Dispatchers.Main) { val browser = browserFuture.await() - browser.addListener(getListener(browser)) + browserInstance = browser + browser.addListener(this@MPlayer) val items = getHistoryItems() if (items.isEmpty()) { @@ -141,6 +144,7 @@ object MPlayer : CoroutineScope { browser.play() } else { browser.seekTo(index, 0) + browser.play() } } } @@ -151,7 +155,7 @@ object MPlayer : CoroutineScope { is PlayerAction.CustomAction -> {} is PlayerAction.PauseWhenCompletion -> { -// if (action.cancel) cancelPauseWhenCompletion() else pauseWhenCompletion() + pauseWhenCompletion = !action.cancel } is PlayerAction.SetPlayMode -> { @@ -177,50 +181,56 @@ object MPlayer : CoroutineScope { val items = LMedia.mapItems(action.mediaIds) browser.setMediaItems(items, index, 0) + if (action.start) { + browser.play() + } } } } - private fun getListener(browser: MediaBrowser) = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - this@MPlayer.isPlaying = isPlaying - } + override fun onIsPlayingChanged(isPlaying: Boolean) { + this@MPlayer.isPlaying = isPlaying + } - @OptIn(UnstableApi::class) - override fun onPlaybackStateChanged(playbackState: Int) { + @OptIn(UnstableApi::class) + override fun onPlaybackStateChanged(playbackState: Int) { - } + } - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - currentMediaItem = mediaItem - updateItems() - } + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + currentMediaItem = mediaItem + updateItems() - override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { - currentMediaItem = browser.currentMediaItem - currentMediaMetadata = mediaMetadata - currentDuration = mediaMetadata.durationMs ?: browser.duration - // TODO 此处获取到的duration仍然可能是上一首歌曲的时长 + if (pauseWhenCompletion) { + browserInstance?.pause() + pauseWhenCompletion = false } + } - override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) { - currentPlaylistMetadata = mediaMetadata - } + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + currentMediaItem = browserInstance?.currentMediaItem + currentMediaMetadata = mediaMetadata + currentDuration = mediaMetadata.durationMs ?: browserInstance?.duration ?: 0L + // TODO 此处获取到的duration仍然可能是上一首歌曲的时长 + } - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - updateItems(timeline) - } + override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) { + currentPlaylistMetadata = mediaMetadata + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + updateItems(timeline) + } - fun updateItems( - timeline: Timeline = browser.currentTimeline, - currentIndex: Int = browser.currentMediaItemIndex - ) { - val items = timeline.toMediaItems() - currentTimelineItems = items.drop(currentIndex) + items.take(currentIndex) + fun updateItems( + timeline: Timeline? = browserInstance?.currentTimeline, + currentIndex: Int = browserInstance?.currentMediaItemIndex ?: 0 + ) { + val items = timeline?.toMediaItems() ?: emptyList() + currentTimelineItems = items.drop(currentIndex) + items.take(currentIndex) - val ids = currentTimelineItems.map { it.mediaId } - saveHistoryIds(mediaIds = ids) - } + val ids = currentTimelineItems.map { it.mediaId } + saveHistoryIds(mediaIds = ids) } } diff --git a/lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt b/lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt index b80e3c8c0..3645b1281 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt @@ -26,7 +26,13 @@ object MediaControl { /** * 替换播放列表,并播放目标歌曲 */ - fun playWithList(mediaIds: List, mediaId: String) { - PlayerAction.UpdateList(mediaIds, mediaId).action() + fun playWithList( + mediaIds: List, + mediaId: String, + start: Boolean = true + ) { + PlayerAction + .UpdateList(mediaIds, mediaId, start) + .action() } } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt index 298e041e0..553ff8acc 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt @@ -19,7 +19,8 @@ sealed class PlayerAction : Action { data class AddToNext(val mediaId: String) : PlayerAction() data class UpdateList( val mediaIds: List, - val mediaId: String? = null + val mediaId: String? = null, + val start: Boolean = false ) : PlayerAction() sealed class CustomAction(val name: String) : PlayerAction() From 307e2383b33ba9851c0c06ea3971d367a95d53f3 Mon Sep 17 00:00:00 2001 From: cy745 <1248393538@qq.com> Date: Wed, 16 Apr 2025 18:34:24 +0800 Subject: [PATCH 213/213] =?UTF-8?q?refactor(lmusic):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=A8=A1=E7=B3=8A=E8=83=8C=E6=99=AF=E7=BB=98=E5=88=B6=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整了绘制顺序,先绘制原始内容,再进行模糊处理 - 修改了注释,更准确地描述了绘制逻辑 - 优化了代码结构,提高了可读性 --- .../lalilu/lmusic/compose/screen/playing/BlurBackground.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt index 55c8da22b..f7592586f 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt @@ -55,12 +55,13 @@ fun BlurBackground( modifier = Modifier .fillMaxSize() .drawWithContent { + drawContent() + val progress = blurProgress() val radius = (progress * StackBlurUtils.MAX_RADIUS).toInt() - // 若无降采样图片或当前Radius为0则直接绘制原图 + // 若无降采样图片或当前Radius为0则只绘制原图 if (samplingBitmap.value == null || radius <= 0) { - this.drawContent() return@drawWithContent }