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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67deeac79..84ed49a20 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 @@ -7,15 +8,17 @@ import java.util.TimeZone plugins { id("com.android.application") kotlin("android") + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) id("com.google.devtools.ksp") - alias(libs.plugins.flyjingfish.aop) + id("android.aop") } 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()) } @@ -39,12 +42,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" @@ -58,7 +61,6 @@ android { buildFeatures { compose = true - viewBinding = true buildConfig = true } @@ -95,7 +97,7 @@ android { isMinifyEnabled = true isShrinkResources = true - versionNameSuffix = "-ALPHA_${releaseTime()}" + versionNameSuffix = "-Aplha-${releaseTime()}" applicationIdSuffix = ".alpha" proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -142,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") } @@ -154,29 +159,36 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.compose.compiler.get().version.toString() - } lint { disable += "Instantiatable" abortOnError = false } } +composeCompiler { + composeCompiler.featureFlags.add(ComposeFeatureFlag.StrongSkipping) + composeCompiler.featureFlags.add(ComposeFeatureFlag.PausableComposition) +} + dependencies { - implementation(project(":ui")) implementation(project(":crash")) implementation(project(":component")) implementation(project(":lplaylist")) implementation(project(":lhistory")) implementation(project(":lartist")) implementation(project(":lalbum")) - implementation(project(":ldictionary")) + implementation(project(":lfolder")) + ksp(libs.koin.compiler) implementation(libs.room.ktx) implementation(libs.room.runtime) + implementation(libs.kotlin.serialization) + 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") @@ -197,11 +209,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 @@ -211,10 +218,18 @@ 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") + implementation("io.github.theapache64:rebugger:1.0.0-rc03") 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/proguard-rules.pro b/app/proguard-rules.pro index 5bdf4c652..a7881a981 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -45,7 +45,47 @@ -dontwarn com.squareup.picasso.Picasso -dontwarn com.squareup.picasso.RequestCreator +# 针对KRouter,保留所需的类的构造函数 +-keepclassmembers @com.zhangke.krouter.annotation.Destination public class * { public (*); } + # 墨 · 状态栏歌词 -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 + + +-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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4f321e956..41ca92a6c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ android:glEsVersion="0x00030000" android:required="true" /> + + @@ -36,37 +38,20 @@ android:supportsRtl="true" android:theme="@style/Theme.Music" android:usesCleartextTraffic="true" - tools:ignore="AllowBackup,UnusedAttribute"> + tools:ignore="AllowBackup,UnusedAttribute" + tools:overrideLibrary="com.nomanr.composables"> - - - - - - - - - - - - - - + android:launchMode="singleTop" + android:resizeableActivity="true" + android:windowSoftInputMode="adjustNothing"> @@ -95,5 +80,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 6454458c3..c50e1ee88 100644 --- a/app/src/main/java/com/lalilu/lmusic/AppModule.kt +++ b/app/src/main/java/com/lalilu/lmusic/AppModule.kt @@ -1,76 +1,87 @@ 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 coil.EventListener -import coil.ImageLoader -import coil.request.ErrorResult -import coil.request.ImageRequest +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.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 -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 import com.lalilu.lmusic.datastore.SettingsSp 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.keyer.PlayableKeyer -import com.lalilu.lmusic.utils.coil.keyer.SongCoverKeyer -import com.lalilu.lmusic.utils.coil.mapper.LSongMapper +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.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 -import com.lalilu.lplayer.notification.Notifier -import com.lalilu.lplaylist.entity.LPlaylistFastEncoder -import io.fastkv.FastKV +import kotlinx.serialization.json.Json 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.module.dsl.singleOf +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single import org.koin.dsl.module import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import java.net.URLDecoder -val AppModule = module { - single { androidApplication() as ViewModelStoreOwner } - single { GlobalNavigatorImpl } +@Module +@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 { - FastKV.Builder(androidApplication(), "LMusic") - .encoder( - arrayOf( - LSongFastEncoder, - LPlaylistFastEncoder - ) - ) +@Single(createdAtStart = true) +fun provideImageLoaderFactory( + context: Application, + client: OkHttpClient, +): SingletonImageLoader.Factory { + return SingletonImageLoader.Factory { + ImageLoader.Builder(context) + .components { + add(OkHttpNetworkFetcherFactory(client)) + add(LSongCoverKeyer()) + add(LAlbumCoverKeyer()) + add(MediaItemKeyer()) + add(LSongFetcher.SongFactory()) + add(LAlbumFetcher.AlbumFactory()) + add(MediaItemFetcher.MediaItemFetcherFactory()) + } + .transitionFactory(CrossfadeTransitionFactory()) +// .logger(DebugLogger()) .build() } +} +val AppModule = module { + single { androidApplication() as ViewModelStoreOwner } single { SettingsSp(androidApplication()) } - single { LastPlayedSp(androidApplication()) } single { TempSp(androidApplication()) } single { EQHelper(androidApplication()) } single { @@ -82,48 +93,10 @@ val AppModule = module { false ) } - single { - ImageLoader.Builder(androidApplication()) - .callFactory(get()) - .components { - add(SongCoverKeyer()) - add(PlayableKeyer()) - add(LSongMapper()) - add(LSongFetcher.SongFactory()) - 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) - } - }) - .build() - } } val ViewModelModule = module { - viewModelOf(::PlayingViewModel) - viewModel { get() } - viewModelOf(::SearchViewModel) - viewModelOf(::AlbumsViewModel) - viewModelOf(::ArtistsViewModel) - viewModelOf(::HistoryViewModel) viewModelOf(::SearchLyricViewModel) - viewModelOf(::LibraryViewModel) -} - -val RuntimeModule = module { - singleOf(::LMusicNotifier) - single { get() } - singleOf(::LMusicServiceConnector) - singleOf(::CoverRepository) - singleOf(::LyricRepository) } val ApiModule = module { @@ -137,44 +110,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 = LSong::durationMs::get, - 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/GlobalNavigatorImpl.kt b/app/src/main/java/com/lalilu/lmusic/GlobalNavigatorImpl.kt deleted file mode 100644 index e73624b34..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.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 -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: SheetNavigator?, - ) { - val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.show(SongDetailScreen(mediaId = mediaId)) - } - - override fun showSongs( - mediaIds: List, - title: String?, - navigator: SheetNavigator?, - ) { - val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.show( - SongsScreen( - title = title, - mediaIds = mediaIds - ) - ) - } - - override fun navigateTo( - screen: Screen, - singleTop: Boolean, - navigator: SheetNavigator? - ) { - val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.show(screen = screen) - } - - override fun goBack(navigator: SheetNavigator?) { - val nav = navigator ?: NavigationWrapper.navigator ?: return - nav.back() - } -} \ 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 88df73f0c..2a0893fbb 100644 --- a/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt +++ b/app/src/main/java/com/lalilu/lmusic/LMusicApp.kt @@ -3,32 +3,55 @@ package com.lalilu.lmusic import android.app.Application import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner -import coil.ImageLoader -import coil.ImageLoaderFactory +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.lfolder.FolderModule 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.LPlayer +import com.lalilu.lplayer.MPlayer import com.lalilu.lplaylist.PlaylistModule -import org.koin.android.ext.android.inject +import com.zhangke.krouter.KRouter +import com.zhangke.krouter.generated.KRouterInjectMap import org.koin.android.ext.koin.androidContext -import org.koin.core.context.startKoin +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 -class LMusicApp : Application(), ImageLoaderFactory, FilterProvider, ViewModelStoreOwner { + +@Suppress("OPT_IN_USAGE") +class LMusicApp : Application(), ViewModelStoreOwner, KoinStartup { override val viewModelStore: ViewModelStore = ViewModelStore() - private val imageLoader: ImageLoader by inject() - private val filterGroup: FilterGroup by inject() - override fun newImageLoader(): ImageLoader = imageLoader - override fun newFilterGroup(): FilterGroup = filterGroup + @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, + ) + + SingletonImageLoader + .setSafe(KoinJavaComponent.get(SingletonImageLoader.Factory::class.java)) + } + + init { + KRouter.init(KRouterInjectMap::getMap) + } override fun onCreate() { super.onCreate() @@ -41,23 +64,5 @@ class LMusicApp : Application(), ImageLoaderFactory, FilterProvider, ViewModelSt .setDir(File("${cacheDir}/log")) ignoreSSLVerification() - startKoin { - androidContext(this@LMusicApp) - modules( - AppModule, - ApiModule, - ViewModelModule, - RuntimeModule, - FilterModule, - PlaylistModule, - ComponentModule, - HistoryModule, - ArtistModule, - AlbumModule, - DictionaryModule, - LPlayer.module, - LMedia.module - ) - } } } \ 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 0866c8fef..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,21 +12,19 @@ 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.service.LMusicServiceConnector +import com.lalilu.lmusic.utils.dynamicUpdateStatusBarColor +import com.lalilu.lmusic.utils.setToMaxFreshRate 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) @@ -42,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) { @@ -69,16 +50,15 @@ class MainActivity : ComponentActivity() { ) } - LMedia.initialize(this) + // 注册返回键事件回调 + onBackPressedDispatcher.addCallback { this@MainActivity.moveTaskToBack(false) } - lifecycle.addObserver(connector) SystemUiUtil.immerseNavigationBar(this) SystemUiUtil.immersiveCutout(window) - // 注册返回键事件回调 - onBackPressedDispatcher.addCallback { this@MainActivity.moveTaskToBack(false) } - setContent { App.Content(activity = this) } + setToMaxFreshRate() + dynamicUpdateStatusBarColor() volumeControlStream = AudioManager.STREAM_MUSIC } 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/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/App.kt b/app/src/main/java/com/lalilu/lmusic/compose/App.kt index 3b820ad5d..d1ba2dba2 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/App.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/App.kt @@ -3,22 +3,31 @@ 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 import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier -import com.lalilu.lmusic.LMusicTheme +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 @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) object App { + @OptIn(ExperimentalVoyagerApi::class) @Composable fun Content(activity: Activity) { Environment(activity = activity) { Box(modifier = Modifier.fillMaxSize()) { - with(LayoutWrapper) { Content() } + ProvideNavigatorLifecycleKMPSupport { + LayoutWrapperContent() + } } } } @@ -26,10 +35,15 @@ object App { @Composable fun Environment(activity: Activity, content: @Composable () -> Unit) { LMusicTheme { - CompositionLocalProvider( - LocalWindowSize provides calculateWindowSizeClass(activity = activity), - content = content - ) + MaterialTheme { + LumoTheme { + CompositionLocalProvider( + LocalWindowSize provides calculateWindowSizeClass(activity = activity), + LocalDataSaver provides koinInject(), + 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 8c48d3136..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/DrawerWrapper.kt +++ /dev/null @@ -1,184 +0,0 @@ -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.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 - -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 }, - 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 - ) - - isPad() -> boxMeasurePolicy(targetIndex = listOf(0, 2)) - - // 普通手机端则Fixed,避免宽高变化影响界面 - else -> fixedMeasurePolicy( - isLandscape = isLandscape(), - targetIndex = listOf(0, 2) - ) - } - } - - 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 - - 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 - (spacer?.width ?: 0) - (main?.width ?: 0) - val second = measurables.getOrNull(2) - ?.measure(constraints.copy(maxWidth = lastXSpace)) - - 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) + (spacer?.width ?: 0), - stop = 0, - fraction = animateProgress - ) - - main?.place(x = mainX, y = 0, zIndex = 20f) - spacer?.place(x = spaceX, y = 0, zIndex = 10f) - second?.place(x = secondX, y = 0, zIndex = 0f) - } - } - - 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) } - - layout( - width = cConstraint.maxWidth, - height = cConstraint.maxHeight - ) { - placeable.onEach { it.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 deleted file mode 100644 index 73ebf08e0..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapper.kt +++ /dev/null @@ -1,47 +0,0 @@ -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) { - 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 } - ) - } - ) - - if (isLandscape) { - ShowScreen() - } - - DialogWrapper.Content() - - with(DynamicTipsHost) { Content() } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt new file mode 100644 index 000000000..d6b4d93c9 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/LayoutWrapperContent.kt @@ -0,0 +1,199 @@ +package com.lalilu.lmusic.compose + +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.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 +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.CompositionLocalProvider +import androidx.compose.runtime.movableContentOf +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.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.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.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 + +@Composable +fun BoxScope.LayoutWrapperContent() { + val windowClass = LocalWindowSize.current + val padding = remember { mutableStateOf(PaddingValues(bottom = 56.dp)) } + + val navHostContent = remember { + movableContentOf<(Navigator) -> Unit> { content -> + CompositionLocalProvider(value = LocalSmartBarPadding provides padding) { + HostNavigator(HomeScreen) { navigator -> + content(navigator) + + CustomTransition( + modifier = Modifier.fillMaxSize(), + navigator = navigator, + ) + } + } + } + } + + val navigationSmartBar = remember { + movableContentOf { modifier -> + NavigationSmartBar(modifier = modifier) + } + } + + if (windowClass.widthSizeClass != WindowWidthSizeClass.Compact) { + LayoutForPad( + navHostContent = navHostContent, + navigationSmartBar = navigationSmartBar + ) + } else { + LayoutForMobile( + navHostContent = navHostContent, + navigationSmartBar = navigationSmartBar + ) + } + + DialogWrapper.Content() + + with(DynamicTipsHost) { Content() } +} + +@Composable +private fun LayoutForPad( + modifier: Modifier = Modifier, + 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 = smartBarHeight + navigatorBar.calculateBottomPadding(), + 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(smartBarHeight + navigatorBar.calculateBottomPadding()) + .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 = true, + 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 84821d673..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/NavigationWrapper.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.lalilu.lmusic.compose - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.SpringSpec -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.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.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.lmusic.compose.component.navigate.NavigationSheetContent -import com.lalilu.lmusic.compose.new_screen.HomeScreen - - -@OptIn(ExperimentalMaterialApi::class) -object NavigationWrapper { - var navigator: SheetNavigator? by mutableStateOf(null) - private set - - // 使用remember避免在该变量内部的state引用触发重组,使其转换为普通的变量 - private val isSheetVisible: Boolean - @Composable - get() = remember { navigator?.isVisible ?: false } - - @OptIn(InternalVoyagerApi::class) - @Composable - fun Content( - modifier: Modifier = Modifier, - forPad: () -> Boolean = { false } - ) { - val emptyScreen = remember { - HiddenBottomSheetScreen - } - val animationSpec = remember { - SpringSpec( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = 1000f - ) - } - - // 共用Navigator避免切换时导致导航栈丢失 - Navigator( - HomeScreen, - onBackPressed = null, - 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 - } - ) - } - ) - } else { - BottomSheetNavigatorLayout( - modifier = modifier.fillMaxSize(), - navigator = navigator, - defaultIsVisible = isSheetVisible, - scrimColor = Color.Black.copy(alpha = 0.5f), - sheetBackgroundColor = MaterialTheme.colors.background, - animationSpec = animationSpec, - sheetContent = { sheetNavigator -> - NavigationWrapper.navigator = sheetNavigator - NavigationSheetContent( - modifier = modifier, - transitionKeyPrefix = "bottomSheet", - sheetNavigator = sheetNavigator - ) - }, - content = { } - ) - } - } - } -} \ No newline at end of file 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/CustomTransition.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt deleted file mode 100644 index b131da641..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/CustomTransition.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.lalilu.lmusic.compose.component - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibilityScope -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.runtime.Composable -import androidx.compose.ui.Modifier -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.Navigator - -@Composable -fun CustomTransition( - modifier: Modifier = Modifier, - navigator: Navigator, - keyPrefix: String = "", - getScreenFrom: (Navigator) -> Screen = { navigator.lastItem }, - content: @Composable (AnimatedVisibilityScope.(Screen) -> Unit) = { it.Content() } -) { - AnimatedContent( - modifier = modifier, - 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) - } - } -} \ No newline at end of file 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/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/component/base/InputBar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt deleted file mode 100644 index 39ffa0daf..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/base/InputBar.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.lalilu.lmusic.compose.component.base - -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 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 - -@Composable -fun InputBar( - modifier: Modifier = Modifier, - hint: String = "", - defaultValue: String = "", - 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) - - 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) - } - } - } - } - ) { - if (inputer.text.toString() != value.value) { - inputer.setText(value.value) - } - } -} \ 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 ddb4e417c..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/base/SearchInputBar.kt +++ /dev/null @@ -1,36 +0,0 @@ -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( - modifier: Modifier = Modifier, - hint: String = "", - value: MutableState, - 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 - ) - } -} \ No newline at end of file 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/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 = "", 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/component/card/SongInformationCard.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/card/SongInformationCard.kt deleted file mode 100644 index ae764e7d4..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/card/SongInformationCard.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.lalilu.lmusic.compose.component.card - -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 -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.blankj.utilcode.util.ConvertUtils -import com.lalilu.lmedia.entity.LSong -import java.text.SimpleDateFormat - -@Composable -fun SongInformationCard( - modifier: Modifier = Modifier, - song: LSong -) { - Surface( - modifier = modifier - .padding(horizontal = 20.dp) - .width(intrinsicSize = IntrinsicSize.Min), - shape = RoundedCornerShape(20.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(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 - ) - } - } - - 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.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 - ) - } - } - - if (song.metadata.disc.isNotBlank() || song.metadata.track.isNotBlank()) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.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.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.fileInfo.pathStr?.takeIf { it.isNotBlank() }?.let { path -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "文件位置", - style = MaterialTheme.typography.subtitle2 - ) - Text( - text = path, - style = MaterialTheme.typography.caption - ) - } - } - } - } -} \ 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 deleted file mode 100644 index c591bc407..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationBar.kt +++ /dev/null @@ -1,297 +0,0 @@ -package com.lalilu.lmusic.compose.component.navigate - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -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.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.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.material.contentColorFor -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.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.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 - -@Composable -fun NavigationBar( - modifier: Modifier = Modifier, - 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 } - } - val previousInfo by remember { derivedStateOf { previousScreen?.getScreenInfo() } } - val previousTitle by remember { - derivedStateOf { previousInfo?.title ?: R.string.bottom_sheet_navigate_back } - } - val dynamicScreen by remember { derivedStateOf { screen as? DynamicScreen } } - val screenActions = dynamicScreen?.registerActions() ?: emptyList() - - AnimatedContent( - modifier = modifier.fillMaxWidth(), - targetState = isCurrentTabScreen, - label = "NavigateBarTransform" - ) { tabScreenNow -> - if (tabScreenNow) { - NavigateTabBar( - tabScreens = tabScreens, - currentScreen = { screen }, - onSelectTab = { navigator.show(it) } - ) - } else { - NavigateCommonBar( - previousTitle = { previousTitle }, - screenActions = { screenActions }, - navigator = navigator - ) - } - } -} - -@Composable -fun NavigateTabBar( - modifier: Modifier = Modifier, - currentScreen: () -> Screen?, - tabScreens: () -> List, - onSelectTab: (TabScreen) -> Unit = {} -) { - Row( - modifier = modifier - .clickable(enabled = false) {} - .height(52.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - tabScreens().forEach { - NavigateItem( - modifier = Modifier.weight(1f), - titleRes = { it.getScreenInfo().title }, - iconRes = { it.getScreenInfo().icon ?: R.drawable.ic_close_line }, - isSelected = { currentScreen() === it }, - onClick = { onSelectTab(it) } - ) - } - } -} - -@Composable -fun NavigateCommonBar( - modifier: Modifier = Modifier, - previousTitle: () -> Int, - screenActions: () -> List, - navigator: SheetNavigator -) { - val itemFitImePadding = remember { mutableStateOf(false) } - - Row( - modifier = modifier - .clickable(enabled = false) {} - .run { if (itemFitImePadding.value) this.imePadding() else this } - .height(52.dp) - .fillMaxWidth(), - 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() } - ) { - Image( - painter = painterResource(id = R.drawable.ic_arrow_left_s_line), - contentDescription = "backButtonIcon", - colorFilter = ColorFilter.tint(color = contentColor) - ) - AnimatedContent(targetState = previousTitle(), label = "") { - Text( - text = stringResource(id = it), - fontSize = 14.sp - ) - } - } - - AnimatedContent( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - targetState = screenActions(), - label = "ExtraActions" - ) { actions -> - LazyRow( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.End - ) { - items(items = actions) { - if (it is ScreenAction.ComposeAction) { - it.content.invoke() - return@items - } - - if (it is ScreenAction.StaticAction) { - if (it.fitImePadding) { - LaunchedEffect(Unit) { - itemFitImePadding.value = true - } - } - - 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 - ) - } - } - } - - 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 - ) - } - } - } - } - } - } -} - -@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() - ) - } - } - } - } -} 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 b221a5beb..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSheetContent.kt +++ /dev/null @@ -1,87 +0,0 @@ -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.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.CustomScreen -import com.lalilu.component.base.LocalPaddingValue -import com.lalilu.component.navigation.SheetNavigator -import com.lalilu.lmusic.compose.TabWrapper -import com.lalilu.lmusic.compose.component.CustomTransition - -@Composable -fun ImmerseStatusBar( - enable: () -> Boolean = { true }, - isExpended: () -> Boolean = { false }, -) { - val result by remember { derivedStateOf { isExpended() && enable() } } - val systemUiController = rememberSystemUiController() - val isDarkModeNow = isSystemInDarkTheme() - - LaunchedEffect(result, isDarkModeNow) { - systemUiController.setStatusBarColor( - color = Color.Transparent, - darkIcons = result && !isDarkModeNow - ) - } -} - -@Composable -fun NavigationSheetContent( - modifier: Modifier, - transitionKeyPrefix: String, - sheetNavigator: SheetNavigator, - getScreenFrom: (Navigator) -> Screen = { sheetNavigator.getNavigator().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() } } - - ImmerseStatusBar( - enable = { customScreenInfo?.immerseStatusBar != false }, - isExpended = { sheetNavigator.isVisible } - ) - - Box(modifier = Modifier.fillMaxSize()) { - CustomTransition( - modifier = Modifier.fillMaxSize(), - keyPrefix = transitionKeyPrefix, - navigator = sheetNavigator.getNavigator(), - getScreenFrom = getScreenFrom, - ) { - CompositionLocalProvider(LocalPaddingValue provides currentPaddingValue) { - it.Content() - } - } - NavigationSmartBar( - modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - measureHeightState = currentPaddingValue, - currentScreen = { currentScreen } - ) { modifier -> - NavigationBar( - modifier = modifier.align(Alignment.BottomCenter), - tabScreens = { TabWrapper.tabScreen }, - currentScreen = { currentScreen }, - navigator = sheetNavigator - ) - } - } -} \ 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 04549eaca..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/component/navigate/NavigationSmartBar.kt +++ /dev/null @@ -1,141 +0,0 @@ -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 -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 -import androidx.compose.foundation.layout.fillMaxSize -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.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.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, ExperimentalLayoutApi::class) -@Composable -fun NavigationSmartBar( - modifier: Modifier = Modifier, - measureHeightState: MutableState, - currentScreen: () -> Screen?, - 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) - .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/component/playing/LyricViewToolbar.kt b/app/src/main/java/com/lalilu/lmusic/compose/component/playing/LyricViewToolbar.kt index 235e11245..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 @@ -1,18 +1,18 @@ 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.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 @@ -23,19 +23,47 @@ 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.LayoutDirection 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.SettingSmallProgressSeekBar 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 +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( + 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 @@ -43,16 +71,136 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T .navigationBarsPadding(), shape = RoundedCornerShape(15.dp) ) { - Column(modifier = Modifier) { + Column( + modifier = Modifier + .fillMaxHeight(0.6f) + .verticalScroll(state = rememberScrollState()) + ) { 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 + } + ) + settings.saveData() + }, 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, + SettingSmallProgressSeekBar( + value = { settings.value.mainFontSize.value }, + onValueUpdate = { + settings.value = settings.value.copy(mainFontSize = it.sp) + }, + onFinishedUpdate = { settings.saveData() }, title = "歌词文字大小", - valueRange = 14..36 + valueRange = 14..64 + ) + SettingSmallProgressSeekBar( + value = { settings.value.mainLineHeight.value }, + onValueUpdate = { + settings.value = settings.value.copy(mainLineHeight = it.sp) + }, + onFinishedUpdate = { settings.saveData() }, + title = "歌词行高大小", + valueRange = 14..72 + ) + SettingSmallProgressSeekBar( + value = { settings.value.mainFontWeight.toFloat() }, + onValueUpdate = { + settings.value = settings.value.copy(mainFontWeight = it.roundToInt()) + }, + onFinishedUpdate = { settings.saveData() }, + title = "歌词字重", + valueRange = 50..900 + ) + SettingSmallProgressSeekBar( + value = { settings.value.translationFontSize.value }, + onValueUpdate = { + settings.value = settings.value.copy(translationFontSize = it.sp) + }, + onFinishedUpdate = { settings.saveData() }, + title = "翻译文字大小", + valueRange = 14..64 + ) + SettingSmallProgressSeekBar( + value = { settings.value.translationLineHeight.value }, + onValueUpdate = { + settings.value = settings.value.copy(translationLineHeight = it.sp) + }, + onFinishedUpdate = { settings.saveData() }, + title = "翻译行高大小", + valueRange = 14..72 + ) + SettingSmallProgressSeekBar( + value = { settings.value.translationFontWeight.toFloat() }, + onValueUpdate = { + settings.value = + settings.value.copy(translationFontWeight = it.roundToInt()) + }, + onFinishedUpdate = { settings.saveData() }, + title = "翻译字重", + valueRange = 50..900 + ) + SettingSmallProgressSeekBar( + value = { settings.value.timeOffset.toFloat() }, + onValueUpdate = { + settings.value = settings.value.copy(timeOffset = it.roundToLong()) + }, + onFinishedUpdate = { settings.saveData() }, + title = "歌词偏移时间(ms)", + valueRange = 0..500 + ) + SettingSmallProgressSeekBar( + value = { settings.value.gapSize.value }, + onValueUpdate = { + settings.value = settings.value.copy(gapSize = it.dp) + }, + onFinishedUpdate = { settings.saveData() }, + title = "歌词翻译间距", + valueRange = 0..50 + ) + + SettingSmallProgressSeekBar( + 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 + ) + ) + }, + onFinishedUpdate = { settings.saveData() }, + title = "横向边距", + valueRange = 0..50 + ) + SettingSwitcher( + title = "歌词模糊效果", + subTitle = "为歌词添加一点模糊效果", + state = { settings.value.blurEffectEnable }, + onStateUpdate = { + settings.value = settings.value.copy(blurEffectEnable = it) + settings.saveData() + } ) SettingSwitcher( title = "歌词页展开时隐藏其他组件", @@ -60,9 +208,9 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T state = settingsSp.autoHideSeekbar, ) SettingFilePicker( - state = settingsSp.lyricTypefacePath, + state = lyricTypefacePath, title = "自定义字体", - subTitle = "请选择TTF格式的字体文件", + subTitle = "请选择TTF格式的字体文件(存在bug,待修复)", mimeType = "font/ttf" ) } @@ -70,11 +218,8 @@ private val LyricViewActionDialog = DialogItem.Dynamic(backgroundColor = Color.T } @Composable -fun LyricViewToolbar( - settingsSp: SettingsSp = koinInject() -) { - var isDrawTranslation by settingsSp.isDrawTranslation - var isEnableBlurEffect by settingsSp.isEnableBlurEffect +fun LyricViewToolbar() { + val settings: DataSaverMutableState = koinInject(named("LyricSettings")) Row( modifier = Modifier @@ -83,13 +228,13 @@ 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 = "" + val iconAlpha = animateFloatAsState( + targetValue = if (settings.value.translationVisible) 1f else 0.5f, + label = "" ) + SleepTimerSmallEntry() + IconButton(onClick = { DialogWrapper.push(LyricViewActionDialog) }) { Icon( painter = painterResource(id = R.drawable.ic_text), @@ -98,23 +243,13 @@ 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 }) { + 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/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/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/new_screen/HomeScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt deleted file mode 100644 index c0b6389c9..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/HomeScreen.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.lalilu.lmusic.compose.new_screen - -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.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -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.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.viewmodel.LibraryViewModel - -object HomeScreen : DynamicScreen(), TabScreen { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_home, - icon = R.drawable.ic_loader_line - ) - - @Composable - override fun Content() { - val vm: LibraryViewModel = singleViewModel() - - LaunchedEffect(Unit) { - vm.checkOrUpdateToday() - } - - LLazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = WindowInsets.statusBars.asPaddingValues() - ) { - item { - DailyRecommend() - } - - item { - LatestPanel() - } - - item { - HistoryPanel() - } - - item { - EntryPanel() - } - } - } -} \ No newline at end of file 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..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 @@ -21,43 +21,55 @@ 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 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 import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import coil.request.ImageRequest +import cafe.adriel.voyager.core.screen.Screen +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 -import com.lalilu.component.base.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.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 -) : DynamicScreen() { +) : Screen, ScreenInfoFactory, ScreenBarFactory { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.preference_lyric_settings - ) + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.preference_lyric_settings) } + ) + } @Composable override fun Content() { val state = SearchLyricPresenter.presentState() + val visible = remember { mutableStateOf(true) } LaunchedEffect(Unit) { if (state.mediaId != mediaId) { @@ -68,7 +80,11 @@ data class SearchLyricScreen( state.onAction(SearchLyricAction.SearchFor(keywords)) } - RegisterExtraContent { + RegisterContent( + isVisible = { visible.value }, + onDismiss = { visible.value = false }, + 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 deleted file mode 100644 index 6a4d72a83..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SearchScreen.kt +++ /dev/null @@ -1,249 +0,0 @@ -package com.lalilu.lmusic.compose.new_screen - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -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.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.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.blankj.utilcode.util.KeyboardUtils -import com.lalilu.R -import com.lalilu.component.Songs -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 - -object SearchScreen : DynamicScreen(), TabScreen { - - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.screen_title_search, - icon = R.drawable.ic_search_2_line - ) - - @Composable - override fun Content() { - RegisterExtraContent { SearchBar() } - - SearchScreen() - } -} - -@Composable -fun SearchBar( - searchVM: SearchViewModel = singleViewModel(), -) { - SearchInputBar( - value = searchVM.keyword, - onValueChange = { searchVM.searchFor(it) }, - onSubmit = { searchVM.searchFor(it) } - ) -} - -@OptIn( - ExperimentalFoundationApi::class, - ExperimentalMaterialApi::class -) -@Composable -private fun DynamicScreen.SearchScreen( - playingVM: PlayingViewModel = singleViewModel(), - searchVM: SearchViewModel = singleViewModel(), -) { - val context = LocalContext.current - - DisposableEffect(Unit) { - onDispose { - context.getActivity()?.let { KeyboardUtils.hideSoftInput(it) } - } - } - - 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()) { - GlobalNavigatorImpl.showSongs( - 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)) - } - ) - } - } - ) -} - -@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, - ) - } - } - } - } - 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" - ) - } - } - 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/new_screen/SettingsScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SettingsScreen.kt index 1ff586f02..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 @@ -11,17 +11,19 @@ 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 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 @@ -29,12 +31,13 @@ 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 -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.base.smartBarPadding import com.lalilu.component.extension.rememberFixedStatusBarHeightDp import com.lalilu.component.settings.SettingCategory import com.lalilu.component.settings.SettingFilePicker @@ -47,14 +50,24 @@ 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 +import kotlin.math.roundToInt -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 = { stringResource(id = R.string.screen_title_settings) }, + icon = RemixIcon.System.settings4Line, + ) + } @Composable override fun Content() { @@ -73,7 +86,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 @@ -95,7 +107,7 @@ private fun SettingsScreen( ) { } - LLazyColumn( + LazyColumn( contentPadding = PaddingValues(top = rememberFixedStatusBarHeightDp()) ) { item { @@ -115,7 +127,8 @@ private fun SettingsScreen( state = ignoreAudioFocus ) SettingProgressSeekBar( - state = volumeControl, + value = { volumeControl.value.toFloat() }, + onValueUpdate = { volumeControl.value = it.roundToInt() }, title = "独立音量控制", valueRange = 0..100 ) @@ -184,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 +// ) } } @@ -197,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, @@ -311,5 +324,7 @@ private fun SettingsScreen( } } } + + smartBarPadding() } } \ No newline at end of file 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 d2d02ccb4..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongDetailScreen.kt +++ /dev/null @@ -1,350 +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.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.wrapContentHeight -import androidx.compose.foundation.lazy.rememberLazyListState -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.derivedStateOf -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.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 -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 coil.compose.AsyncImage -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.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 -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.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 - -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 }, - getSong = { song.value } - ) - } -} - -@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) -@Composable -private fun DetailScreen( - mediaId: () -> String, - getSong: () -> 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()}") - } - return - } - - val listState = rememberLazyListState() - val scrollPosition = rememberScrollPosition(state = listState) - val bgAlpha = remember { - derivedStateOf { - return@derivedStateOf 1f - (scrollPosition.value / 500f) - .coerceIn(0f, 0.8f) - } - } - - val intent = remember(song) { - Intent().apply { - component = ComponentName( - "com.xjcheng.musictageditor", - "com.xjcheng.musictageditor.SongDetailActivity" - ) - action = "android.intent.action.VIEW" - data = song.uri - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .recomposeHighlighter(), - contentAlignment = Alignment.TopCenter - ) { - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .edgeTransparent(position = EDGE_BOTTOM, percent = 1.5f) - .graphicsLayer { alpha = bgAlpha.value }, - model = ImageRequest.Builder(context) - .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) - ) { - 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) - ) - } - } - } - } - } - } - - item { - 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 - ) - ) - } - ) - } - } - - item { - SongInformationCard( - modifier = Modifier.fillMaxWidth(), - song = song - ) - } - } - } -} \ 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/new_screen/SongsScreen.kt deleted file mode 100644 index ecf3648ee..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/new_screen/SongsScreen.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.lalilu.lmusic.compose.new_screen - -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 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.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 - -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 - ) - - @Transient - private var scrollHelper: LazyListScrollToHelper? = null - - @Transient - private var songsSM: SongsScreenModel? = null - - @Composable - override fun registerActions(): 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 historyVM: HistoryViewModel = singleViewModel() - val songsSM = rememberScreenModel { SongsScreenModel() } - .also { this.songsSM = it } - val scrollHelper = rememberLazyListScrollToHelper(listState = listState) - .also { this.scrollHelper = it } - - Songs( - showAll = true, - mediaIds = mediaIds, - 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 - ) - } - } - ) - } -} \ 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..fa9c1a7af --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongActionsCard.kt @@ -0,0 +1,99 @@ +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.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 + +@Composable +fun SongActionsCard( + modifier: Modifier = Modifier, + song: LSong, +) { + 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(12.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 = { + AppRouter.intent( + NavIntent.Push( + 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..421c9f44b --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongAlbumInfoCard.kt @@ -0,0 +1,149 @@ +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 +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.lmedia.entity.LAlbum + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SongAlbumInfoCard( + modifier: Modifier = Modifier, + album: LAlbum, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + onClick = { + AppRouter.route("/pages/albums/detail") + .with("albumId", album.id) + .push() + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + // TODO Animation BG + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + 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" + ) + 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) + ) + } + } + } + } + } +} + +@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/SongArtistsRow.kt b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt new file mode 100644 index 000000000..f87d75eb4 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongArtistsRow.kt @@ -0,0 +1,48 @@ +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmedia.entity.LArtist + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) +@Composable +fun SongArtistsRow( + modifier: Modifier = Modifier, + artists: Set +) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + artists.forEach { + Chip( + onClick = { + 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, + color = MaterialTheme.colors.onBackground + .copy(alpha = 0.7f) + ) + } + } + } +} \ 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 new file mode 100644 index 000000000..e75f08a6a --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/new_screen/detail/SongInformationCard.kt @@ -0,0 +1,209 @@ +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 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.mutableStateOf +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.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 + +@Composable +fun SongInformationCard( + modifier: Modifier = Modifier, + song: LSong +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + song.metadata.genre.takeIf(String::isNotBlank)?.let { + ColumnItem( + title = "流派", + content = it, + ) + } + + song.fileInfo.mimeType.let { mimeType -> + ColumnItem( + title = "文件类型", + content = mimeType, + ) + } + + ColumnItem( + title = "文件大小", + content = remember(song) { + ConvertUtils.byte2FitMemorySize(song.fileInfo.size) + }, + ) + + if (song.fileInfo.bitrate > 0) { + ColumnItem( + title = "平均码率", + content = remember(song) { "%.1f kbps".format(song.fileInfo.bitrate / 1000f) }, + ) + } + + song.metadata.dateAdded.let { date -> + 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(8.dp) + ) { + song.metadata.disc.takeIf(String::isNotBlank)?.let { disc -> + ColumnItem( + modifier = Modifier.weight(1f), + title = "光盘号", + content = disc, + ) + } + song.metadata.track.takeIf(String::isNotBlank)?.let { track -> + ColumnItem( + modifier = Modifier.weight(1f), + title = "音轨号", + content = track, + ) + } + } + } + + song.fileInfo.pathStr?.takeIf { it.isNotBlank() }?.let { path -> + ColumnItem( + title = "文件位置", + content = path, + verticalAlignment = Alignment.Top, + ) + } + + 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 + ) + } + } +} + +@Composable +fun ColumnItem( + modifier: Modifier = Modifier, + title: String, + content: String, + maxLines: Int = 5, + 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, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.caption, + ) + } +} \ No newline at end of file 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 c46b3e261..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/presenter/DetailScreenPresenter.kt +++ /dev/null @@ -1,72 +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.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 -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 - -data class DetailScreenIsPlayingState( - val isPlaying: 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)) - } - } - } -} - -@SuppressLint("ComposableNaming") -@Composable -fun DetailScreenIsPlayingPresenter( - mediaId: String, - playingVM: PlayingViewModel = koinInject() -): DetailScreenIsPlayingState { - val isPlaying = playingVM.isItemPlaying(mediaId, Playable::mediaId) - - return DetailScreenIsPlayingState(isPlaying = isPlaying) { - 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/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/ShowScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt deleted file mode 100644 index 711ece2fe..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/ShowScreen.kt +++ /dev/null @@ -1,256 +0,0 @@ -package com.lalilu.lmusic.compose.screen - -import android.content.res.Configuration -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.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.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.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 coil.compose.AsyncImage -import coil.compose.AsyncImagePainter -import coil.compose.SubcomposeAsyncImage -import coil.compose.SubcomposeAsyncImageContent -import coil.request.ImageRequest -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( - 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 - } - - if (visible) { - val song by LPlayer.runtime.info.playingFlow.collectAsState(null) - - Box( - modifier = Modifier - .fillMaxSize() - .background(color = Color.DarkGray) - ) { - BlurImageBg(playable = song) - Row( - modifier = Modifier - .fillMaxSize() - .clickable(enabled = false) { } - .padding(64.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = 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 = painter.state - 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 - ) - } - } - } - } -} - -@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?, -) { - if (playable == null) { - Text( - text = "歌曲读取失败", - color = Color.White, - fontSize = 24.sp - ) - return - } - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = playable.title, - color = Color.White, - fontSize = 24.sp - ) - Text( - text = playable.subTitle, - color = Color.White, - fontSize = 16.sp - ) - Text( - text = playable.subTitle, - color = Color.White, - fontSize = 12.sp - ) - } -} - -@Composable -fun ControlPanel( - playingVM: PlayingViewModel = singleViewModel(), -) { - val isPlaying = LPlayer.runtime.info.isPlayingFlow.collectAsState(false) - var playMode by playingVM.settingsSp.playMode - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - IconButton(onClick = { PlayerAction.SkipToPrevious.action() }) { - Image( - painter = painterResource(id = R.drawable.ic_skip_previous_line), - contentDescription = "skip_back", - modifier = Modifier.size(28.dp) - ) - } - IconToggleButton( - checked = isPlaying.value, - onCheckedChange = { - PlayerAction.PlayOrPause.action() - } - ) { - Image( - painter = painterResource( - if (isPlaying.value) R.drawable.ic_pause_line else R.drawable.ic_play_line - ), - contentDescription = "play_pause", - modifier = Modifier.size(28.dp) - ) - } - IconButton(onClick = { PlayerAction.SkipToNext.action() }) { - Image( - painter = painterResource(id = R.drawable.ic_skip_next_line), - contentDescription = "skip_forward", - modifier = Modifier.size(28.dp) - ) - } - 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 - } - ), - contentDescription = "play_pause", - colorFilter = ColorFilter.tint(color = Color.White), - modifier = Modifier.size(24.dp) - ) - } - } -} \ 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..1900da4bb --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailContent.kt @@ -0,0 +1,172 @@ +package com.lalilu.lmusic.compose.screen.detail + +import android.net.Uri +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.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.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.component.extension.clipFade +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 }, +) { + val bottomPadding = LocalSmartBarPadding.current.value + val navigationBar = WindowInsets.navigationBars.asPaddingValues() + + LazyColumn( + modifier = modifier + .fillMaxSize(), + contentPadding = PaddingValues( + bottom = navigationBar.calculateBottomPadding() + + bottomPadding.calculateBottomPadding() + + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item(key = "MAIN_COVER") { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.BottomCenter + ) { + AsyncImage( + modifier = Modifier + .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 = "" + ) + + 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 = 24.sp, + lineHeight = 30.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() + .padding(horizontal = 12.dp), + album = it + ) + } + } + + item(key = "ACTIONS") { + SongActionsCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + song = song + ) + } + + item(key = "INFOS") { + SongInformationCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + 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/screen/detail/SongDetailScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt new file mode 100644 index 000000000..32312004f --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongDetailScreen.kt @@ -0,0 +1,73 @@ +package com.lalilu.lmusic.compose.screen.detail + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.R +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.ScreenInfo +import com.lalilu.component.base.screen.ScreenInfoFactory +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.action.PlayerAction +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( + @Param val mediaId: String +) : Screen, ScreenActionFactory, ScreenInfoFactory, ScreenType.Detail { + override val key: ScreenKey = "${super.key}:$mediaId" + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.screen_title_song_detail) } + ) + } + + @Composable + override fun provideScreenActions(): List = remember(this) { + listOfNotNull( + requestFor( + qualifier = named("like_action"), + parameters = { parametersOf(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 + + PlayerAction.AddToNext(song.id).action() + DynamicTipsItem.Static( + title = song.metadata.title, + subTitle = "下一首播放", + imageData = song + ).show() + } + ), + ) + } + + @Composable + override fun Content() { + val song = LMedia.getFlow(id = mediaId) + .collectAsState(initial = null) + + SongDetailContent( + song = { song.value }, + ) + } +} \ 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..b7cfb8016 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/detail/SongPlayAction.kt @@ -0,0 +1,77 @@ +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.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 + +@OptIn(ExperimentalMaterialApi::class) +fun provideSongPlayAction(mediaId: String): ScreenAction.Dynamic { + return ScreenAction.Dynamic { actionContext -> + val color = Color(0xFF008394) + + Surface( + color = color.copy(0.2f), + onClick = { MediaControl.addAndPlay(mediaId) } + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AnimatedContent( + modifier = Modifier + .fillMaxHeight(), + targetState = MPlayer.isItemPlaying(mediaId), + 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/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 40bd2fac0..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 @@ -3,13 +3,12 @@ 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 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 @@ -43,18 +42,16 @@ 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.lmusic.compose.component.CustomTransition 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 @Composable -@OptIn(ExperimentalAnimationApi::class) fun GuidingScreen() { val windowSize = LocalWindowSize.current val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp + @@ -65,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( @@ -109,13 +102,17 @@ fun GuidingScreen() { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start ) { - AnimatedContent(targetState = currentScreenTitleRes, transitionSpec = { - (slideInVertically { height -> height } + fadeIn() with - 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", @@ -126,17 +123,13 @@ fun GuidingScreen() { } Navigator( AgreementScreen( - nextScreen = PermissionsScreen( - nextScreen = SeekbarGuidingScreen - ) + nextScreen = PermissionsScreen() ) ) { navigator -> navigatorState.value = navigator CustomTransition( navigator = navigator - ) { - it.Content() - } + ) } } } 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..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 @@ -4,43 +4,57 @@ 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.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 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.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.component.base.CustomScreen -import com.lalilu.component.base.ScreenInfo +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 - ) +class PermissionsScreen : Screen, ScreenInfoFactory { + + @Composable + override fun provideScreenInfo(): ScreenInfo { + return remember { + ScreenInfo(title = { stringResource(R.string.screen_title_permissions) }) + } + } @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 + + LaunchedEffect(permission.status) { + if (permission.status is PermissionStatus.Granted) { + LMedia.init(context) + } + } Column( modifier = Modifier @@ -50,8 +64,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/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..c9883db52 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/home/HomeScreenContent.kt @@ -0,0 +1,53 @@ +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.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.LatestPanel +import org.koin.core.qualifier.named + +@Composable +fun HomeScreenContent( + modifier: Modifier = Modifier, +) { + val padding by LocalSmartBarPadding.current + + val dailyRecommend = DailyRecommend.register() + val entryPanel = EntryPanel.register() + val latestPanel = LatestPanel.register() + val historyPanel = remember { + requestFor(named("history_panel")) + }?.register() + + LazyVerticalGrid( + modifier = modifier, + columns = GridCells.Fixed(12), + contentPadding = WindowInsets.systemBars.asPaddingValues() + ) { + dailyRecommend(this) + + latestPanel(this) + + historyPanel?.invoke(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/playing/BlurBackground.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/BlurBackground.kt index 039b50d34..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 @@ -20,11 +20,12 @@ 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.request.ImageRequest +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 @Composable fun BlurBackground( @@ -50,16 +51,17 @@ fun BlurBackground( val srcRect = remember { Rect() } val targetRect = remember { Rect() } - SubcomposeAsyncImage( + AsyncImage( 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 } @@ -85,7 +87,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/CustomAnchoredDraggableState.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomAnchoredDraggableState.kt index 8a28f79a0..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 @@ -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.animation.rememberSplineBasedDecay +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.gestures.ScrollScope +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 com.lalilu.component.OverScroller +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 overScroller: OverScroller, 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 position = mutableFloatStateOf(Float.MAX_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 @@ -85,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) + } } } @@ -175,78 +223,98 @@ 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 + // 使用自定义的OverScroller进行Fling推算,获取终速 + val velocityLeft = overScroller.fling( + initialVelocity = velocityY, + startPosition = position.floatValue, + min = minPosition.toFloat(), + max = maxPosition.toFloat() ) - snapBy(overScroller.finalY) - return velocityY - } - - fun snapBy(targetPosition: Int) { - when (stateValue) { - DragAnchor.Middle -> { - val position = - calcSnapToPosition(targetPosition, minPosition, middlePosition, maxPosition) - animator.animateToFinalPosition(position.toFloat()) - } + val targetPosition = calcSnapByTargetPosition( + targetPosition = overScroller.finalPosition.toInt() + ) - DragAnchor.Min -> { - val position = - calcSnapToPosition(targetPosition, minPosition, middlePosition) - animator.animateToFinalPosition(position.toFloat()) - } + doAnimateTo( + offset = targetPosition.toFloat(), + initialVelocity = velocityY + ) - DragAnchor.Max -> { - val position = - calcSnapToPosition(targetPosition, middlePosition, maxPosition) - animator.animateToFinalPosition(position.toFloat()) - } + return velocityLeft + } - 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) } } 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, + initialVelocity: Float = animation.velocity + ) { + animation.apply { + snapTo(position.floatValue) + animateTo( + targetValue = offset, + initialVelocity = initialVelocity, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) { + updatePosition(value) + } + } + } +} @Composable fun rememberCustomAnchoredDraggableState( - context: Context = LocalContext.current, onStateChange: (DragAnchor, DragAnchor) -> Unit = { _, _ -> } ): CustomAnchoredDraggableState { var initAnchor by rememberSaveable(saver = DragAnchor.Saver) { mutableStateOf(DragAnchor.Middle) } + val flingSpec = rememberSplineBasedDecay() + val overScroller = remember { OverScroller(flingSpec) } + val scope = rememberCoroutineScope() return remember { CustomAnchoredDraggableState( - context = context, + scope = scope, + overScroller = overScroller, initAnchor = { initAnchor }, onStateChange = { oldState, newState -> initAnchor = newState 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 b8b4d42f3..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/CustomRecyclerView.kt +++ /dev/null @@ -1,132 +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.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 -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, - navigator = NavigationWrapper.navigator, - ) - NavigationWrapper.navigator?.show() - } - - 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/LyricSentence.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt deleted file mode 100644 index 2fa8c4b89..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/LyricSentence.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.lalilu.lmusic.compose.screen.playing - -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.ExperimentalFoundationApi -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 - - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun LyricSentence( - modifier: Modifier = Modifier, - lyric: LyricEntry, - 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) { - 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 - ) - ) - } - } - - 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/NestedScrollBaseLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt new file mode 100644 index 000000000..4c2c5c38a --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/NestedScrollBaseLayout.kt @@ -0,0 +1,180 @@ +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.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.Velocity +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.roundToInt + + +@Composable +fun NestedScrollBaseLayout( + draggable: CustomAnchoredDraggableState, + isLyricScrollEnable: MutableState, + toolbarContent: @Composable () -> 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 lyricViewNestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // 取消正在进行的动画事件 + draggable.tryCancel() + + if ( + !isLyricScrollEnable.value + && available.y > 0 + && source == NestedScrollSource.UserInput + && 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.UserInput) { + draggable.dispatchRawDelta(available.y) + } + available + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (isLyricScrollEnable.value) { + super.onPreScroll(available, source) + } else { + draggable.dispatchRawDelta(available.y) + available + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (!isLyricScrollEnable.value) { + draggable.fling(available.y) + return available + } + + return super.onPreFling(available) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + draggable.fling(0f) + + return super.onPostFling(consumed, available) + } + } + } + + val playlistNestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + draggable.tryCancel() + + if (available.y < 0f) { + return available.copy(y = draggable.dispatchRawDelta(available.y)) + } + + return super.onPreScroll(available, source) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (available.y > 0f) { + val consumedY = draggable.dispatchRawDelta(available.y) + + if ((available.y - consumedY) > 0.005f && source == NestedScrollSource.SideEffect) { + throw CancellationException() + } + return available.copy(y = consumedY) + } + + return super.onPostScroll(consumed, available, source) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + draggable.fling(available.y) + + return if (available.y > 0) { + // 向下滑动的情况,消耗剩余的所有速度,避免剩余的速度传递给OverScroll + available + } else { + // 向上滑动的情况,将剩余速度继续传递给外部的OverScroll + super.onPostFling(consumed, available) + } + } + } + } + + Box { + Layout( + content = { + toolbarContent() + dynamicHeaderContent( + Modifier.nestedScroll(lyricViewNestedScrollConnection) + ) + playlistContent( + Modifier.nestedScroll(playlistNestedScrollConnection) + ) + } + ) { 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 63cbb7a4b..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 @@ -2,85 +2,89 @@ 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.Animatable 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.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 +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.mutableLongStateOf +import androidx.compose.runtime.mutableFloatStateOf 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.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.LocalContext +import androidx.compose.ui.platform.LocalDensity 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 androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController -import com.lalilu.common.HapticUtils +import com.lalilu.component.base.LocalEnhanceSheetState +import com.lalilu.component.extension.DynamicTipsItem 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.compose.screen.playing.lyric.LyricLayout +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.lmusic.extension.SleepTimerSmallEntry -import com.lalilu.lmusic.viewmodel.PlayingViewModel -import com.lalilu.lplayer.LPlayer -import com.lalilu.lplayer.extensions.PlayerAction -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.mapLatest +import com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.action.PlayerAction +import com.lalilu.lplayer.extensions.PlayMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext import org.koin.compose.koinInject import kotlin.math.pow -import kotlin.math.roundToInt - -@OptIn(ExperimentalCoroutinesApi::class) @Composable fun PlayingLayout( - playingVM: PlayingViewModel = singleViewModel(), - settingsSp: SettingsSp = koinInject() + settingsSp: SettingsSp = koinInject(), ) { - val view = LocalView.current + val context = LocalContext.current val haptic = LocalHapticFeedback.current + val lifecycle = LocalLifecycleOwner.current + val enhanceSheetState = LocalEnhanceSheetState.current val systemUiController = rememberSystemUiController() - val lyricLayoutLazyListState = rememberLazyListState() + val listState = 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) } + val currentPosition = remember { mutableFloatStateOf(0f) } + val animation = remember { Animatable(0f) } - 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 @@ -97,300 +101,260 @@ 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 + LaunchedEffect(Unit) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + withFrameMillis { + val newValue = MPlayer.currentPosition.toFloat() + if (currentPosition.floatValue != newValue) { + currentPosition.floatValue = newValue + } } - - return super.onPreFling(available) } + } + } - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - if (consumed.y != 0f && available.y == 0f) { - draggable.fling(0f) + NestedScrollBaseLayout( + 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 + ) } - return super.onPostFling(consumed, available) } - 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) + Column( + modifier = Modifier + .hideControl( + enable = { hideComponent.value }, + intercept = { true } + ) + .fillMaxWidth() + .statusBarsPadding() + .padding(bottom = 10.dp) + .graphicsLayer { + translationY = lerp( + start = 0f, + stop = -navigationBar + .getBottom(density) + .toFloat() + 10.dp.toPx(), + fraction = middleToMaxProgress.value + ) } - - // 前面的条件都不满足,则将该事件全部消费,避免未知的子组件产生动作 - else -> available - } + ) { + PlayingToolbar( + 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() }, + extraContent = { LyricViewToolbar() } + ) } + }, + dynamicHeaderContent = { modifier -> + BoxWithConstraints( + 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 - } - } - - recyclerViewScrollState.value -> { - if (available.y > 0) available.copy(y = draggable.scrollBy(available.y)) - else super.onPostScroll(consumed, available, source) + val minToMiddleProgress = remember { + derivedStateOf { + draggable.progressBetween( + from = DragAnchor.Min, + to = DragAnchor.Middle, + offset = draggable.position.floatValue + ) } - - else -> super.onPostScroll(consumed, available, source) } - } - } - } - - BoxWithConstraints { - Layout( - modifier = Modifier - .nestedScroll(nestedScrollConnection), - content = { - Column( - modifier = Modifier - .hideControl( - enable = { hideComponent.value }, - intercept = { true } + val middleToMaxProgress = remember { + derivedStateOf { + draggable.progressBetween( + from = DragAnchor.Middle, + to = DragAnchor.Max, + offset = draggable.position.floatValue ) - .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() }, - fixContent = { SleepTimerSmallEntry() }, - extraContent = { LyricViewToolbar() } - ) + } } - Box( + BlurBackground( 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 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 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 - ) - } + .fillMaxWidth() + .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 = { + MPlayer.currentMediaItem + ?: com.lalilu.component.R.drawable.ic_music_2_line_100dp } + ) - 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 aspectRatio = - this@BoxWithConstraints.constraints.maxHeight.toFloat() / this@BoxWithConstraints.constraints.maxWidth.toFloat() - val scale = lerp(1f, aspectRatio, progress) + val lyricSource = remember { LyricSourceEmbedded(context = context) } + val lyrics = remember { mutableStateOf>(emptyList()) } - val floatProgress = transition(middleToMaxProgress.value) - val translation = floatProgress * 200f + 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() } + } + } - 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) - LyricLayout( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - val interpolation = - adInterpolator.getInterpolation(middleToMaxProgress.value) - val progressIncrease = (2 * interpolation - 1F).coerceAtLeast(0F) + val fixOffset = size.height - draggable.position.floatValue - val floatProgress = transition(middleToMaxProgress.value) - val translation = floatProgress * 200f + val progressTransited = transition(middleToMaxProgress.value) + val additionalOffset = progressTransited * 200f * 3f - 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)) + translationY = additionalOffset + fixOffset + alpha = progressIncrease }, - onItemLongClick = { - if (draggable.state.value == DragAnchor.Max) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - isLyricScrollEnable.value = !isLyricScrollEnable.value - } - }, - ) - } - - CustomRecyclerView( - modifier = Modifier.clipToBounds(), - scrollToTopEvent = { scrollToTopEvent.value }, - onScrollStart = { recyclerViewScrollState.value = true }, - onScrollTouchUp = { }, - onScrollIdle = { - recyclerViewScrollState.value = false - draggable.fling(0f) - } + lyricEntry = lyrics, + listState = listState, + currentTime = { animation.value.toLong() }, + screenConstraints = constraints, + isUserClickEnable = { draggable.state.value == DragAnchor.Max }, + isUserScrollEnable = { isLyricScrollEnable.value }, + onPositionReset = { + if (isLyricScrollEnable.value) { + isLyricScrollEnable.value = false + } + }, + onItemClick = { + if (isLyricScrollEnable.value) { + isLyricScrollEnable.value = false + } + PlayerAction.SeekTo(it.time).action() + }, + onItemLongClick = { + if (draggable.state.value == DragAnchor.Max) { + 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 + }, + playlistContent = { modifier -> + Surface(color = MaterialTheme.colors.background) { + PlaylistLayout( + modifier = modifier.clipToBounds(), + forceRefresh = { draggable.state.value != DragAnchor.Min }, + items = { MPlayer.currentTimelineItems } + ) + } + }, + overlayContent = { + val animateProgress = animateFloatAsState( + targetValue = if (!isLyricScrollEnable.value) 100f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "" ) - layout( - width = constraints.maxWidth, - height = constraints.maxHeight + Box( + Modifier + .align(Alignment.BottomCenter) + .graphicsLayer { + alpha = animateProgress.value / 100f + translationY = (1f - animateProgress.value / 100f) * 500f + } ) { - 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 + .hideControl(enable = { hideComponent.value }) + .padding(horizontal = 40.dp) + .padding(bottom = 100.dp), + animateColor = { animateColor.value }, + maxValue = { MPlayer.currentDuration.toFloat() }, + animation = animation, + dataValue = { currentPosition.floatValue }, + onDispatchDragOffset = { enhanceSheetState?.dispatch(it) }, + onDragStop = { result -> + if (result == -1) enhanceSheetState?.hide() + else enhanceSheetState?.settle(0f) + }, + 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() + ClickPart.Middle -> PlayerAction.PlayOrPause.action() + ClickPart.End -> PlayerAction.SkipToNext.action() + } + } + ) } } - - 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 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 new file mode 100644 index 000000000..ff59bfd0f --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingLayoutExpended.kt @@ -0,0 +1,334 @@ +package com.lalilu.lmusic.compose.screen.playing + +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.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.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.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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import org.koin.compose.koinInject + +/** + * Expended 状态下的播放布局 + */ +@Composable +fun PlayingLayoutExpended( + modifier: Modifier = Modifier, +) { + val currentPlaying = MPlayer.currentMediaItem + val context = LocalContext.current + val data = remember(currentPlaying) { + ImageRequest.Builder(context) + .data(currentPlaying) + .size(500) + .crossfade(true) + .transformations(BlurTransformation(context, 25f, 8f)) + .build() + } + + 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 + ) + } + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + PlayerPanel( + modifier = Modifier + .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) + ) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = model, + contentScale = ContentScale.Crop, + contentDescription = null + ) + } + } + + SongDetailPanel(playable = currentPlaying) + Spacer(Modifier.weight(1f)) + 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 +private fun SongDetailPanel( + playable: MediaItem?, +) { + if (playable == null) { + Text( + text = "歌曲读取失败", + color = Color.White, + fontSize = 24.sp + ) + return + } + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = playable.mediaMetadata.title.toString(), + color = Color.White, + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = playable.mediaMetadata.subtitle.toString(), + color = Color.White, + fontSize = 12.sp, + lineHeight = 18.sp + ) + } +} + + +@Composable +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, alignment = Alignment.CenterHorizontally) + ) { + IconButton(onClick = { PlayerAction.SkipToPrevious.action() }) { + Image( + painter = painterResource(id = R.drawable.ic_skip_previous_line), + contentDescription = "skip_back", + modifier = Modifier.size(28.dp) + ) + } + IconToggleButton( + checked = isPlaying.value, + onCheckedChange = { + PlayerAction.PlayOrPause.action() + } + ) { + Image( + painter = painterResource( + if (isPlaying.value) R.drawable.ic_pause_line else R.drawable.ic_play_line + ), + contentDescription = "play_pause", + modifier = Modifier.size(28.dp) + ) + } + IconButton(onClick = { PlayerAction.SkipToNext.action() }) { + Image( + painter = painterResource(id = R.drawable.ic_skip_next_line), + contentDescription = "skip_forward", + modifier = Modifier.size(28.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 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..7a59a1f91 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlayingSmartCard.kt @@ -0,0 +1,94 @@ +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.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.lplayer.MPlayer + +@Composable +fun PlayingSmartCard( + modifier: Modifier = Modifier, +) { + val currentPlaying = MPlayer.currentMediaItem + + 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?.mediaMetadata?.title?.toString() ?: "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?.mediaMetadata?.subtitle?.toString() ?: "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/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt new file mode 100644 index 000000000..4fa8f1752 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -0,0 +1,207 @@ +package com.lalilu.lmusic.compose.screen.playing + +import androidx.annotation.OptIn +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +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.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.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +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 +import kotlinx.coroutines.launch + + +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, + forceRefresh: () -> Boolean = { false }, + items: () -> List = { emptyList() } +) { + val view = LocalView.current + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val favouriteIds = state("favourite_ids", 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 + + if (isNewListTopVisible || isOldListTopVisible || forceRefresh()) { + actualItems = emptyList() + view.post { + actualItems = newList + scope.launch { listState.animateScrollToItem(0) } + } + } else { + actualItems = newList + } + } + + LazyColumn( + state = listState, + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 200.dp), + overscrollEffect = null + ) { + items( + items = actualItems, + key = { it.key }, + ) { item -> + SongCardReverse( + modifier = Modifier.animateItem(), + 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) + .jump() + } + ) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun SongCardReverse( + modifier: Modifier = Modifier, + 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 = {} +) { + 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/playing/SeekbarLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt deleted file mode 100644 index 68b14f325..000000000 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/SeekbarLayout.kt +++ /dev/null @@ -1,165 +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.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 -import com.lalilu.lplayer.LPlayer -import com.lalilu.lplayer.extensions.PlayerAction -import com.lalilu.lplayer.playback.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/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/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/LyricLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricLayout.kt similarity index 50% 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 acf8b4d6e..f33f467f0 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,6 +1,5 @@ -package com.lalilu.lmusic.compose.screen.playing +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 @@ -15,7 +14,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 @@ -31,85 +29,29 @@ 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.platform.LocalDensity 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.funny.data_saver.core.DataSaverMutableState +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.lmedia.lyric.LyricItem +import com.lalilu.lmedia.lyric.findPlayingIndex +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 kotlin.math.abs +import org.koin.compose.koinInject +import org.koin.core.qualifier.named -data class LyricEntry( - val index: Int, - val time: Long, - val text: String, - val translate: String? = null -) { - val key = "$index:$time" -} - -/** - * 读取字体文件,并将其转换成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 = LyricEntry( - index = 0, - time = 0, - text = "暂无歌词", - translate = null -) @OptIn(FlowPreview::class) @Composable @@ -117,55 +59,36 @@ 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 }, + lyricEntry: State> = remember { mutableStateOf(emptyList()) }, + screenConstraints: Constraints, isUserClickEnable: () -> Boolean = { false }, isUserScrollEnable: () -> Boolean = { false }, - isTranslationShow: () -> Boolean = { false }, onPositionReset: () -> Unit = {}, - onItemClick: (LyricEntry) -> Unit = {}, - onItemLongClick: (LyricEntry) -> Unit = {}, - lyricEntry: State> = remember { mutableStateOf(emptyList()) }, - fontFamily: State = remember { mutableStateOf(null) } + onItemClick: (LyricItem) -> Unit = {}, + onItemLongClick: (LyricItem) -> Unit = {}, ) { + val density = LocalDensity.current + val settings: DataSaverMutableState = koinInject(named("LyricSettings")) val textMeasurer = rememberTextMeasurer() - val isUserScrolling = remember(isUserScrollEnable()) { mutableStateOf(isUserScrollEnable()) } - val scrollToHelper = rememberLazyListScrollToHelper(listState) + val isUserScrolling = remember { mutableStateOf(isUserScrollEnable()) } + .also { it.value = isUserScrollEnable() } + val recorder = remember { ItemRecorder() } val scroller = rememberLazyListAnimateScroller( listState = listState, enableScrollAnimation = { !isUserScrolling.value }, - keysKeeper = { scrollToHelper.getKeys() } + keys = { recorder.list().filterNotNull() } ) val currentItemIndex = remember { derivedStateOf { - val time = currentTime() + val time = currentTime() + settings.value.timeOffset 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 + lyricEntryList.findPlayingIndex(time) } } - val currentItem: State = remember { + val currentItem: State = remember { derivedStateOf { currentItemIndex.value .takeIf { it != Int.MAX_VALUE } @@ -200,58 +123,80 @@ fun LyricLayout( } } + val context = remember { + LyricContext( + currentTime = { currentTime() + settings.value.timeOffset }, + currentIndex = { currentItemIndex.value }, + isUserScrolling = { isUserScrolling.value }, + screenConstraints = screenConstraints, + textMeasurer = textMeasurer, + ) + } + 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 + ) ) { - scrollToHelper.record { + startRecord(recorder) { if (lyricEntry.value.isEmpty()) { - item { - 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 - ) - } + itemWithRecord(key = "EMPTY_TIPS") { + 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 { - record(lyricEntry.value.map { it.key }) - itemsIndexed( + itemsIndexedWithRecord( items = lyricEntry.value, key = { _, item -> item.key }, - contentType = { _, _ -> LyricEntry::class } + contentType = { _, _ -> LyricItem::class } ) { index, item -> - LyricSentence( - lyric = item, - maxWidth = maxWidth, - textMeasurer = textMeasurer, - fontFamily = fontFamily, - textAlign = textAlign, - textSize = textSize, - 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) } - ) + when (item) { + is LyricItem.NormalLyric -> LyricContentNormal( + lyric = item, + index = index, + modifier = Modifier, + 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, + 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..cc2b54d98 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettings.kt @@ -0,0 +1,101 @@ +@file:UseSerializers( + TextAlignSerializer::class, + TextUnitSerializer::class, + DpSerializer::class, + PaddingValueSerializer::class +) + +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 +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 + + +internal val DEFAULT_TEXT_SHADOW = Shadow( + color = Color.Black.copy(alpha = 0.2f), + offset = Offset(x = 0f, y = 1f), + 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 mainFont: SerializableFont? = null, + val translationFontSize: TextUnit = 22.sp, + val translationLineHeight: TextUnit = 26.sp, + val translationFontWeight: Int = FontWeight.Bold.weight, + val translationFont: SerializableFont? = null, + + // 特殊效果开关 + val blurEffectEnable: Boolean = true, + val translationVisible: Boolean = true, + val variableFontWeightEnable: Boolean = false +) { + @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)) + ) + ) + ) + + @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/LyricSettingsState.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt new file mode 100644 index 000000000..3f84914e4 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/LyricSettingsState.kt @@ -0,0 +1,30 @@ +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 +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(), + savePolicy = SavePolicy.NEVER + ) +} 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 new file mode 100644 index 000000000..8092b3a75 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentNormal.kt @@ -0,0 +1,185 @@ +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.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +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.platform.LocalLayoutDirection +import androidx.compose.ui.text.drawText +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 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 + + +@Composable +fun LyricContentNormal( + index: Int, + lyric: LyricItem.NormalLyric, + modifier: Modifier = Modifier, + settings: LyricSettings, + context: LyricContext, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, +) { + val density = LocalDensity.current + val direction = LocalLayoutDirection.current + val isCurrent = context.currentIndex() == index + + 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(settings, context, lyric) { + context.textMeasurer.measure( + text = lyric.content, + constraints = actualConstraints, + style = settings.mainTextStyle + ) to lyric.translation + ?.takeIf(String::isNotBlank) + ?.let { + context.textMeasurer.measure( + text = it, + constraints = actualConstraints, + style = settings.translationTextStyle + ) + } + } + + 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 animateHeight = animateDpAsState( + targetValue = heightDp as? Dp ?: 0.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + + val animateAlpha = animateFloatAsState( + targetValue = if (settings.translationVisible) 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) settings.scaleRange.endInclusive + else settings.scaleRange.start, + visibilityThreshold = 0.001f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + + 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, + label = "" + ) + + Canvas( + modifier = modifier + .fillMaxWidth() + .height(animateHeight.value) + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .blur { animateBlurRadius.value } + .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) + .padding(settings.containerPadding) + ) { + scale( + scale = scale.value, + pivot = pivotOffset as? Offset ?: Offset.Zero + ) { + drawText( + color = color.value, + shadow = DEFAULT_TEXT_SHADOW, + textLayoutResult = textResult + ) + + if (translateResult == null) return@scale + drawText( + color = color.value, + topLeft = translationTopLeft as? Offset ?: Offset.Zero, + 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..e37890da7 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/impl/LyricContentWords.kt @@ -0,0 +1,287 @@ +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.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.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.platform.LocalLayoutDirection +import androidx.compose.ui.text.drawText +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 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.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 + + +private val DEFAULT_GRADIENT_GAP = 48.dp + +@Composable +fun LyricContentWords( + index: Int, + lyric: LyricItem.WordsLyric, + modifier: Modifier = Modifier, + settings: LyricSettings, + context: LyricContext, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, +) { + val density = LocalDensity.current + val direction = LocalLayoutDirection.current + val isCurrent = context.currentIndex() == index + + val fullSentence = remember { lyric.getSentenceContent() } + 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(context, settings, lyric) { + context.textMeasurer.measure( + text = fullSentence, + constraints = actualConstraints, + 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 -> settings.scaleRange.endInclusive + context.currentTime() in lyric.startTime..lyric.endTime -> 0.95f + else -> settings.scaleRange.start + }, + visibilityThreshold = 0.001f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + + val animateAlpha = animateFloatAsState( + targetValue = if (settings.translationVisible) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ), + 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 + ) { + 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 animateHeight = animateDpAsState( + targetValue = heightDp as? Dp ?: 0.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ), + label = "" + ) + + 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, + label = "" + ) + + Canvas( + modifier = modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .fillMaxWidth() + .height(animateHeight.value) + .combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) + .blur { animateBlurRadius.value } + .padding(settings.containerPadding) + ) { + val now = context.currentTime() + val wordIndex = lyric.words.findPlayingIndexForWords(now) + val word = lyric.words.getOrNull(wordIndex) + + // 获取某一词的播放进度 + var progress = normalized( + start = word?.startTime ?: 0, + end = word?.endTime ?: 0, + current = now + ) + + // 若当前句的歌词已经播放完毕,则进度固定为1 + if (lyric.words.maxOf { it.endTime } < context.currentTime()) { + progress = 1f + } + + val offset = lyric.words.take(wordIndex) + .sumOf { it.content.length } + + val (path, rect, position) = textResult.getPathForProgress( + progress = progress, + offset = offset, + length = word?.content?.length + ) + + scale( + scale = scale.value, + pivot = pivotOffset as? Offset ?: Offset.Zero, + ) { + drawText( + color = Color(0x80FFFFFF), + shadow = DEFAULT_TEXT_SHADOW, + textLayoutResult = textResult, + ) + + if (progress > 0f) { + val lineProgress = if (progress >= 0.99f) 1f else { + normalized( + start = rect.left, + end = rect.right, + current = position + ) + } + + 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) { + withLayer { + drawText( + 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 + ) + } + } + } + } + + if (translateResult == null) return@scale + drawText( + color = Color(0x80FFFFFF), + topLeft = translationTopLeft as? Offset ?: Offset.Zero, +// shadow = DEFAULT_TEXT_SHADOW, + textLayoutResult = translateResult, + alpha = animateAlpha.value + ) + } + } +} + +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/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/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 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/TextLayoutUtils.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt new file mode 100644 index 000000000..2437bb302 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/lyric/utils/TextLayoutUtils.kt @@ -0,0 +1,145 @@ +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 + +/** + * 获取指定行的宽度 + */ +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 +} + +@Immutable +data class WordsLayoutResult( + @Stable val path: Path, + @Stable val rect: Rect, + @Stable val position: Float +) + +/** + * 获取指定进度对应的路径 + * + * @param progress 进度 + * @param offset 起始偏移值 + * @param length 长度 + */ +fun TextLayoutResult.getPathForProgress( + progress: Float, + offset: Int = 0, + length: Int? = null +): WordsLayoutResult { + 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() + var rect: Rect? = null + var position = 0f + for (lineIndex in 0 until lineCount) { + val lineWidth = getLineWidth(lineIndex) + + // 若加上该行宽度会超出目标宽度,则说明该行已经超出目标宽度,此时需要截取该行 + if (addedWidth + lineWidth >= targetWidth) { + val widthToAdd = targetWidth - addedWidth + + 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 lineRect = getLineRect(lineIndex) + path.addRect(lineRect) + position = lineRect.right + addedWidth += lineWidth + rect = lineRect + } + } + + return WordsLayoutResult( + path = path, + 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) +} + +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 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 new file mode 100644 index 000000000..1c205d743 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/seekbar/SeekbarLayout.kt @@ -0,0 +1,666 @@ +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.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 +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 +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 +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.CompositingStrategy +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 +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextAlign +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 + +private 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 { + data object Start : ClickPart + data object Middle : ClickPart + data object End : ClickPart +} + +private fun SeekbarState.isCanceled(): Boolean { + return when (this) { + is SeekbarState.Cancel, is SeekbarState.Dispatcher -> true + else -> false + } +} + +@Preview +@Composable +fun SeekbarLayout( + modifier: Modifier = Modifier, + minValue: () -> Float = { 0f }, + 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 = {}, + onSeekTo: (Float) -> Unit = {}, + onSwitchTo: (Int) -> Unit = {}, + onClick: (ClickPart) -> Unit = {} +) { + val textStyle = remember { + TextStyle.Default.copy( + fontSize = 16.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold, + baselineShift = BaselineShift.None, + textAlign = TextAlign.End, + color = Color.White, + fontFamily = FontFamily.Monospace + ) + } + + 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() }, + ) + + 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) + } + + val offsetY = remember { + derivedStateOf { + val offsetY = seekbarOffsetY.floatValue + .coerceAtMost(0f) + .absoluteValue + .takeIf { it < (scrollThreadHold / 2f) } + ?: 0f + (offsetY / (scrollThreadHold / 2f)).coerceIn(0f, 1f) + } + } + val offsetYProgress = animateFloatAsState( + targetValue = offsetY.value, + animationSpec = spring( + stiffness = Spring.StiffnessLow, + dampingRatio = Spring.DampingRatioMediumBouncy + ), + ) + + val draggableState = rememberDraggable2DState { offset -> + val oldState = seekbarState.value + + val deltaY = offset.y + val deltaX = offset.x + + // 直接记录Y轴上的滚动距离 + seekbarOffsetY.floatValue += deltaY + + // 根据当前状态控制进度变量 + when { + isSwitching -> { + switchModeX.floatValue += 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 + } + + // 当状态发生变化的时候,进行震动 + if (oldState != seekbarState.value) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + + 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 + val targetOffset = scrollThreadHold + + density.run { (seekbarPaddingBottom + seekbarHeight).toPx() } + + animationState.animateTo(targetOffset) { + val dt = value - lastValue + lastValue = value + onDispatchDragOffset(-dt) + } + } + } + + else -> {} + } + + else -> {} + } + + // 若当前状态为Dispatcher,则将滚动的位移量向外分发 + if (seekbarState.value is SeekbarState.Dispatcher) { + onDispatchDragOffset(deltaY) + } + } + + 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) + } + isTouching = false + } + } + } + } + } + .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) + } + } + .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 + + seekbarOffsetY.floatValue = 0f + scope.launch { onDragStop(0) } + }, + onDrag = { _, dragAmount -> + draggableState.dispatchRawDelta(dragAmount) + } + ) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + + translationY = -offsetYProgress.value * (scrollThreadHold / 2f) + scaleX = 1f - (offsetYProgress.value * 0.1f) + scaleY = scaleX + } + .clip(RoundedCornerShape(16.dp)) + ) { + SeekbarBackground(visible = { isTouching && !isCanceled }) + SeekbarContentMask(clip = { isTouching && !isCanceled }) + SeekbarDuration( + modifier = Modifier + .align(Alignment.CenterEnd), + visible = { !isSwitching }, + text = { maxDurationText }, + textStyle = textStyle + ) + 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()) } + ) + SeekbarSwitcher(switching = { isSwitching }) + } + } +} + +@Composable +private 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 + ) + } +} + +@Composable +private 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" + ) + + 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) + ) + ) + + clipPath(path) { + drawRect(color = maskColor) + } + } +} + +@Composable +private 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 +private 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) + ) + } + } +} + +@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 = { }, + onDragEnd: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, + onLongClickStart: (Offset) -> Unit = {} +): 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 + ) + } + +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) +} + +private 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 +private 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 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 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/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/screen/search/SearchScreen.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt new file mode 100644 index 000000000..489efb140 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/SearchScreen.kt @@ -0,0 +1,177 @@ +package com.lalilu.lmusic.compose.screen.search + +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.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.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.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 + +@Destination("/pages/search") +data object SearchScreen : Screen, TabScreen, ScreenInfoFactory, ScreenBarFactory { + private fun readResolve(): Any = SearchScreen + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.screen_title_search) }, + icon = RemixIcon.System.search2Line, + ) + } + + @Composable + override fun Content() { + val searchVM: SearchVM = koinInject() + + SearchBar(searchVM = searchVM) + + SearchScreenContent( + searchVM = searchVM + ) + } +} + +@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() } + } + + 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 + } + + 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() + } + } +} + +@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) + ) + } + } + } +} \ 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..5bd54a669 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchArtistsResult.kt @@ -0,0 +1,121 @@ +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, +) : LazyGridContent { + + @Composable + override fun register(): LazyGridScope.() -> Unit { + val collapsed = remember { mutableStateOf(false) } + + return fun LazyGridScope.() { + if (artistsResult().isNotEmpty()) { + stickyHeader( + key = "${this@SearchArtistsResult::class.java.name}_Header", + contentType = "sticky" + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { collapsed.value = !collapsed.value } + .padding(16.dp), + 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 + ) + } + } + } + } + + 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() + } + ) + } + } + } + } +} \ 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..901aa4b87 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/search/extensions/SearchSongsResult.kt @@ -0,0 +1,113 @@ +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.component.state +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, +) : LazyGridContent { + + @Composable + override fun register(): LazyGridScope.() -> Unit { + val collapsed = remember { mutableStateOf(false) } + val favouriteIds = state("favourite_ids", emptyList()) + + return fun LazyGridScope.() { + if (songsResult().isNotEmpty()) { + stickyHeader( + key = "${this@SearchSongsResult::class.java.name}_Header", + contentType = "sticky" + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { collapsed.value = !collapsed.value } + .padding(16.dp), + 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 + ) + } + } + } + } + + if (!collapsed.value) { + items( + items = songsResult(), + key = { it.id }, + contentType = { it::class.java }, + span = { GridItemSpan(maxLineSpan) } + ) { + SongCard( + modifier = Modifier + .fillMaxWidth() + .animateItem(), + song = { it }, + isFavour = { favouriteIds.value.contains(it.id) } + ) + } + } + } + } +} \ 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 new file mode 100644 index 000000000..10dcf52e7 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreen.kt @@ -0,0 +1,170 @@ +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.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 +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.screenVM +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 +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 +import com.zhangke.krouter.annotation.Destination +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named + +@Destination("/pages/songs") +data class SongsScreen( + private val title: String? = null, + private val mediaIds: List = emptyList() +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory, ScreenType.List { + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.screen_title_songs) }, + icon = RemixIcon.Media.music2Line, + ) + } + + @Composable + override fun provideScreenActions(): List { + val vm = screenVM() + val state by vm.state + + 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) } + ), + ) + } + } + + @Composable + override fun Content() { + val vm = screenVM(parameters = { parametersOf(mediaIds) }) + val songs by vm.songs + val state by vm.state + + SongsSortPanelDialog( + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(SongsAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(SongsAction.SelectSortAction(it)) } + ) + + SongsHeaderJumperDialog( + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(SongsAction.HideJumperDialog) }, + items = { songs.keys }, + onSelectItem = { vm.intent(SongsAction.LocaleToGroupItem(it)) } + ) + + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(SongsAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(SongsAction.SearchFor(it)) } + ) + + 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 = { + val list = songs.values.flatten() + vm.selector.selectAll(list) + } + ), + 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) } + ) + ) + ) + + SongsScreenContent( + 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 new file mode 100644 index 000000000..953beaccd --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/songs/SongsScreenContent.kt @@ -0,0 +1,247 @@ +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 +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.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +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 +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.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.component.state +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.action.MediaControl +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest + +@Composable +internal fun SongsScreenContent( + recorder: ItemRecorder = ItemRecorder(), + eventFlow: SharedFlow = MutableSharedFlow(), + keys: () -> Collection = { emptyList() }, + songs: Map> = emptyMap(), + isSelecting: () -> Boolean = { false }, + isSelected: (LSong) -> Boolean = { false }, + onSelect: (LSong) -> Unit = {}, + onClickGroup: (GroupIdentity) -> Unit = {} +) { + 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 + ) + + LaunchedEffect(Unit) { + eventFlow.collectLatest { event -> + when (event) { + is SongsEvent.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 -> {} + } + } + } + + 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, + ) { + startRecord(recorder) { + itemWithRecord(key = "全部歌曲") { + val count = remember(songs) { songs.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, + ) + } + } + + songs.forEach { (group, list) -> + if (group !is GroupIdentity.None) { + stickyHeaderWithRecord( + key = group, + contentType = "group" + ) { + SongsScreenStickyHeader( + listState = listState, + group = group, + minOffset = { statusBar.getTop(density) }, + onClickGroup = onClickGroup + ) + } + } + + itemsWithRecord( + items = list, + key = { it.id }, + contentType = { it::class.java } + ) { + SongCard( + song = { it }, + isFavour = { favouriteIds.value.contains(it.id) }, + isSelected = { isSelected(it) }, + onClick = { + if (isSelecting()) { + onSelect(it) + } else { + MediaControl.playWithList( + mediaIds = list.map(LSong::id), + mediaId = it.id + ) + } + }, + onLongClick = { + if (isSelecting()) { + onSelect(it) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() + } + }, + onEnterSelect = { onSelect(it) } + ) + } + } + } + + smartBarPadding() + } + } +} + +@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/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/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/extension/DailyRecommend.kt b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt index 0f1ca0141..aad5c2ece 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/DailyRecommend.kt @@ -1,43 +1,172 @@ package com.lalilu.lmusic.extension +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.height 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.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.unit.dp -import com.lalilu.component.extension.dayNightTextColor -import com.lalilu.component.extension.singleViewModel -import com.lalilu.lmusic.GlobalNavigatorImpl +import com.lalilu.component.LazyGridContent +import com.lalilu.component.base.LocalWindowSize +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.screen.songs.SongsScreen import com.lalilu.lmusic.viewmodel.LibraryViewModel +import org.koin.compose.koinInject + +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.id } + AppRouter.intent(NavIntent.Push(SongsScreen(mediaIds = ids))) + } + ) { + Chip(onClick = { libraryVM.forceUpdate() }) { + Text( + style = MaterialTheme.typography.caption, + text = "换一换" + ) + } + } + } + + when (windowWidthClass) { + WindowWidthSizeClass.Compact -> dailyRecommendForSideCompat() + WindowWidthSizeClass.Medium -> dailyRecommendForSideMedium() + WindowWidthSizeClass.Expanded -> dailyRecommendForSideExpanded(libraryVM) + } + } + } +} + +fun LazyGridScope.dailyRecommendForSideCompat() { + item( + key = "daily_recommend", + contentType = "daily_recommend", + span = { GridItemSpan(maxLineSpan) } + ) { + val libraryVM: LibraryViewModel = koinInject() -@Composable -fun DailyRecommend( - vm: LibraryViewModel = singleViewModel(), -) { - Column { - Text( - modifier = Modifier - .padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 10.dp) - .fillMaxWidth(), - text = "每日推荐", - style = MaterialTheme.typography.h6, - color = dayNightTextColor() - ) RecommendRow( - items = { vm.dailyRecommends.value }, + items = { libraryVM.dailyRecommends.value }, getId = { it.id } ) { RecommendCard2( item = { it }, - contentModifier = Modifier.size(width = 250.dp, height = 250.dp), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) } + modifier = Modifier.size(width = 250.dp, height = 250.dp), + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .jump() + } ) } } } + +fun LazyGridScope.dailyRecommendForSideMedium() { + dailyRecommendForSideCompat() +} + +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 = { item }, + modifier = Modifier.fillMaxSize(), + onClick = { + AppRouter.route("/pages/songs/detail") + .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 + + Column( + modifier = Modifier + .height(250.dp) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + RecommendCard2( + item = { item }, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", item.id) + .jump() + } + ) + + RecommendCard2( + item = { item2 }, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + onClick = { + AppRouter.route("/pages/songs/detail") + .with("mediaId", item2.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 2cd8da87f..bdbc340e6 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/EntryPanel.kt @@ -2,80 +2,109 @@ 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.extension.dayNightTextColor -import com.lalilu.component.navigation.GlobalNavigator -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.new_screen.SongsScreen -import com.lalilu.lplaylist.screen.PlaylistScreen -import org.koin.compose.koinInject +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 +import com.lalilu.component.divider +import com.lalilu.component.navigation.AppRouter +import com.lalilu.component.navigation.NavIntent +import com.lalilu.component.rememberGridItemPadding +import com.zhangke.krouter.KRouter -@Composable -fun EntryPanel() { - val navigator: GlobalNavigator = koinInject() - val screenEntry = remember { - listOf( - SongsScreen(), - ArtistsScreen(), - AlbumsScreen(), - PlaylistScreen, - HistoryScreen, - DictionaryScreen, - SettingsScreen +object EntryPanel : LazyGridContent { + + @Composable + override fun register(): LazyGridScope.() -> Unit { + val screenEntry = remember { + listOfNotNull( + KRouter.route("/pages/songs"), + KRouter.route("/pages/artists"), + KRouter.route("/pages/albums"), + KRouter.route("/pages/playlist"), + KRouter.route("/pages/history"), + KRouter.route("/pages/folders"), + KRouter.route("/pages/settings") + ) + } + 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 info = entry.getScreenInfo() ?: continue + return fun LazyGridScope.() { + divider { it.height(16.dp) } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { navigator.navigateTo(entry) } - .padding(horizontal = 20.dp, vertical = 15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.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 + + Surface( + modifier = Modifier.padding(gridItemPaddings(index)), + shape = RoundedCornerShape(8.dp) ) { - info.icon?.let { icon -> - Icon( - painter = painterResource(id = icon), - contentDescription = stringResource(id = info.title), - tint = dayNightTextColor(0.7f) + 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) + ) + } + + Text( + text = title, + color = MaterialTheme.colors.onBackground.copy(0.6f), + style = MaterialTheme.typography.subtitle2 ) } - - Text( - text = stringResource(id = info.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 deleted file mode 100644 index 384c3127c..000000000 --- a/app/src/main/java/com/lalilu/lmusic/extension/HistoryPanel.kt +++ /dev/null @@ -1,96 +0,0 @@ -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.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() -) { - val haptic = LocalHapticFeedback.current - val itemsCount = - remember { derivedStateOf { historyVM.historyState.value.size.coerceIn(0, 5) } } - val itemsHeight = animateDpAsState(itemsCount.value * 85.dp, label = "") - - Column { - RecommendTitle( - title = "最近播放", - onClick = { } - ) { - Chip( - onClick = { - // navigator.navigate(HistoryScreenDestination) - }, - ) { - Text(style = MaterialTheme.typography.caption, text = "历史记录") - } - } - - LazyColumn( - 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) - } - ) - } - } - } -} 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..632fcd35e 100644 --- a/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt +++ b/app/src/main/java/com/lalilu/lmusic/extension/LatestPanel.kt @@ -1,63 +1,80 @@ package com.lalilu.lmusic.extension -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Column +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.extension.singleViewModel -import com.lalilu.lmusic.GlobalNavigatorImpl +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 com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.action.MediaControl +import org.koin.compose.koinInject -@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +object LatestPanel : LazyGridContent { -@Composable -fun LatestPanel( - libraryVM: LibraryViewModel = singleViewModel(), - playingVM: PlayingViewModel = singleViewModel() -) { - Column { - RecommendTitle( - title = "最近添加", - onClick = { } - ) { - Chip(onClick = { }) { - Text( - style = MaterialTheme.typography.caption, - text = "所有歌曲" - ) + @OptIn(ExperimentalMaterialApi::class) + @Composable + override fun register(): LazyGridScope.() -> Unit { + val libraryVM: LibraryViewModel = 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 = "所有歌曲" + ) + } + } } - } - RecommendRow( - items = { libraryVM.recentlyAdded.value }, - getId = { it.id } - ) { - RecommendCard( - item = { it }, - width = { 100.dp }, - height = { 100.dp }, - modifier = Modifier.animateItemPlacement(), - onClick = { GlobalNavigatorImpl.goToDetailOf(mediaId = it.id) }, - 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.id) + .jump() + }, + isPlaying = { MPlayer.isItemPlaying(it.id) }, + onClickButton = { MediaControl.addAndPlay(mediaId = it.id) } ) } - ) + } } } -} +} \ No newline at end of file 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..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,12 +54,12 @@ 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 com.lalilu.lplayer.action.PlayerAction 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 +131,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 ) @@ -154,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() @@ -163,9 +163,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() } } ) @@ -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/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/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 ef71026e0..000000000 --- a/app/src/main/java/com/lalilu/lmusic/service/LMusicNotifier.kt +++ /dev/null @@ -1,214 +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 coil.imageLoader -import coil.request.ImageRequest -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 - - override suspend fun getBitmapFromData(data: Any?): Bitmap? { - return mContext.imageLoader.execute( - ImageRequest.Builder(mContext) - .allowHardware(false) - .data(data) - .size(400) - .build() - ).drawable?.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/service/LMusicServiceConnector.kt b/app/src/main/java/com/lalilu/lmusic/service/LMusicServiceConnector.kt deleted file mode 100644 index 6c212a45a..000000000 --- a/app/src/main/java/com/lalilu/lmusic/service/LMusicServiceConnector.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.lalilu.lmusic.service - -import android.content.ComponentName -import android.content.Context -import android.support.v4.media.MediaBrowserCompat -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import com.lalilu.common.base.Playable -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LSong -import com.lalilu.lmusic.datastore.LastPlayedSp -import com.lalilu.lmusic.utils.extension.moveHeadToTailWithSearch -import com.lalilu.lplayer.LPlayer -import com.lalilu.lplayer.runtime.ItemSource -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext - -@OptIn(ExperimentalCoroutinesApi::class) -class LMusicServiceConnector( - private val context: Context, - private val lastPlayedSp: LastPlayedSp, -) : DefaultLifecycleObserver, CoroutineScope { - override val coroutineContext: CoroutineContext = Dispatchers.IO - private val browser: MediaBrowserCompat by lazy { - MediaBrowserCompat( - context, - ComponentName(context, LMusicService::class.java), - MediaBrowserCompat.ConnectionCallback(), - null - ) - } - - init { - launch { - reloadItems() - initLPlayer() - } - } - - override fun onStart(owner: LifecycleOwner) { - reloadItems() - browser.connect() - } - - override fun onStop(owner: LifecycleOwner) { - browser.disconnect() - } - - private fun reloadItems() { - val queue = LPlayer.runtime.queue - // 若当前播放列表不为空,则不尝试提取历史数据填充 - if (queue.getSize() != 0) { - return - } - - val songIds = lastPlayedSp.lastPlayedListIdsKey.value - val lastPlayedIdKey = lastPlayedSp.lastPlayedIdKey.value - - // 存在历史记录 - if (songIds.isNotEmpty()) { - queue.setIds(songIds) - queue.setCurrentId(lastPlayedIdKey) - return - } - - LMedia.whenReady { - val songs = LMedia.get() - 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 - } - } - } -} \ No newline at end of file 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/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") - } - } -} 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..002ed60d2 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/utils/DynamicStatusBarUtils.kt @@ -0,0 +1,93 @@ +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 = false, + 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内的平均亮度 + var sumValue = 0f + for (x in 0.. 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 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/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, 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..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 @@ -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.fetch.SourceFetchResult +import coil3.request.Options 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/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 b88498c20..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 @@ -1,18 +1,26 @@ package com.lalilu.lmusic.utils.coil.keyer -import coil.key.Keyer -import coil.request.Options -import com.lalilu.common.base.Playable -import com.lalilu.lmedia.entity.Item +import androidx.media3.common.MediaItem +import coil3.key.Keyer +import coil3.request.Options +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}" } } -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/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/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..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 @@ -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 @@ -21,19 +24,18 @@ 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.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,46 +228,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( - state: RecyclerView.State, - extraLayoutSpace: IntArray - ) { - extraLayoutSpace[0] = size - extraLayoutSpace[1] = size - } - } -} - /** * 简易的防抖实现 */ diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt deleted file mode 100644 index 8ab9d22d0..000000000 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/HistoryViewModel.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.lalilu.lmusic.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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 - -@OptIn(ExperimentalCoroutinesApi::class) -class HistoryViewModel( - private val historyRepo: HistoryRepository -) : ViewModel() { - val historyState = historyRepo - .getHistoriesIdsMapWithLastTime() - .flatMapLatest { map -> - val ids = map.toList() - .sortedByDescending { it.second } - .map { it.first } - LMedia.flowMapBy(ids) - }.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 - } - - fun clearHistories() { - historyRepo.clearHistories() - } -} \ No newline at end of file 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..ab2a5c292 100644 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/LibraryViewModel.kt @@ -9,28 +9,43 @@ 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 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) - fun checkOrUpdateToday() { + init { + 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 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 7a8cea52a..000000000 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/PlayingViewModel.kt +++ /dev/null @@ -1,89 +0,0 @@ -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) - val isPlaying = LPlayer.runtime.info.isPlayingFlow.toState(false, viewModelScope) - - /** - * 综合播放操作 - * - * @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 == LPlayer.runtime.queue.getCurrentId() && 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 - } - - 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) - - override fun isFavourite(item: Playable): Boolean { - return isFavouriteList.value?.contains(item.mediaId) ?: false - } -} \ No newline at end of file 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) } } diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt new file mode 100644 index 000000000..00df9c112 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchVM.kt @@ -0,0 +1,74 @@ +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 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.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() { + 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 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 -> + if (keywordList.isEmpty()) return@combine emptyList() + items.filter { item -> keywordList.all { item.getMatchStr().contains(it) } } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt b/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt deleted file mode 100644 index 1093968e1..000000000 --- a/app/src/main/java/com/lalilu/lmusic/viewmodel/SearchViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.lalilu.lmusic.viewmodel - -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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 -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.mapLatest - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -class SearchViewModel : ViewModel() { - val keyword = mutableStateOf("") - private val keywordStr = MutableStateFlow("") - private val keywords = keywordStr.debounce(200).mapLatest { - if (it.isEmpty()) return@mapLatest emptyList() - 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) - - 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) } } - } - - fun searchFor(str: String) { - keywordStr.tryEmit(str) - } -} \ 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..b830e16ae --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/viewmodel/SongsVM.kt @@ -0,0 +1,147 @@ +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 +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.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 + + +@Stable +@Immutable +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, +) { + val distinctKey: Int = + mediaIds.hashCode() + searchKeyWord.hashCode() + selectedSortAction.hashCode() + + @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.uppercase()) } } + } + + 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() + .distinctUntilChangedBy { it.distinctKey } + .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/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/build.gradle.kts b/build.gradle.kts index 7d807818c..7e136ccd7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,14 +4,11 @@ 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 + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.krouter.plugin) + alias(libs.plugins.lumo) } -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 +// 配置注入遍历的起点项目 +ext { set("targetInjectProjectName", "app") } \ No newline at end of file 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..c72edb179 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 { @@ -22,19 +22,22 @@ android { kotlinOptions { jvmTarget = "1.8" } + lint { + disable += "UnrememberedMutableState" + } } 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("io.github.billywei01:fastkv:2.4.2") - api("io.github.billywei01:packable:1.1.0") + api(libs.kotlinx.coroutines.guava) - 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/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 } 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/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/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..c2ba05bd0 --- /dev/null +++ b/common/src/main/java/com/lalilu/common/MVI.kt @@ -0,0 +1,56 @@ +package com.lalilu.common + +import androidx.compose.runtime.Stable +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()) + + @Stable + fun stateFlow(): StateFlow + + @Stable + 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/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 c0e70f4aa..000000000 --- a/common/src/main/java/com/lalilu/common/base/Playable.kt +++ /dev/null @@ -1,24 +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 - - val metaDataCompat: MediaMetadataCompat -} \ 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..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,8 +1,14 @@ 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) - open class SourceSticker(source: String) : Sticker(source) + + @Stable + open class SourceSticker(sourceType: SourceType) : Sticker(sourceType.name) data object HasLyricSticker : Sticker("LRC") data object HiresSticker : Sticker("HIRES") } @@ -15,7 +21,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/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/common/src/main/java/com/lalilu/common/kv/BaseKV.kt b/common/src/main/java/com/lalilu/common/kv/BaseKV.kt index ff0b9bfe6..3f20c05c9 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 -> ObjectListKVImpl(actualKey, T::class.java) + } + } 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..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,7 @@ package com.lalilu.common.kv +import android.annotation.SuppressLint +import androidx.annotation.CallSuper import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import kotlinx.coroutines.flow.Flow @@ -7,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 @@ -36,6 +39,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..717301889 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,177 @@ 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.gson.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>() + +@Suppress("UnstableApiUsage") +class StringListKVImpl( + private val key: String +) : KVListItem() { + companion object { + val typeToken by lazy { object : TypeToken>() {} } + } - key.take(20) + EncryptUtils.encryptMD5ToString(key).take(8) + 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) + } + + 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 FloatListKVImpl( + 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 DoubleListKVImpl( + 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 BoolListKVImpl( + 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) + } - val count = fastKV.getInt(countKey) - if (count == 0) { - LogUtils.i("[$key] is empty, return emptyList [countKey: $countKey]") - return emptyList() + 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) } + } +} - return (0..count).mapNotNull { - val valueKey = valueKeyTemplate.format(identityKey, it) - get(valueKey) +class ObjectListKVImpl( + private val key: String, + 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, 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/component/build.gradle.kts b/component/build.gradle.kts index 628f4740d..7860ac31b 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -1,18 +1,21 @@ +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag + 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,37 +30,52 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } } -dependencies { - api(libs.lottie.compose) +composeCompiler { + featureFlags.set( + listOf( + ComposeFeatureFlag.StrongSkipping + ) + ) +} - api(libs.bundles.voyager) +dependencies { + // 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(libs.human.readable) + api(libs.kotlinx.datetime) + api(libs.remixicon.kmp) api(project(":lmedia")) api(project(":common")) api(project(":lplayer")) - api(libs.coil) - api(libs.coil.compose) - // 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("sh.calvin.reorderable:reorderable:2.4.0") + 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.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") - // 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) + api("io.github.FunnySaltyFish:data-saver-core:1.2.2") + api("com.nomanr:composables:1.1.0") + + // 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") } \ 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 530948ddf..000000000 --- a/component/src/main/java/com/lalilu/component/ComponentModule.kt +++ /dev/null @@ -1,12 +0,0 @@ -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/LLazyColumn.kt b/component/src/main/java/com/lalilu/component/LLazyColumn.kt index a96ae6da9..4bf09201e 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 @@ -16,8 +16,9 @@ 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 +@Deprecated("弃用") @Composable fun LLazyColumn( modifier: Modifier = Modifier, @@ -31,7 +32,7 @@ fun LLazyColumn( userScrollEnabled: Boolean = true, content: LazyListScope.() -> Unit ) { - val padding by LocalPaddingValue.current + val padding by LocalSmartBarPadding.current LazyColumn( modifier = modifier, @@ -47,7 +48,7 @@ fun LLazyColumn( item { Spacer( modifier = Modifier - .fillMaxWidth() + .width(1.dp) .height(padding.calculateBottomPadding() + 20.dp) ) } 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 7bef6b835..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.LocalPaddingValue - -@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 LocalPaddingValue.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/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/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/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 + } 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/SongListWrapper.kt b/component/src/main/java/com/lalilu/component/SongListWrapper.kt deleted file mode 100644 index 27d4875ad..000000000 --- a/component/src/main/java/com/lalilu/component/SongListWrapper.kt +++ /dev/null @@ -1,274 +0,0 @@ -package com.lalilu.component - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -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.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 -> - SongCard( - song = { item }, - modifier = Modifier.animateItemPlacement(), - onClick = { - if (selector?.isSelecting() == true) { - selector.onSelect(item) - } else { - onClickItem(item) - } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClickItem(item) - }, - onDoubleClick = { - if (selector?.isSelecting() != true) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onDoubleClickItem(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 -> - SongCard( - song = { item }, - modifier = Modifier.animateItemPlacement(), - dragModifier = Modifier.draggableHandle( - onDragStopped = { onDragMoveEnd(itemsState) } - ), - onClick = { - if (selector?.isSelecting() == true) { - selector.onSelect(item) - } else { - onClickItem(item) - } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClickItem(item) - }, - onDoubleClick = { - if (selector?.isSelecting() != true) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onDoubleClickItem(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 deleted file mode 100644 index ee1ffa72d..000000000 --- a/component/src/main/java/com/lalilu/component/Songs.kt +++ /dev/null @@ -1,424 +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 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.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.navigation.GlobalNavigator -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 - -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() - ) - } -} - -@Composable -fun DynamicScreen.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 navigator: GlobalNavigator = koinInject() - 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 - ) - - 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 = { navigator.goToDetailOf(it.mediaId) }, - 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 = { navigator.goToDetailOf(it.mediaId) }, - 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 DynamicScreen.registerSelectPanel( - modifier: Modifier = Modifier, - selector: ItemSelectHelper = rememberItemSelectHelper(), - selectActions: () -> List = { emptyList() } -) { - RegisterMainContent( - 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/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/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 914951291..000000000 --- a/component/src/main/java/com/lalilu/component/base/BottomSheetNavigator.kt +++ /dev/null @@ -1,227 +0,0 @@ -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 -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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -typealias BottomSheetNavigatorContent = @Composable (bottomSheetNavigator: BottomSheetNavigator) -> Unit - -@SuppressLint("UnnecessaryComposedModifier") -@ExperimentalMaterialApi -@Composable -fun BottomSheetNavigatorLayout( - modifier: Modifier = Modifier, - navigator: Navigator, - hideOnBackPress: Boolean = true, - resetOnHide: Boolean = false, - visibleWhenShow: Boolean = false, - 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), - sheetGesturesEnabled: Boolean = true, - skipHalfExpanded: Boolean = true, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - sheetContent: BottomSheetNavigatorContent = { CurrentScreen() }, - content: BottomSheetNavigatorContent -) { - val coroutineScope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, // initialValue 不可动态修改,重组时与预取效果不符 - skipHalfExpanded = skipHalfExpanded, - animationSpec = animationSpec - ) - - // 只能重组时取值后判断状态进行更新,且需要避免该参数变化触发不必要的重组 - LaunchedEffect(Unit) { - when { - defaultIsVisible && sheetState.currentValue == ModalBottomSheetValue.Hidden -> sheetState.show() - !defaultIsVisible && sheetState.currentValue == ModalBottomSheetValue.Expanded -> sheetState.hide() - } - } - - val bottomSheetNavigator = remember { - BottomSheetNavigator( - visibleWhenShow = visibleWhenShow, - resetOnHide = resetOnHide, - navigator = navigator, - sheetState = sheetState, - coroutineScope = coroutineScope - ) - } - - CompositionLocalProvider(LocalSheetNavigator provides bottomSheetNavigator) { - ModalBottomSheetLayout( - modifier = modifier, - scrimColor = scrimColor, - sheetState = sheetState, - sheetShape = sheetShape, - sheetElevation = sheetElevation, - sheetBackgroundColor = sheetBackgroundColor, - sheetContentColor = sheetContentColor, - sheetGesturesEnabled = sheetGesturesEnabled, - sheetContent = { - BottomSheetNavigatorBackHandler(bottomSheetNavigator, hideOnBackPress) - Box( - modifier = Modifier.nestedScroll(rememberBottomSheetNestedScrollInterceptor()), - content = { sheetContent(bottomSheetNavigator) } - ) - }, - content = { content(bottomSheetNavigator) } - ) - } -} - -@OptIn(ExperimentalMaterialApi::class) -class BottomSheetNavigator 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 { - - 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 - } - } - - when (sheetState.currentValue) { - ModalBottomSheetValue.Hidden -> sheetState.progress >= 0.95f - ModalBottomSheetValue.Expanded -> sheetState.progress <= 0.05f - - else -> false - } - } - - override fun back(enable: Boolean) { - // 若当前只剩一个页面,则不清空元素了 - if (navigator.items.size <= 1) { - hide() - return - } - - if (navigator.pop().not() && enable) { - hide() - } - } - - 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) - } - } - - if (!isVisible) { - coroutineScope.launch { - sheetState.show() - } - } - } - - 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) - } - } - } - - 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) - } -} - 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..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,97 +1,12 @@ package com.lalilu.component.base -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.navigation.BackHandler +import com.lalilu.component.base.screen.ScreenInfoFactory import kotlinx.coroutines.CoroutineScope -/** - * 定义一个页面的信息 - */ -data class ScreenInfo( - @StringRes val title: Int, - @DrawableRes val icon: Int? = null, - val immerseStatusBar: Boolean = true, -) - -/** - * 定义某个页面可执行的动作 - */ -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 fitImePadding: Boolean = false, - val isLongClickAction: Boolean = false, - val onAction: () -> Unit - ) : ScreenAction - - data class ComposeAction( - val content: @Composable () -> Unit - ) : ScreenAction -} - -data class ScreenBarComponent( - val state: MutableState, - val showMask: Boolean, - val showBackground: Boolean, - val key: String = state.hashCode().toString(), - 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 -} - -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 DialogScreen : CustomScreen - +@Deprecated("弃用,待移除") +interface TabScreen : Screen, ScreenInfoFactory interface UiState interface UiAction @@ -100,80 +15,3 @@ interface UiPresenter : CoroutineScope { fun presentState(): T fun onAction(action: UiAction) } - -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 = extraContentStack.plus(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 = mainContentStack.plus(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/EnhanceSheetState.kt b/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt new file mode 100644 index 000000000..b10c210d2 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/EnhanceSheetState.kt @@ -0,0 +1,93 @@ +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 { bottomSheetState.expand() } + } + + override fun hide() { + scope.launch { 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() { + scope.launch { sheetState.hide() } + } + + override fun show() { + 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/LocalObject.kt b/component/src/main/java/com/lalilu/component/base/LocalObject.kt index f1c4a8aa9..75bd6c765 100644 --- a/component/src/main/java/com/lalilu/component/base/LocalObject.kt +++ b/component/src/main/java/com/lalilu/component/base/LocalObject.kt @@ -1,13 +1,67 @@ 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.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 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") } + +@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", + 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/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/base/NavigatorHeader.kt b/component/src/main/java/com/lalilu/component/base/NavigatorHeader.kt index c60e2993f..c840883af 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 @@ -14,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 @@ -23,11 +25,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,46 +47,60 @@ 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 + ), + 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), - modifier = modifier.padding(top = 26.dp, bottom = 20.dp, start = 20.dp, end = 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() 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/base/screen/ScreenActionFactory.kt b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt new file mode 100644 index 000000000..d340e8b76 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenActionFactory.kt @@ -0,0 +1,39 @@ +package com.lalilu.component.base.screen + +import androidx.compose.runtime.Composable +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 subTitle: @Composable () -> String? = { null }, + val color: @Composable () -> Color = { Color.White }, + val icon: @Composable () -> ImageVector? = { null }, + val dotColor: @Composable () -> Color? = { null }, + val longClick: () -> Boolean = { false }, + val onAction: () -> Unit = {} + ) : ScreenAction() + + @Stable + data class Dynamic( + val content: @Composable (ActionContext) -> Unit + ) : 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..a7436903c --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenBarFactory.kt @@ -0,0 +1,68 @@ +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.currentCompositeKeyHash +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +data class ScreenBarComponent( + val key: String, + val content: @Composable () -> Unit +) + +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: () -> Boolean, + onDismiss: () -> Unit, + onBackPressed: (() -> Unit)?, + content: @Composable () -> Unit + ) { + val key = currentCompositeKeyHash + + LaunchedEffect(isVisible()) { + if (isVisible()) { + stack.stack += ScreenBarComponent( + key = key.toString(), + content = { + content.invoke() + + if (onBackPressed != null) { + BackHandler { + onDismiss() + onBackPressed() + } + } + } + ) + } else { + stack.stack = stack.stack + .filter { it.key != key.toString() } + } + } + } +} \ 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..2dfa2572d --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenInfoFactory.kt @@ -0,0 +1,15 @@ +package com.lalilu.component.base.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector + +data class ScreenInfo( + val title: @Composable () -> String, + val icon: ImageVector? = null +) + +interface ScreenInfoFactory { + + @Composable + fun provideScreenInfo(): ScreenInfo +} \ No newline at end of file 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..4eb2bdfab --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/screen/ScreenType.kt @@ -0,0 +1,8 @@ +package com.lalilu.component.base.screen + +sealed interface 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/base/songs/SongsHeaderJumperDialog.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt new file mode 100644 index 000000000..128052ff1 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsHeaderJumperDialog.kt @@ -0,0 +1,114 @@ +package com.lalilu.component.base.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.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 +fun SongsHeaderJumperDialog( + isVisible: () -> Boolean, + onDismiss: () -> Unit, + items: () -> Collection, + onSelectItem: (item: GroupIdentity) -> Unit = {} +) { + val dialog = remember { + DialogItem.Dynamic(backgroundColor = Color.Transparent) { + SongsHeaderJumperDialogContent( + items = items, + onSelectItem = onSelectItem + ) + } + } + + DialogWrapper.register( + isVisible = isVisible, + onDismiss = onDismiss, + dialogItem = dialog + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +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/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/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt new file mode 100644 index 000000000..d2cd7ee5f --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSearcherPanel.kt @@ -0,0 +1,204 @@ +package com.lalilu.component.base.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.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 +fun ScreenBarFactory.SongsSearcherPanel( + isVisible: () -> Boolean, + onDismiss: () -> Unit, + keyword: () -> String, + onUpdateKeyword: (String) -> Unit +) { + RegisterContent( + isVisible = isVisible, + onDismiss = onDismiss, + onBackPressed = { } + ) { + SongsSearcherPanelContent( + modifier = Modifier, + keyword = keyword, + onUpdateKeyword = onUpdateKeyword, + onBackPress = { onDismiss() } + ) + } +} + +@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/songs/SongsSelectorPanel.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt new file mode 100644 index 000000000..344cd5f3b --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSelectorPanel.kt @@ -0,0 +1,52 @@ +package com.lalilu.component.base.songs + +import androidx.compose.runtime.Composable +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.smartbar.NavigateCommonBarContent +import com.lalilu.remixicon.System +import com.lalilu.remixicon.system.closeLine + + +@Composable +fun ScreenBarFactory.SongsSelectorPanel( + isVisible: () -> Boolean, + onDismiss: () -> Unit, + screenActions: List? = null, +) { + RegisterContent( + isVisible = isVisible, + onDismiss = onDismiss, + onBackPressed = { } + ) { + SongsSelectorPanelContent( + modifier = Modifier, + screenActions = screenActions, + onBackPress = { onDismiss() } + ) + } +} + +@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/base/songs/SongsSortPanelDialog.kt b/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt new file mode 100644 index 000000000..c9be229c9 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/base/songs/SongsSortPanelDialog.kt @@ -0,0 +1,207 @@ +package com.lalilu.component.base.songs + +import android.content.res.Configuration +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.navigationBarsPadding +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.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.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.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.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 +fun SongsSortPanelDialog( + isVisible: () -> Boolean, + onDismiss: () -> Unit, + supportSortActions: Set, + isSortActionSelected: (ListAction) -> Boolean = { false }, + onSelectSortAction: (ListAction) -> Unit +) { + val dialog = remember { + DialogItem.Dynamic(backgroundColor = Color.Transparent) { + SongsSortPanelDialogContent( + supportSortActions = supportSortActions, + isSortActionSelected = isSortActionSelected, + onSelectSortAction = onSelectSortAction, + onDismiss = { dismiss() } + ) + } + } + + DialogWrapper.register( + isVisible = isVisible, + onDismiss = onDismiss, + dialogItem = dialog + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun SongsSortPanelDialogContent( + modifier: Modifier = Modifier, + supportSortActions: Set, + 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(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 + ) { + VerticalGrid( + 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) + ) { + 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 = RemixIcon.System.closeLine, + contentDescription = null + ) + } + } + + supportSortActions.forEach { + SortItem( + modifier = Modifier.fillMaxWidth(), + title = stringResource(id = it.titleRes), + subTitle = "test", + colors = colors, + 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, +) +@Composable +private fun SongsSortPanelDialogPVDay() { + 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() { + SongsSortPanelDialogContent( + supportSortActions = setOf(SortStaticAction.Normal) + ) +} \ No newline at end of file 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/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( 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..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.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 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 @@ -27,7 +27,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.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -35,40 +34,48 @@ 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 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 com.lalilu.common.base.Playable +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.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, - song: () -> Playable, + 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() } @@ -76,48 +83,53 @@ fun SongCard( SongCard( modifier = modifier, dragModifier = dragModifier, - title = { item.title }, - subTitle = { item.subTitle }, - duration = { item.durationMs }, - sticker = { item.sticker }, + horizontalArrangement = horizontalArrangement, + interactionSource = interactionSource, + title = { item.metadata.title }, + subTitle = { item.metadata.artist }, + duration = { item.metadata.duration }, imageData = { item }, onClick = onClick, onLongClick = onLongClick, onDoubleClick = onDoubleClick, onEnterSelect = onEnterSelect, - hasLyric = hasLyric, isPlaying = isPlaying, + fixedHeight = fixedHeight, + reverseLayout = reverseLayout, isSelected = isSelected, showPrefix = showPrefix, - fixedHeight = fixedHeight, + stickerContent = stickerContent, prefixContent = prefixContent ) } -@OptIn(ExperimentalFoundationApi::class) + @Composable 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, onDoubleClick: (() -> Unit)? = null, onEnterSelect: () -> Unit = {}, - hasLyric: () -> Boolean = { false }, isPlaying: () -> Boolean = { false }, fixedHeight: () -> Boolean = { false }, + reverseLayout: () -> Boolean = { false }, isSelected: () -> Boolean = { false }, showPrefix: () -> Boolean = { false }, + stickerContent: @Composable RowScope.() -> Unit = {}, prefixContent: @Composable (Modifier) -> Unit = {} ) { - val interactionSource = remember { MutableInteractionSource() } 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 = "" ) @@ -129,15 +141,25 @@ fun SongCard( .background(color = bgColor) .combinedClickable( interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = onClick, 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, @@ -147,32 +169,18 @@ fun SongCard( showPrefix = showPrefix, fixedHeight = fixedHeight, prefixContent = prefixContent, - stickerContent = { - HasLyricIcon( - hasLyric = hasLyric, - fixedHeight = fixedHeight - ) - - val stickers = remember { sticker() } - stickers.firstOrNull { it is Sticker.ExtSticker }?.let { - Image( - painter = painterResource(id = mimeTypeToIcon(mimeType = it.name)), - contentDescription = "MediaType Icon", - colorFilter = dayNightTextColorFilter(0.9f), - 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 + ) + } } } @@ -202,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() @@ -223,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)) } @@ -233,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( @@ -241,13 +249,12 @@ fun SongCardContent( text = durationMsToString(duration = duration()), fontSize = 12.sp, letterSpacing = 0.05.em, - color = dayNightTextColor(0.7f) + color = MaterialTheme.colors.onBackground.copy(0.7f) ) } } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun SongCardImage( modifier: Modifier = Modifier, @@ -256,8 +263,6 @@ fun SongCardImage( onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, ) { - val haptic = LocalHapticFeedback.current - Surface( modifier = modifier, elevation = 2.dp, @@ -271,7 +276,6 @@ fun SongCardImage( interactionSource = interaction, indication = null, onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) onLongClick() }, onClick = onClick @@ -296,8 +300,6 @@ private fun SongCardPreview() { title = { "歌いましょう鳴らしましょう" }, subTitle = { "MyGO!!!!!" }, duration = { 189999L }, - hasLyric = { true }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) } @@ -310,32 +312,24 @@ private fun SongCardPreviewMulti() { title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - hasLyric = { true }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) SongCard( title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - hasLyric = { true }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) SongCard( title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - hasLyric = { true }, - sticker = { emptyList() }, imageData = { "https://api.sretna.cn/layout/pc.php" } ) SongCard( title = { "测试" }, subTitle = { "测试" }, duration = { 159999L }, - hasLyric = { true }, - 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/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/extension/ComposeExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeExt.kt index ebf1ec497..92abad659 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,18 @@ 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.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 +32,26 @@ 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 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 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 kotlin.math.roundToInt @Composable @@ -65,15 +85,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的 @@ -87,12 +98,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) @@ -181,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) - } -} - /** * 监听滚动位置的变化,计算总的滚动距离 */ @@ -277,6 +266,41 @@ fun rememberIsPadLandScape(): State { } } +@Deprecated(message = "弃用") @Composable inline fun singleViewModel(): T = - koinViewModel(viewModelStoreOwner = koinInject()) \ No newline at end of file + koinViewModel(viewModelStoreOwner = koinInject()) + +@Composable +inline fun Screen.screenVM( + qualifier: Qualifier? = null, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull( + value = getScreenViewModelStoreOwner() ?: LocalViewModelStoreOwner.current, + lazyMessage = { "No ViewModelStoreOwner was provided for ${T::class.java}" } + ), + key: String? = this.key, + 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 + ) +} + +@OptIn(ExperimentalVoyagerApi::class, InternalVoyagerApi::class) +@Composable +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/extension/ComposeModifierExt.kt b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt index 4b3e14cf3..dfb3b1c0f 100644 --- a/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/ComposeModifierExt.kt @@ -2,22 +2,34 @@ 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 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.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 @@ -43,10 +55,7 @@ fun Modifier.longClickable( this .semantics { role = Role.Button } - .indication( - interactionSource = interactionSource, - indication = indication ?: rememberRipple() - ) + .indication(interactionSource, indication ?: LocalIndication.current) .hoverable(interactionSource, true) .pointerInput(Unit) { var timer: Job? @@ -93,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 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..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,5 +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 @@ -17,7 +24,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 @@ -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 @@ -63,7 +70,11 @@ interface DialogHost { fun push(dialogItem: DialogItem) @Composable - fun register(isVisible: MutableState, dialogItem: DialogItem) + fun register( + isVisible: () -> Boolean, + onDismiss: () -> Unit, + dialogItem: DialogItem + ) } interface DialogContext { @@ -83,17 +94,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 @@ -110,7 +125,7 @@ object DialogWrapper : DialogHost, DialogContext { @Composable override fun Content() { if (dialogItem == null) return - + val isActiveClose by remember { mutableStateOf(false) .also { dismissFunc = { it.value = true } } @@ -136,8 +151,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, @@ -151,24 +166,37 @@ 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 = "", + 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) { + 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/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/FlowExt.kt b/component/src/main/java/com/lalilu/component/extension/FlowExt.kt index 58f20c868..f02d62a45 100644 --- a/component/src/main/java/com/lalilu/component/extension/FlowExt.kt +++ b/component/src/main/java/com/lalilu/component/extension/FlowExt.kt @@ -25,7 +25,10 @@ fun Flow.toState(scope: CoroutineScope): State { /** * 将Flow转换为State,附带初始值 */ -fun Flow.toState(defaultValue: T, scope: CoroutineScope): State { +fun Flow.toState( + defaultValue: T, + scope: CoroutineScope, +): State { return mutableStateOf(defaultValue).also { state -> 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 new file mode 100644 index 000000000..e57b42d6f --- /dev/null +++ b/component/src/main/java/com/lalilu/component/extension/ItemRecorder.kt @@ -0,0 +1,108 @@ +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.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf + +class LazyListRecordScope internal constructor( + var recorder: ItemRecorder, +) { + var lazyListScope: LazyListScope? = null + internal set + + fun stickyHeaderWithRecord( + key: Any? = null, + contentType: Any? = null, + content: @Composable LazyItemScope.(Int) -> Unit + ) { + lazyListScope?.let { scope -> + recorder.record(key) + scope.stickyHeader( + key = key, + contentType = contentType, + content = content + ) + } + } + + 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 + ) + } + } + + 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 { + private val keys = mutableStateListOf() + 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 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/extension/LazyListAnimateScroller.kt b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt index 5df1856f9..8c8025403 100644 --- a/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt +++ b/component/src/main/java/com/lalilu/component/extension/LazyListAnimateScroller.kt @@ -1,137 +1,145 @@ 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 +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.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 -import kotlin.random.Random class LazyListAnimateScroller internal constructor( - private val keysKeeper: () -> Collection, + private val scope: CoroutineScope, 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 keys: () -> Collection, + private val enable: () -> Boolean = { true }, + private val defaultAnimationSpec: AnimationSpec = spring(stiffness = Spring.StiffnessVeryLow), ) { - private val keyEvent: MutableSharedFlow = MutableSharedFlow(1) - private var exactAnimation: Boolean = false - private val animator: SpringAnimation = springAnimationOf( - getter = { currentValue.floatValue }, - setter = { - deltaValue.floatValue = it - currentValue.floatValue - currentValue.floatValue = it - }, - finalPosition = 0f - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_VERY_LOW - }.addEndListener { animation, canceled, value, velocity -> - if (!canceled) { - targetRange.value = IntRange.EMPTY - } + /** + * 滚动任务,用于缓存每一次的主动滚动 + * + * @param key 目标元素key + * @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 offset: (LazyListItemInfo) -> Int = { 0 } + ) { + var isRectified = false + var isFinished = false + var targetIndex = -1 } - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - internal suspend fun startLoop(scope: CoroutineScope) = withContext(scope.coroutineContext) { - snapshotFlow { targetValue.floatValue } - .onEach { animator.animateToFinalPosition(it) } - .launchIn(this) + private val animation by lazy { Animatable(0f, Float.VectorConverter) } + private var targetRange: IntRange = IntRange(0, 0) + private val sizeMap = mutableMapOf() + private var task: ScrollTask? = null + /** + * 启动循环任务,用于监听可见元素列表的变化,并计算目标元素的偏移量 + */ + 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 - } + fun animateTo( + key: Any, + animationSpec: AnimationSpec? = null, + onEnd: (Boolean) -> Unit = {}, + isStickyHeader: (LazyListItemInfo) -> Boolean = { false }, + offset: (LazyListItemInfo) -> Int = { 0 } + ) = animateTo( + ScrollTask( + key = key, + onEnd = onEnd, + animationSpec = animationSpec, + isStickyHeader = isStickyHeader, + offset = offset + ) + ) - return@mapLatest key - }.debounce(20L) - .collectLatest { key -> - if (key == null) return@collectLatest + fun animateTo(scrollTask: ScrollTask) { + task = scrollTask + calcAndStartAnimation(scrollTask) + } - val index = keysKeeper().indexOfFirst { it == key } - if (index == -1) return@collectLatest // 元素不存在keys列表中,则不进行滚动 + private fun calcAndStartAnimation(task: ScrollTask) { + // 1. 从当前可见元素直接查找offset (准确值) + // get the offset directly from the visibleItemsInfo + val targetItem = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == task.key } - // 2. 使用实时维护的sizeMap查找并计算目标元素的offset (非准确值) - // Use the real-time maintained sizeMap to find and calculate the offset of the target element - scrollTo(index) + val targetOffset = targetItem?.let { item -> + // 若非StickyHeader,则其offset即为准确的滚动位移值 + if (!task.isStickyHeader(item)) { + return@let item.offset } - } - fun animateTo(key: Any) { - keyEvent.tryEmit(key) - } + // 若为StickyHeader则使用其下一个元素的offset - 当前元素的size计算获取 + listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == (item.index + 1) } + ?.let { it.offset - item.size - listState.layoutInfo.mainAxisItemSpacing } + } - private fun doScroll( - targetOffset: Float, - isExactScroll: Boolean = false - ) { - 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 + // 若获取到offset,则直接进行滚动 + targetOffset?.let { offset -> + doScroll(offset.toFloat() + task.offset(targetItem)) + return + } + + // 若未获取到offset,则使用keys列表查找目标元素,并计算其offset + val index = keys().indexOfFirst { it == task.key } + task.targetIndex = index + + if (index == -1) { + this.task = null + return // 元素不存在keys列表中,则不进行滚动 + } + + // 2. 使用实时维护的sizeMap查找并计算目标元素的offset (非准确值) + // Use the real-time maintained sizeMap to find and calculate the offset of the target element + scrollTo(index) + return } - 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.value = minOf(firstVisibleIndex, index)..maxOf(firstVisibleIndex, index) + 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 - if (!isActive) return@withContext // 计算目标距离,若未缓存有相应位置的值,则计算使用平均值 // 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 @@ -141,46 +149,100 @@ class LazyListAnimateScroller internal constructor( // 使用非准确值进行滚动 // use the non-accurate value for scrolling - if (!isActive) return@withContext - doScroll(offsetTemp * forwardMultiple, false) + doScroll(offsetTemp * forwardMultiple) + } + + private fun doScroll(targetOffset: Float) = task?.apply { + scope.launch { + // 获取上一次滚动时最终的滚动速度 + val oldVelocity = animation.velocity + animation.snapTo(0f) + var canceled = false + + var lastValue = 0f + animation.animateTo( + targetValue = targetOffset, + animationSpec = animationSpec ?: defaultAnimationSpec, + initialVelocity = oldVelocity + ) { + val dy = value - lastValue + lastValue = value + + scope.launch { + try { + if (!canceled && enable()) { + listState.scroll { scrollBy(dy) } + } + } catch (e: Exception) { + if (e is CancellationException) { + canceled = true + animation.stop() + } + } + } + + if (!isRectified && !canceled && targetIndex != -1 && + isItemVisible(this@apply, targetIndex) + ) { + // 更新阻止滚动继续的标志 + canceled = true + // 触发计算新的目标元素偏移量 + calcAndStartAnimation(this@apply) + // 标记已纠正,避免无限重复调用计算逻辑 + isRectified = true + } + } + + if (!isFinished && targetIndex != -1) { + // 触发计算新的目标元素偏移量 + calcAndStartAnimation(this@apply) + // 标记已纠正,避免无限重复调用计算逻辑 + isFinished = true + } + } + } - println("[calculate target]: ${targetValue.floatValue} -> range: [${targetRange.value.first} -> ${targetRange.value.last}]") + 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 fun rememberLazyListAnimateScroller( listState: LazyListState, + keys: () -> Collection = { emptyList() }, + defaultAnimationSpec: AnimationSpec = spring( + stiffness = Spring.StiffnessVeryLow, + visibilityThreshold = 0.001f + ), 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( + scope = scope, + keys = keys, listState = listState, - currentValue = currentValue, - targetValue = targetValue, - deltaValue = deltaValue, - targetRange = targetRange, - sizeMap = sizeMap, - keysKeeper = keysKeeper + enable = { enableAnimation.value }, + defaultAnimationSpec = defaultAnimationSpec, ) } - 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/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/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/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..4ec0d18fd --- /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/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) + ) + } + } + } +} 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..c4e3dbef9 --- /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 LumoTheme { + val colors: Colors + @ReadOnlyComposable @Composable + get() = LocalColors.current + + val typography: Typography + @ReadOnlyComposable @Composable + get() = LocalTypography.current +} + +@Composable +fun LumoTheme( + 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 LumoTheme.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/components/Slider.kt b/component/src/main/java/com/lalilu/component/lumo/components/Slider.kt new file mode 100644 index 000000000..891999448 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/lumo/components/Slider.kt @@ -0,0 +1,438 @@ +package com.lalilu.component.lumo.components + +import androidx.annotation.IntRange +import androidx.compose.foundation.background +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.Spacer +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.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.nomanr.composables.slider.BasicRangeSlider +import com.nomanr.composables.slider.BasicSlider +import com.nomanr.composables.slider.RangeSliderState +import com.nomanr.composables.slider.SliderColors +import com.nomanr.composables.slider.SliderState +import com.lalilu.component.lumo.LumoTheme +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun Slider( + value: Float, + onValueChange: (Float) -> 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(), + ) + } + } + } +} 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/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/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..22c276485 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/AppRouter.kt @@ -0,0 +1,114 @@ +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 +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.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 class PopUtil(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 DefaultHandler = NavHandler { navigator, 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) + is NavIntent.PopUtil -> navigator.popUntil { intent.screen == it } + NavIntent.None -> {} + } +} + +object AppRouter : CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() + private val sharedFlow = MutableSharedFlow() + private var handler: NavHandler = DefaultHandler + private val interceptors = mutableListOf( + DefaultInterceptorForTabScreen, + ) + + 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() + } + + fun intent(intent: NavIntent) = launch { + sharedFlow.emit(intent) + } + + fun intent(block: AppRouter.() -> NavIntent?) = launch { + this@AppRouter.block()?.let { sharedFlow.emit(it) } + } + + fun route(baseUrl: String): Request = 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 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? = + runCatching { KRouter.route(baseUrl, params) } + .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/CustomTransition.kt b/component/src/main/java/com/lalilu/component/navigation/CustomTransition.kt new file mode 100644 index 000000000..94fa3b7ae --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/CustomTransition.kt @@ -0,0 +1,52 @@ +package com.lalilu.component.navigation + +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 + +@OptIn(ExperimentalVoyagerApi::class) +@Composable +fun CustomTransition( + modifier: Modifier = Modifier, + navigator: Navigator, + animationSpec: FiniteAnimationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ), + content: @Composable (AnimatedVisibilityScope.(Screen) -> Unit) = { + navigator.saveableState("transition", it) { it.Content() } + } +) { + CustomScreenTransition( + navigator = navigator, + modifier = modifier, + 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/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..1cb8ea9db --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/EmptyScreen.kt @@ -0,0 +1,17 @@ +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 { + private fun readResolve(): Any = EmptyScreen + + @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/GlobalNavigator.kt b/component/src/main/java/com/lalilu/component/navigation/GlobalNavigator.kt deleted file mode 100644 index 0659d7f9c..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: SheetNavigator? = null - ) - - /** - * 展示一些歌曲 - */ - fun showSongs( - mediaIds: List, - title: String? = null, - navigator: SheetNavigator? = null - ) - - /** - * 跳转至某页面 - * - * [screen] 目标页面 - * [singleTop] 是否替换栈顶的相同类型的页面 - * [navigator] 执行操作的导航器 - */ - fun navigateTo( - screen: Screen, - singleTop: Boolean = true, - navigator: SheetNavigator? = null - ) - - fun goBack( - navigator: SheetNavigator? = null - ) -} \ 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..f89aa860e --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/HostNavigator.kt @@ -0,0 +1,54 @@ +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 + +@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, enhanceSheetState) { + AppRouter.bind(navigator) { + // 尝试跳转的时候若底部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 + } + ) { + when (enhanceSheetState) { + is EnhanceBottomSheetState -> navigator.pop() + is EnhanceModalSheetState -> if (!navigator.pop()) enhanceSheetState.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..e6a8f6c35 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationContext.kt @@ -0,0 +1,15 @@ +package com.lalilu.component.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.Navigator + +@Composable +fun Navigator.previousScreen(): State { + return remember(this) { + derivedStateOf { items.getOrNull(items.size - 2) } + } +} diff --git a/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt new file mode 100644 index 000000000..2887de03f --- /dev/null +++ b/component/src/main/java/com/lalilu/component/navigation/NavigationSmartBar.kt @@ -0,0 +1,120 @@ +package com.lalilu.component.navigation + +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.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +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.material.MaterialTheme +import androidx.compose.runtime.Composable +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.unit.dp +import cafe.adriel.voyager.navigator.LocalNavigator +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 +import com.lalilu.component.smartbar.NavigateCommonBar +import com.lalilu.component.smartbar.NavigateTabBar + + +private sealed interface NavigationBarType { + data object TabBar : NavigationBarType + data object CommonBar : NavigationBarType + data class NormalBar(val barComponent: ScreenBarComponent) : NavigationBarType +} + +@Composable +fun NavigationSmartBar( + modifier: Modifier = Modifier, +) { + val currentScreen = LocalNavigator.current + ?.lastItemOrNull + + val mainContent = (currentScreen as? ScreenBarFactory)?.content() + val navigationBar: NavigationBarType = remember(mainContent, currentScreen) { + when { + mainContent != null -> NavigationBarType.NormalBar(mainContent) + currentScreen is TabScreen -> NavigationBarType.TabBar + else -> NavigationBarType.CommonBar + } + } + + AnimatedContent( + modifier = modifier + .fillMaxWidth() + .imePadding(), + transitionSpec = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) togetherWith slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) + }, + contentAlignment = Alignment.BottomCenter, + targetState = navigationBar, + label = "" + ) { item -> + Box( + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { detectTapGestures() } + .background(MaterialTheme.colors.background.copy(0.95f)) + .navigationBarsPadding() + .height(56.dp) + ) { + when (item) { + is NavigationBarType.NormalBar -> { + item.barComponent.content() + } + + 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 }, + tabScreens = { tabScreens }, + onSelectTab = { AppRouter.intent(NavIntent.Jump(it)) } + ) + } + + is NavigationBarType.CommonBar -> { + val previousScreen = LocalNavigator.current + ?.previousScreen() + ?.value + + val previousTitle = (previousScreen as? ScreenInfoFactory) + ?.provideScreenInfo() + ?.title?.invoke() + ?: "返回" + + NavigateCommonBar( + modifier = Modifier.fillMaxHeight(), + previousTitle = previousTitle, + currentScreen = currentScreen + ) + } + } + } + } +} 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/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 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..af44f6191 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/override/AnchoredDraggable.kt @@ -0,0 +1,904 @@ +/* + * 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 +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 +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 +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 + if (javaClass != other?.javaClass) return false + + 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..b740726de --- /dev/null +++ b/component/src/main/java/com/lalilu/component/override/ModalBottomSheetLayout.kt @@ -0,0 +1,634 @@ +/* + * 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 androidx.compose.runtime.Composable +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.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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * 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, + enableBottomSheetMode: () -> Boolean = { true }, + confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true }, + internal val animationSpec: AnimationSpec = ModalBottomSheetDefaults.AnimationSpec, + internal val isSkipHalfExpanded: Boolean = false, +) { + + val anchoredDraggableState = AnchoredDraggableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmValueChange = confirmValueChange, + positionalThreshold = { + with(density) { + ModalBottomSheetPositionalThreshold.toPx() + } + }, + velocityThreshold = { with(density) { ModalBottomSheetVelocityThreshold.toPx() } } + ) + + val enabled: Boolean by derivedStateOf(enableBottomSheetMode) + + /** + * 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 != ModalBottomSheetValue.Hidden + + internal val hasHalfExpandedState: Boolean + get() = anchoredDraggableState.anchors.hasAnchorFor(ModalBottomSheetValue.HalfExpanded) + + init { + if (isSkipHalfExpanded) { + require(initialValue != ModalBottomSheetValue.HalfExpanded) { + "The initial value must not be set to ModalBottomSheetValue.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(ModalBottomSheetValue.Expanded) + val targetValue = when (currentValue) { + ModalBottomSheetValue.Hidden -> if (hasHalfExpandedState) ModalBottomSheetValue.HalfExpanded else ModalBottomSheetValue.Expanded + else -> if (hasExpandedState) ModalBottomSheetValue.Expanded else ModalBottomSheetValue.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(ModalBottomSheetValue.HalfExpanded) + } + + /** + * Hide the bottom sheet with animation and suspend until it if fully hidden or animation has + * been cancelled. + */ + 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(ModalBottomSheetValue.Expanded)) { + return + } + animateTo(ModalBottomSheetValue.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, + enableBottomSheetMode: () -> Boolean, + confirmValueChange: (ModalBottomSheetValue) -> Boolean, + skipHalfExpanded: Boolean, + density: Density + ): Saver = Saver( + save = { it.currentValue }, + restore = { + ModalBottomSheetState( + initialValue = it, + density = density, + enableBottomSheetMode = enableBottomSheetMode, + 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, + enableBottomSheetMode: () -> Boolean = { true }, + 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 = ModalBottomSheetState.Saver( + density = density, + animationSpec = animationSpec, + enableBottomSheetMode = enableBottomSheetMode, + skipHalfExpanded = skipHalfExpanded, + confirmValueChange = confirmValueChange + ) + ) { + ModalBottomSheetState( + density = density, + initialValue = initialValue, + animationSpec = animationSpec, + enableBottomSheetMode = enableBottomSheetMode, + 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(ModalBottomSheetValue.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(ModalBottomSheetValue.Hidden)) { + scope.launch { sheetState.hide() } + } + }, + visible = sheetState.anchoredDraggableState.targetValue != ModalBottomSheetValue.Hidden + ) + } + Surface( + Modifier + .align(Alignment.TopCenter) // We offset from the top so we'll center from there + .fillMaxWidth() + .then( + if (sheetState.enabled) { + Modifier + .widthIn(max = MaxModalBottomSheetWidth) + .then( + if (sheetGesturesEnabled) { + Modifier.nestedScroll( + remember(sheetState.anchoredDraggableState, orientation) { + ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + state = sheetState.anchoredDraggableState, + orientation = orientation, + scope = scope + ) + } + ) + } 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 + } + } + } + } + } else Modifier + ) + } 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 { + ModalBottomSheetValue.Hidden at fullHeight + val halfHeight = fullHeight / 2f + if (!sheetState.isSkipHalfExpanded && sheetSize.height > halfHeight) { + ModalBottomSheetValue.HalfExpanded at halfHeight + } + if (sheetSize.height != 0) { + ModalBottomSheetValue.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) { + ModalBottomSheetValue.Hidden -> ModalBottomSheetValue.Hidden + ModalBottomSheetValue.HalfExpanded, ModalBottomSheetValue.Expanded -> { + val hasHalfExpandedState = + newAnchors.hasAnchorFor(ModalBottomSheetValue.HalfExpanded) + val newTarget = if (hasHalfExpandedState) { + ModalBottomSheetValue.HalfExpanded + } else if (newAnchors.hasAnchorFor(ModalBottomSheetValue.Expanded)) { + ModalBottomSheetValue.Expanded + } else { + ModalBottomSheetValue.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, + 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() + } 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 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..f8ae5d713 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,6 @@ 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 @@ -10,43 +10,26 @@ 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 -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 = {}, + onFinishedUpdate: (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) @@ -55,7 +38,7 @@ fun SettingProgressSeekBar( .fillMaxWidth() .clickable( interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = { } ) .padding(horizontal = 20.dp, vertical = 10.dp), @@ -68,13 +51,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 = { onFinishedUpdate(tempValue.floatValue) } ) if (subTitle != null) { Text( 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 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..b040380b9 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 @@ -11,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 @@ -73,7 +73,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..22a489db0 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,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 @@ -13,11 +13,8 @@ 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.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember @@ -28,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( @@ -60,7 +58,6 @@ fun SettingSwitcher( enableContentClickable = enableContentClickable ) -@OptIn(ExperimentalFoundationApi::class) @Composable fun SettingSwitcher( modifier: Modifier = Modifier, @@ -103,9 +100,6 @@ fun SettingSwitcher( checked = state(), onCheckedChange = { onStateUpdate(it) }, interactionSource = interaction, - colors = SwitchDefaults.colors( - checkedThumbColor = textColor.multiply(0.7f) - ) ) } ) @@ -127,7 +121,7 @@ fun SettingSwitcher( .enableFor(enable = { enableContentClickable }) { clickable( interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = onContentStartClick ) } 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..13a77ae94 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/smartbar/NavigateCommonBar.kt @@ -0,0 +1,176 @@ +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.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() + ) + + AnimatedContent( + modifier = modifier.fillMaxHeight(), + transitionSpec = { fadeIn() togetherWith fadeOut() }, + targetState = previousTitle to screenActions, + label = "ExtraActions" + ) { (title, actions) -> + 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 = { + if (onBackPress != null) { + onBackPress() + } else { + onBackPressedDispatcher?.onBackPressed() + } + } + ) { + Icon( + imageVector = previousIcon, + tint = MaterialTheme.colors.onBackground, + contentDescription = null + ) + Text( + text = title, + fontSize = 14.sp + ) + } + + SubcomposeLayout( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + ) { 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/smartbar/NavigateTabBar.kt b/component/src/main/java/com/lalilu/component/smartbar/NavigateTabBar.kt new file mode 100644 index 000000000..e03516562 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/smartbar/NavigateTabBar.kt @@ -0,0 +1,127 @@ +package com.lalilu.component.smartbar + +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.graphics.vector.ImageVector +import androidx.compose.ui.layout.FixedScale +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 + + +@Composable +fun NavigateTabBar( + modifier: Modifier = Modifier, + currentScreen: () -> Screen?, + tabScreens: () -> List, + onSelectTab: (TabScreen) -> Unit = {} +) { + val defaultTitle = stringResource(id = R.string.empty_screen_no_items) + + Row( + modifier = modifier + .height(52.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + tabScreens().forEach { + val screenInfo = (it as? ScreenInfoFactory)?.provideScreenInfo() + val title = screenInfo?.title?.invoke() + + NavigateItem( + modifier = Modifier.weight(1f), + title = { title ?: defaultTitle }, + icon = { screenInfo?.icon }, + isSelected = { currentScreen() === it }, + onClick = { onSelectTab(it) } + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun NavigateItem( + modifier: Modifier = Modifier, + 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 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 + ) { + 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 = title(), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + letterSpacing = 0.1.sp, + color = MaterialTheme.colors.onBackground + ) + } + } + } + } +} \ No newline at end of file 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..a8b74f7d0 --- /dev/null +++ b/component/src/main/java/com/lalilu/component/smartbar/component/ActionItem.kt @@ -0,0 +1,343 @@ +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 + + +@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 -> { + 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 + ) { + 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.Center + ) { + Text( + text = title, + fontSize = 14.sp, + lineHeight = 14.sp, + color = color, + fontWeight = FontWeight.Medium + ) + + 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, + lineHeight = 10.sp, + color = color.copy(0.5f), + ) + } + } + } 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 = "" + ) + + 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)) + } + + 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/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/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 8fbe9d428..000000000 --- a/component/src/main/java/com/lalilu/component/viewmodel/PlayingViewModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -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() { - /** - * 综合播放操作 - * - * @param mediaId 目标歌曲的ID - * @param mediaIds 歌曲ID列表 - * @param playOrPause 当前正在播放则暂停,暂停则开始播放 - * @param addToNext 是否在播放前将该歌曲移动到下一首播放的位置 - */ - abstract fun play( - mediaId: String, - mediaIds: List? = null, - playOrPause: Boolean = false, - addToNext: Boolean = false, - ) - - 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/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 c98d2247d..000000000 --- a/component/src/main/java/com/lalilu/component/viewmodel/SongsViewModel.kt +++ /dev/null @@ -1,155 +0,0 @@ -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 { - return context.getSharedPreferences( - context.packageName + "_SONGS", - Application.MODE_PRIVATE - ) - } -} - -@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/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/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/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 c209e78ec..000000000 Binary files a/extension/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ 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 4f0f1d64e..000000000 Binary files a/extension/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/extension/src/main/res/mipmap-xhdpi/ic_launcher.webp b/extension/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070f..000000000 Binary files a/extension/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ 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 28d4b77f9..000000000 Binary files a/extension/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/extension/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/extension/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427e..000000000 Binary files a/extension/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/gradle.properties b/gradle.properties index 7e90671af..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 @@ -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 bc3382a91..ebdcff21b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,50 +1,47 @@ [versions] -agp_version = "8.2.0" -kotlin_version = "1.9.20" -coroutines_version = "1.7.3" -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_version = "2024.01.00" -compose_compiler_version = "1.5.5" +compile_version = "35" +min_sdk_version = "21" + +agp_version = "8.6.1" +kotlin_version = "2.1.0" +ksp_version = "2.1.0-1.0.29" + +koin_version = "4.0.1" +koin_ksp_version = "1.4.0" +compose_bom_alpha_version = "2025.03.00" +compose_bom_version = "2025.03.00" accompanist_version = "0.32.0" -voyager = "1.0.0-rc10" -lottie-compose = "5.2.0" +voyager = "1.1.0-beta03" +lottie-compose = "6.6.0" -kotlinpoet = "1.14.2" -coil_version = "2.4.0" +coil3_version = "3.1.0" utilcodex_version = "1.31.1" # androidx -appcompat = "1.6.1" -core-ktx = "1.12.0" +appcompat = "1.7.0" +core-ktx = "1.15.0" palette-ktx = "1.0.0" -dynamicanimation-ktx = "1.0.0-alpha03" -startup-runtime = "1.2.0-alpha02" -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" -room_version = "2.5.2" +dynamicanimation-ktx = "1.0.0-beta01" +startup-runtime = "1.2.0" +activity-compose = "1.10.1" +room_version = "2.6.1" media = "1.7.0" -flyjingfish-aop = "1.3.6" +media3 = "1.6.0" +gson = "2.11.0" +flyjingfish-aop = "1.9.7" +paging_version = "3.3.6" +krouter_version = "0.0.3" +xmlutil = "0.90.3" [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.ref = "serialization_json_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-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-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" } @@ -53,7 +50,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" } @@ -61,10 +58,9 @@ 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-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-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" } @@ -74,59 +70,75 @@ 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" } # coil # 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" } + +# 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" } 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" } - -# 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" } +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" } +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" } 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-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" } 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" } +krouter-plugin = { id = "io.github.cy745.KRouter.plugin", version.ref = "krouter_version" } +lumo = { id = "com.nomanr.plugin.lumo", version = "1.2.1" } [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", @@ -137,18 +149,29 @@ compose = [ "compose-foundation", "compose-foundation-layout", "compose-material", - # "compose-material3", + "compose-material3", "compose-material3-window-size", "compose-runtime-livedata", ] voyager = [ "voyager-navigator", + "voyager-lifecycle-kmp", "voyager-tabNavigator", "voyager-transitions", - "voyager-androidx", "voyager-koin" ] flyjingfish-aop = [ "flyjingfish-aop-core", "flyjingfish-aop-annotation" +] +coil3 = [ + "coil3-android", + "coil3-compose", + "coil3-okhttp" +] +koin = [ + "koin-android", + "koin-compose", + "koin-startup", + "koin-annotations" ] \ No newline at end of file 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..ec88e16d6 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,11 +28,13 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } 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/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/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt index ac0d922ba..b17bbd01b 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreen.kt @@ -1,110 +1,170 @@ 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.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.koin.getScreenModel -import com.lalilu.component.Songs +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.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 +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.component.extension.screenVM 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.lalbum.component.AlbumCoverCard -import com.lalilu.lmedia.LMedia -import com.lalilu.lmedia.entity.LAlbum -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.launch +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 org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +@Destination("/pages/albums/detail") data class AlbumDetailScreen( private val albumId: String -) : DynamicScreen() { +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory { + 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 = { stringResource(id = R.string.album_screen_title) } + ) + } @Composable - override fun Content() { - val albumDetailSM = getScreenModel() + override fun provideScreenActions(): List { + val vm = screenVM( + parameters = { parametersOf(albumId) } + ) + 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 = screenVM( + 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 DynamicScreen.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)) } + ) - 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 = { } - ) - } + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(AlbumDetailAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(AlbumDetailAction.SearchFor(it)) } + ) - 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..2e84fc329 --- /dev/null +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumDetailScreenContent.kt @@ -0,0 +1,238 @@ +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 +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 +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.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 +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.state +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.action.MediaControl +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 favouriteIds = state("favourite_ids", emptyList()) + val stickyHeaderContentType = remember { "group" } + 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(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = Color.White.copy(0.3f), + shape = RoundedCornerShape(8.dp) + ) + .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", + 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) }, + isFavour = { favouriteIds.value.contains(it.id) }, + onClick = { + if (isSelecting()) { + onSelect(it) + } else { + MediaControl.playWithList( + mediaIds = list.map(LSong::id), + mediaId = it.id + ) + } + }, + onLongClick = { + 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/screen/AlbumsScreen.kt b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt index 6b2d48edc..6f2ff7406 100644 --- a/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreen.kt @@ -1,164 +1,117 @@ 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.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -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.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.koin.getScreenModel -import com.lalilu.component.LLazyVerticalStaggeredGrid -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.GlobalNavigator -import com.lalilu.component.viewmodel.IPlayingViewModel -import com.lalilu.component.viewmodel.SongsSp +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.screen.Screen +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.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSortPanelDialog +import com.lalilu.component.extension.DialogWrapper +import com.lalilu.component.extension.screenVM 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 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 +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 org.koin.core.parameter.parametersOf +@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, ScreenActionFactory, ScreenBarFactory { @Composable - override fun Content() { - val albumsSM = getScreenModel() - - LaunchedEffect(Unit) { - albumsSM.updateAlbumsId(albumsId) - } - - AlbumsScreen( - albumsSM = albumsSM, + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.album_screen_title) }, + icon = RemixIcon.Media.albumFill ) } -} - -@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 DynamicScreen.AlbumsScreen( - title: String = "全部专辑", - albumsSM: AlbumsScreenModel, - playingVM: IPlayingViewModel = koinInject(), - sortFor: String = Sortable.SORT_FOR_ALBUMS, -) { - val albumsState = albumsSM.albums.collectAsLoadingState() - val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val navigator = koinInject() - - LoadingScaffold( - targetState = albumsState - ) { albums -> - LLazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalItemSpacing = 10.dp, - contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = statusBarPadding) - ) { - 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 provideScreenActions(): List { + val albumsVM = screenVM( + parameters = { parametersOf(albumsId) } + ) + val state by albumsVM.state - items( - items = albums, - key = { it.id }, - contentType = { LAlbum::class } - ) { item -> - AlbumCard( - album = { item }, - isPlaying = { - playingVM.isItemPlaying { playing -> - playing.let { it as? LSong } - ?.let { it.album?.id == item.id } - ?: false - } + return remember { + 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) } + ), + 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 }, - showTitle = { albumsSM.showTitle.value }, - onClick = { - navigator.navigateTo(AlbumDetailScreen(item.id)) + onAction = { + albumsVM.intent(AlbumsAction.ToggleSearcherPanel) + DialogWrapper.dismiss() } - ) - } + ), + ) } } -// 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() -// ) { -// } + @Composable + override fun Content() { + val vm = screenVM( + parameters = { parametersOf(albumsId) } + ) + val state by vm.state + val albums by vm.albums + + SongsSortPanelDialog( + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(AlbumsAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(AlbumsAction.SelectSortAction(it)) } + ) + + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(AlbumsAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(AlbumsAction.SearchFor(it)) } + ) + + AlbumsScreenContent( + eventFlow = vm.eventFlow(), + title = { "全部专辑" }, + albums = { albums }, + showText = { state.showText } + ) + } } \ 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..ad621a167 --- /dev/null +++ b/lalbum/src/main/java/com/lalilu/lalbum/screen/AlbumsScreenContent.kt @@ -0,0 +1,121 @@ +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +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.ui.Modifier +import androidx.compose.ui.unit.dp +import com.blankj.utilcode.util.LogUtils +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 +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.Compact + 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( + modifier = Modifier.animateItem(), + text = group.text + ) + } + } + + items( + items = list, + key = { it.id }, + contentType = { LAlbum::class } + ) { item -> + AlbumCard( + modifier = Modifier.animateItem(), + album = { item }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, + showTitle = showText, + onClick = { + AppRouter.intent( + NavIntent.Push( + AlbumDetailScreen(item.id) + ) + ) + } + ) + } + } + + 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..b9694e440 --- /dev/null +++ b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumDetailVM.kt @@ -0,0 +1,163 @@ +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 +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.distinctUntilChangedBy +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 + + +@Stable +@Immutable +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, +) { + val distinctKey: Int = + albumId.hashCode() + searchKeyWord.hashCode() + selectedSortAction.hashCode() + + 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() + .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( + 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") + } + } + } +} + 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..694c33080 --- /dev/null +++ b/lalbum/src/main/java/com/lalilu/lalbum/viewModel/AlbumsVM.kt @@ -0,0 +1,128 @@ +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 +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.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 + +@Stable +@Immutable +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, +) { + val distinctKey: Int = + albumIds.hashCode() + searchKeyWord.hashCode() + selectedSortAction.hashCode() + + @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() + .distinctUntilChangedBy { it.distinctKey } + .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 diff --git a/lartist/build.gradle.kts b/lartist/build.gradle.kts index 5c7cde26a..fb5057153 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,11 +28,13 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } 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 1240711f0..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,48 +22,44 @@ 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 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 -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() @@ -90,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 ) @@ -101,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 ) } @@ -147,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 caae89fba..a1d51e3d3 100644 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreen.kt @@ -1,123 +1,172 @@ 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.getValue 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 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 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.GlobalNavigator +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.screenVM 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 -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.launch -import org.koin.compose.koinInject +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 +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 + +@Destination("/pages/artist/detail") data class ArtistDetailScreen( private val artistName: String -) : DynamicScreen() { +) : Screen, ScreenInfoFactory, ScreenActionFactory, ScreenBarFactory, ScreenType.List { 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() + override fun provideScreenActions(): List { + val vm = screenVM( + parameters = { parametersOf(artistName) } + ) + val state by vm.state - LaunchedEffect(Unit) { - artistDetailSM.updateArtistName(artistName) + 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) } + ), + ) } - - ArtistDetail(artistDetailSM = artistDetailSM) } -} -@OptIn(ExperimentalCoroutinesApi::class) -class ArtistDetailScreenModel : ScreenModel { - private val artistName = MutableStateFlow(null) - val artist = artistName.flatMapLatest { LMedia.getFlow(it) } + @Composable + override fun Content() { + val vm = screenVM( + parameters = { parametersOf(artistName) } + ) + val songs by vm.songs + val state by vm.state + val artist by vm.artist + + SongsSortPanelDialog( + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(ArtistDetailAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(ArtistDetailAction.SelectSortAction(it)) } + ) - fun updateArtistName(artistName: String) = screenModelScope.launch { - this@ArtistDetailScreenModel.artistName.emit(artistName) - } -} + SongsHeaderJumperDialog( + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(ArtistDetailAction.HideJumperDialog) }, + items = { songs.keys }, + onSelectItem = { vm.intent(ArtistDetailAction.LocaleToGroupItem(it)) } + ) -@Composable -private fun DynamicScreen.ArtistDetail( - artistDetailSM: ArtistDetailScreenModel -) { - val navigator = koinInject() - val artistState = artistDetailSM.artist.collectAsLoadingState() + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(ArtistDetailAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(ArtistDetailAction.SearchFor(it)) } + ) - LoadingScaffold(targetState = artistState) { artist -> - val relateArtist = remember { - derivedStateOf { - artist.songs.map { it.artists } - .flatten() - .toSet() - .filter { it.id != artist.name } - } - } + 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) } + ) + ) + ) - 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 = { - navigator.navigateTo( - screen = ArtistDetailScreen(it.id), - singleTop = false - ) - } - ) - } - } - } + ArtistDetailScreenContent( + 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 new file mode 100644 index 000000000..cf80d200f --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/screen/ArtistDetailScreenContent.kt @@ -0,0 +1,243 @@ +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.LaunchedEffect +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.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.component.state +import com.lalilu.lartist.component.ArtistCard +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.action.MediaControl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.emptyFlow + +@Composable +internal fun ArtistDetailScreenContent( + 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 = {}, + onClickGroup: (GroupIdentity) -> Unit = {} +) { + val listState = rememberLazyListState() + 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 + ) + + val relateArtist = remember(artist) { + artist?.songs?.map { it.artists } + ?.flatten() + ?.toSet() + ?.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() + .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 = 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.id }, + contentType = { it::class.java } + ) { + SongCard( + song = { it }, + isSelected = { isSelected(it) }, + isFavour = { favouriteIds.value.contains(it.id) }, + onClick = { + if (isSelecting()) { + onSelect(it) + } else { + MediaControl.playWithList( + mediaIds = list.map(LSong::id), + mediaId = it.id + ) + } + }, + onLongClick = { + if (isSelecting()) { + onSelect(it) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", it.id) + .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() }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, + onClick = { AppRouter.intent(NavIntent.Push(ArtistDetailScreen(item.id))) } + ) + } + } + } + + smartBarPadding() + } +} \ No newline at end of file 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 76fef8eb4..000000000 --- a/lartist/src/main/java/com/lalilu/lartist/screen/ArtistsScreen.kt +++ /dev/null @@ -1,121 +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.GlobalNavigator -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 navigator = 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 = { - navigator.navigateTo(ArtistDetailScreen(item.name)) - } - ) - } - } - } -} \ 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..b49b3ec89 --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreen.kt @@ -0,0 +1,163 @@ +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.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.component.extension.screenVM +import com.lalilu.lartist.R +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 +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 + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.artist_screen_title) }, + icon = RemixIcon.UserAndFaces.userLine + ) + } + + @Composable + override fun provideScreenActions(): List { + val vm = screenVM() + val state by vm.state + + return remember { + listOf( + ScreenAction.Static( + title = { "排序" }, + icon = { RemixIcon.Editor.sortDesc }, + color = { Color(0xFF1793FF) }, + onAction = { vm.intent(ArtistsAction.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(ArtistsAction.ToggleSearcherPanel) + DialogWrapper.dismiss() + } + ), + ScreenAction.Static( + title = { "定位当前播放所属" }, + icon = { RemixIcon.Design.focus3Line }, + color = { Color(0xFF8700FF) }, + onAction = { vm.intent(ArtistsAction.LocaleToPlayingItem) } + ), + ) + } + } + + @Composable + override fun Content() { + val vm = screenVM() + val state by vm.state + val artists by vm.artists + + SongsSortPanelDialog( + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(ArtistsAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(ArtistsAction.SelectSortAction(it)) } + ) + + SongsHeaderJumperDialog( + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(ArtistsAction.HideJumperDialog) }, + items = { artists.keys }, + onSelectItem = { vm.intent(ArtistsAction.LocaleToGroupItem(it)) } + ) + + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(ArtistsAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(ArtistsAction.SearchFor(it)) } + ) + + 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(artists.values.flatten()) } + ), + ScreenAction.Static( + title = { "取消全选" }, + icon = { RemixIcon.System.checkboxMultipleBlankLine }, + color = { Color(0xFFFF5100) }, + onAction = { vm.selector.clear() } + ), + ScreenAction.Static( + title = { "添加到播放列表" }, + icon = { RemixIcon.System.checkboxMultipleBlankLine }, + color = { Color(0xFF002FB9) }, + onAction = { + // TODO 选择歌手后将其列表下歌曲添加到播放列表 + ToastUtils.showShort("开发中,敬请期待") + } + ), + ) + ) + + ArtistsScreenContent( + 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 new file mode 100644 index 000000000..de2dfb24d --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/screen/artists/ArtistsScreenContent.kt @@ -0,0 +1,193 @@ +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.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.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.lartist.component.ArtistCard +import com.lalilu.lartist.screen.ArtistDetailScreen +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 kotlinx.coroutines.flow.emptyFlow + + +@Composable +internal fun ArtistsScreenContent( + 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 listState = rememberLazyListState() + val statusBar = WindowInsets.statusBars + val density = LocalDensity.current + val stickyHeaderContentType = remember { "group" } + val scroller = rememberLazyListAnimateScroller( + listState = listState, + keys = keys + ) + + LaunchedEffect(Unit) { + eventFlow.collectLatest { event -> + when (event) { + is ArtistsEvent.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 -> {} + } + } + } + + 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(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 = stickyHeaderContentType + ) { + 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() }, + isPlaying = { item.songs.any { MPlayer.isItemPlaying(it.id) } }, + onClick = { + if (isSelecting()) { + onSelect(item) + } else { + AppRouter.intent(NavIntent.Push(ArtistDetailScreen(item.id))) + } + } + ) + } + } + } + + smartBarPadding() + } + } +} \ 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..6307936d5 --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistDetailVM.kt @@ -0,0 +1,160 @@ +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 +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.distinctUntilChangedBy +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 + +@Stable +@Immutable +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, +) { + val distinctKey: Int = searchKeyWord.hashCode() + selectedSortAction.hashCode() + + 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() + .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( + 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/ArtistsVM.kt b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt new file mode 100644 index 000000000..c5ec379f9 --- /dev/null +++ b/lartist/src/main/java/com/lalilu/lartist/viewModel/ArtistsVM.kt @@ -0,0 +1,149 @@ +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 +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.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, + 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() + + @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() + .distinctUntilChangedBy { it.distinctKey } + .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/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/ldictionary/.gitignore b/ldictionary/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/ldictionary/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/ldictionary/src/main/AndroidManifest.xml b/ldictionary/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68a..000000000 --- a/ldictionary/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file 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/src/main/res/values/strings.xml b/ldictionary/src/main/res/values/strings.xml deleted file mode 100644 index b9b3c5cdb..000000000 --- a/ldictionary/src/main/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - Dictionary - \ 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/extension-core/.gitignore b/lfolder/.gitignore similarity index 100% rename from extension-core/.gitignore rename to lfolder/.gitignore diff --git a/ldictionary/build.gradle.kts b/lfolder/build.gradle.kts similarity index 65% rename from ldictionary/build.gradle.kts rename to lfolder/build.gradle.kts index 3fc26b174..9d79b4042 100644 --- a/ldictionary/build.gradle.kts +++ b/lfolder/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 + namespace = "com.lalilu.lfolder" + 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/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/extension-core/src/main/AndroidManifest.xml b/lfolder/src/main/AndroidManifest.xml similarity index 100% rename from extension-core/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/extension/src/main/res/values/strings.xml b/lfolder/src/main/res/values/strings.xml similarity index 53% rename from extension/src/main/res/values/strings.xml rename to lfolder/src/main/res/values/strings.xml index 7cd6a26f0..966dbd2b0 100644 --- a/extension/src/main/res/values/strings.xml +++ b/lfolder/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - Test + Folder \ No newline at end of file diff --git a/lhistory/build.gradle.kts b/lhistory/build.gradle.kts index dcd183d72..9a836c3f8 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,15 +33,21 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } 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/HistoryAnalyticsListener.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt new file mode 100644 index 000000000..82661583b --- /dev/null +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryAnalyticsListener.kt @@ -0,0 +1,157 @@ +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, + startTime = item.startTime + ) + } + } + + private fun setNewPlayingItem( + mediaId: String, + title: String, + isPlaying: Boolean = false + ) = scope.launch(Dispatchers.Main.immediate) { + val startTime = System.currentTimeMillis() + val unUsedHistory = historyRepo.getUnUsedPreSaveHistory(mediaId) + val primaryKey = unUsedHistory?.id ?: 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/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/HistoryPanel.kt b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt new file mode 100644 index 000000000..ec39ede28 --- /dev/null +++ b/lhistory/src/main/java/com/lalilu/lhistory/HistoryPanel.kt @@ -0,0 +1,101 @@ +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.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 +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 + + +@Named("history_panel") +@Single(binds = [LazyGridContent::class]) +class HistoryPanel : LazyGridContent { + + @Composable + override fun register(): LazyGridScope.() -> Unit { + 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.() { + // 若列表为空,则不显示 + if (items.isEmpty()) return + + items( + items = itemsWithReplace.value, + key = { it.id }, + contentType = { "History_item" }, + span = { + if (widthSizeClass != WindowWidthSizeClass.Compact) GridItemSpan(maxLineSpan / 2) + else GridItemSpan(maxLineSpan) + } + ) { item -> + SongCard( + modifier = Modifier + .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") + .with("mediaId", item.id) + .jump() + } + ) + } + } + } +} \ 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 60% rename from lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt rename to lhistory/src/main/java/com/lalilu/lhistory/SortActions.kt index 3ef2ff235..58c8ec82f 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/ExtendSortRule.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/SortActions.kt @@ -2,16 +2,20 @@ 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.java.KoinJavaComponent +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single -object SortRulePlayCount : - SortDynamicAction(titleRes = R.string.sort_preset_by_played_times) { - private val historyRepo: HistoryRepository by KoinJavaComponent.inject(HistoryRepository::class.java) +@Named("sort_rule_play_count") +@Single(binds = [ListAction::class]) +class SortRulePlayCount( + private val historyRepo: HistoryRepository +) : SortDynamicAction(titleRes = R.string.sort_preset_by_played_times) { override fun doSort( items: Flow>, @@ -20,17 +24,18 @@ object 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) } } } } -object SortRuleLastPlayTime : - SortDynamicAction(titleRes = R.string.sort_preset_by_last_play_time) { - - private val historyRepo: HistoryRepository by KoinJavaComponent.inject(HistoryRepository::class.java) +@Named("sort_rule_last_play_time") +@Single(binds = [ListAction::class]) +class SortRuleLastPlayTime( + private val historyRepo: HistoryRepository +) : SortDynamicAction(titleRes = R.string.sort_preset_by_last_play_time) { override fun doSort( items: Flow>, @@ -39,7 +44,7 @@ object 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/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/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..728bb5279 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,26 @@ package com.lalilu.lhistory.repository -import androidx.room.* +import androidx.paging.PagingSource +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, startTime = :startTime WHERE id = :id;") + fun updateHistory(id: Long, duration: Long, repeatCount: Int, startTime: Long) @Query("DELETE FROM m_history;") fun clear() @@ -29,18 +28,21 @@ 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? + @Query("SELECT * FROM m_history ORDER BY id DESC LIMIT 1") + fun getLatestHistory(): LHistory? + /** * 查询播放历史,去除重复的记录,只保留最近的一条,按照最近播放时间排序 */ @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 +53,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..5d1f999b9 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/repository/HistoryRepository.kt @@ -1,17 +1,16 @@ package com.lalilu.lhistory.repository +import androidx.paging.PagingSource 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 getUnUsedPreSaveHistory(mediaId: String): LHistory? + suspend fun preSaveHistory(history: LHistory): Long + suspend fun updateHistory(id: Long, duration: Long, repeatCount: Int, startTime: Long) 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 3e26b0c18..af42e6391 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 @@ -8,8 +9,11 @@ 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 +@Single(binds = [HistoryRepository::class]) class HistoryRepositoryImpl( private val historyDao: HistoryDao ) : HistoryRepository, CoroutineScope { @@ -27,30 +31,38 @@ class HistoryRepositoryImpl( .toCachedFlow() .also { it.launchIn(this) } - override fun saveHistory(vararg history: LHistory) { - launch { historyDao.save(history.toList()) } - } - - override fun saveHistories(history: List) { - launch { historyDao.save(history) } - } + override suspend fun getUnUsedPreSaveHistory(mediaId: String): LHistory? = + withContext(Dispatchers.IO) { + historyDao.getLatestHistory() + ?.takeIf { it.contentId == mediaId && it.duration <= 1000L } + } - override fun preSaveHistory(history: LHistory) { - launch { historyDao.save(history.copy(duration = -1L)) } + override suspend fun preSaveHistory(history: LHistory): Long = withContext(Dispatchers.IO) { + 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, + startTime: Long + ) { + historyDao.updateHistory( + id = id, + duration = duration, + repeatCount = repeatCount, + startTime = startTime + ) } override fun clearHistories() { 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 7d2d1b484..fbfdde4d4 100644 --- a/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt +++ b/lhistory/src/main/java/com/lalilu/lhistory/screen/HistoryScreen.kt @@ -1,70 +1,134 @@ 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.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 -import com.lalilu.lhistory.repository.HistoryRepository -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.mapLatest -import com.lalilu.component.R as ComponentR +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.compose.LazyPagingItems +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.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.System +import com.lalilu.remixicon.system.historyLine +import com.zhangke.krouter.annotation.Destination +import org.koin.compose.koinInject -data object HistoryScreen : DynamicScreen() { - override fun getScreenInfo(): ScreenInfo = ScreenInfo( - title = R.string.history_screen_title, - icon = ComponentR.drawable.ic_play_list_fill - ) +@Destination("/pages/history") +data object HistoryScreen : Screen, ScreenInfoFactory { + private fun readResolve(): Any = HistoryScreen + + @Composable + override fun provideScreenInfo(): ScreenInfo { + return remember { + ScreenInfo( + title = { "历史记录" }, + icon = RemixIcon.System.historyLine + ) + } + } @Composable override fun Content() { - val historySM = getScreenModel() + val historyVM = koinInject() + val items = historyVM.pager.collectAsLazyPagingItems() - HistoryScreen(historySM = historySM) + HistoryScreenContent( + items = items + ) } } -class HistoryScreenModel( - historyRepo: HistoryRepository -) : ScreenModel { - @OptIn(ExperimentalCoroutinesApi::class) - val mediaIds = historyRepo - .getHistoriesIdsMapWithLastTime() - .mapLatest { map -> - map.toList() - .sortedByDescending { it.second } - .map { it.first } - } -} - @Composable -private fun DynamicScreen.HistoryScreen( - historySM: HistoryScreenModel +private fun HistoryScreenContent( + items: LazyPagingItems ) { - val mediaIdsState = historySM.mediaIds.collectAsLoadingState() + val listState = rememberLazyListState() - LoadingScaffold( + LazyColumn( 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)) + 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] + + 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() } - }, - footerContent = {} - ) + ) + } + + 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 new file mode 100644 index 000000000..5c69caad9 --- /dev/null +++ b/lhistory/src/main/java/com/lalilu/lhistory/viewmodel/HistoryVM.kt @@ -0,0 +1,53 @@ +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 +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) +@Single +class HistoryVM( + val historyRepo: HistoryRepository +) : ViewModel() { + val historyState = historyRepo + .getHistoriesIdsMapWithLastTime() + .flatMapLatest { map -> + val ids = map.toList() + .sortedByDescending { it.second } + .map { it.first } + LMedia.flowMapBy(ids) + }.map { it.take(6) } + .toState(emptyList(), viewModelScope) + + val pager = Pager( + config = PagingConfig( + pageSize = 20, + enablePlaceholders = true, + ), + pagingSourceFactory = { + historyRepo.getAllData() + } + ).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/lmedia b/lmedia index a339cef69..b6a2944c1 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit a339cef695f8d583de31871f1330ac9faf42ca60 +Subproject commit b6a2944c10f419695e117e935bb64f19a1b79034 diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index b652007d6..6196296a1 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") @@ -27,5 +27,10 @@ android { dependencies { implementation(project(":common")) - implementation("com.github.cy745:AndroidVideoCache:2.7.2") + implementation(project(":lmedia")) + implementation(libs.startup.runtime) + + 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/lib-decoder-flac/lib-decoder-flac-release.aar b/lplayer/lib-decoder-flac/lib-decoder-flac-release.aar new file mode 100644 index 000000000..acbdf134c Binary files /dev/null and b/lplayer/lib-decoder-flac/lib-decoder-flac-release.aar differ diff --git a/lplayer/src/main/AndroidManifest.xml b/lplayer/src/main/AndroidManifest.xml index a5918e68a..a6af91a5b 100644 --- a/lplayer/src/main/AndroidManifest.xml +++ b/lplayer/src/main/AndroidManifest.xml @@ -1,4 +1,38 @@ - + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..db59cd63e --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayer.kt @@ -0,0 +1,247 @@ +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 +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.common.util.UnstableApi +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.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 +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) +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) + .buildAsync() + } + + val module = module { + } + + var pauseWhenCompletion: Boolean by mutableStateOf(false) + 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 currentDuration: 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 + + fun isItemPlaying(mediaId: String): Boolean { + if (!isPlaying) return false + return currentMediaItem?.mediaId == mediaId + } + + internal fun init() { + launch(Dispatchers.Main) { + val browser = browserFuture.await() + browserInstance = browser + browser.addListener(this@MPlayer) + + val items = getHistoryItems() + if (items.isEmpty()) { + LogUtils.i("No songs found") + return@launch + } + + browser.playWhenReady = false + browser.setMediaItems(items) + browser.prepare() + } + } + + fun doAction(action: Action) = launch(Dispatchers.Main) { + val browser = browserFuture.await() + + when (action) { + PlayerAction.Play -> browser.play() + PlayerAction.Pause -> browser.pause() + + PlayerAction.SkipToNext -> { + if (browser.playMode == PlayMode.Shuffle) { + browser.sendCustomCommand( + CustomCommand.SeekToNext.toSessionCommand(), + Bundle.EMPTY + ) + } else { + browser.seekToNext() + } + } + + PlayerAction.SkipToPrevious -> { + if (browser.playMode == PlayMode.Shuffle) { + browser.sendCustomCommand( + CustomCommand.SeekToPrevious.toSessionCommand(), + Bundle.EMPTY + ) + } else { + 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) + browser.play() + } + } + } + + is PlayerAction.SeekTo -> { + browser.seekTo(action.positionMs) + } + + is PlayerAction.CustomAction -> {} + is PlayerAction.PauseWhenCompletion -> { + pauseWhenCompletion = !action.cancel + } + + 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) + if (action.start) { + browser.play() + } + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + this@MPlayer.isPlaying = isPlaying + } + + @OptIn(UnstableApi::class) + override fun onPlaybackStateChanged(playbackState: Int) { + + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + currentMediaItem = mediaItem + updateItems() + + if (pauseWhenCompletion) { + browserInstance?.pause() + pauseWhenCompletion = false + } + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + currentMediaItem = browserInstance?.currentMediaItem + currentMediaMetadata = mediaMetadata + currentDuration = mediaMetadata.durationMs ?: browserInstance?.duration ?: 0L + // TODO 此处获取到的duration仍然可能是上一首歌曲的时长 + } + + override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) { + currentPlaylistMetadata = mediaMetadata + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + updateItems(timeline) + } + + 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) + } +} + +private fun Timeline.toMediaItems(): List { + return (0 until this.windowCount) + .mapNotNull { this.getWindow(it, Timeline.Window()).mediaItem } +} + +private 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/MPlayerKV.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt new file mode 100644 index 000000000..84e2ea63f --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt @@ -0,0 +1,10 @@ +package com.lalilu.lplayer + +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") + val playMode = obtain("play_mode") +} \ 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..1e67d59d6 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/Startup.kt @@ -0,0 +1,18 @@ +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) { + LMedia.whenReady { + MPlayer.init() + } + } + + override fun dependencies(): MutableList>> { + return mutableListOf() + } +} \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/action/Action.kt b/lplayer/src/main/java/com/lalilu/lplayer/action/Action.kt new file mode 100644 index 000000000..93b38ec05 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/action/Action.kt @@ -0,0 +1,6 @@ +package com.lalilu.lplayer.action + + +interface Action { + fun action() +} \ No newline at end of file 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..3645b1281 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/action/MediaControl.kt @@ -0,0 +1,38 @@ +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, + 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/extensions/PlayerAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt similarity index 65% 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 e71ef64e2..553ff8acc 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayerAction.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/action/PlayerAction.kt @@ -1,9 +1,12 @@ -package com.lalilu.lplayer.extensions +package com.lalilu.lplayer.action -import com.lalilu.lplayer.LPlayer +import com.lalilu.lplayer.MPlayer +import com.lalilu.lplayer.extensions.PlayMode 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() @@ -12,6 +15,13 @@ 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() + data class AddToNext(val mediaId: String) : PlayerAction() + data class UpdateList( + val mediaIds: List, + val mediaId: String? = null, + val start: Boolean = false + ) : 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/Action.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt deleted file mode 100644 index e15f8cf41..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Action.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.lalilu.lplayer.extensions - - -interface Action { - fun action(): Boolean = false -} \ No newline at end of file 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..be67cbc0f 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/Extensions.kt @@ -1,178 +1,8 @@ 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 } - -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/FadeTransitionAudioSink.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt new file mode 100644 index 000000000..86521dddc --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/FadeTransitionAudioSink.kt @@ -0,0 +1,97 @@ +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.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 + +@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, + teeBufferListener: TeeAudioProcessor.AudioBufferSink? = null, +) : DefaultRenderersFactory(context), AudioProcessorChain { + + private val teeAudioProcessor = teeBufferListener + ?.let { TeeAudioProcessor(it) } + + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ): 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 diff --git a/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt new file mode 100644 index 000000000..ccef43f25 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/PlayMode.kt @@ -0,0 +1,29 @@ +package com.lalilu.lplayer.extensions + +import androidx.media3.common.Player + +enum class PlayMode { + ListRecycle, + RepeatOne, + Shuffle; + + companion object { + fun of(repeatMode: Int, shuffleModeEnabled: Boolean): PlayMode { + if (repeatMode == Player.REPEAT_MODE_ONE) return RepeatOne + 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 == 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/QueueAction.kt b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt deleted file mode 100644 index 4b4f64d34..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueAction.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.lalilu.lplayer.extensions - -import com.lalilu.lplayer.LPlayer - -sealed class QueueAction : Action { - override fun action(): Boolean = LPlayer.controller.doAction(this) - - 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/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..f800c2474 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/extensions/QueueControlPlayer.kt @@ -0,0 +1,122 @@ +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: ExoPlayer) : ForwardingPlayer(player), Player.Listener { + + init { + player.addListener(this) + player.setShuffleOrder(CustomShuffleOrder(0)) + } + + private fun tryMoveNext() { + if (playMode == PlayMode.Shuffle) { + val target = getRandomNextIndex() + if (target < 0) return + + 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 { + if (currentTimeline.windowCount <= 0) return -1 + + 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 + } + .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() { + super.seekToPrevious() + tryMoveNext() + } + + @UnstableApi + private class CustomShuffleOrder(private val size: Int) : ShuffleOrder { + override fun getLength(): Int { + return size + } + + 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) +} \ 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/playback/PlayMode.kt b/lplayer/src/main/java/com/lalilu/lplayer/playback/PlayMode.kt deleted file mode 100644 index 678202d22..000000000 --- a/lplayer/src/main/java/com/lalilu/lplayer/playback/PlayMode.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.lalilu.lplayer.playback - -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 - -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 - } - } - - 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 - return ListRecycle - } - } -} \ No newline at end of file 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/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/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/LService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/LService.kt deleted file mode 100644 index b8c1304d9..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?.metaDataCompat) - 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 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..9d4efa928 --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MNotificationProvider.kt @@ -0,0 +1,407 @@ +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.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 +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 = list.findPlayingIndex(time) + if (lastIndex == index) { + delay(50) + continue + } + + lastIndex = index + val current = list.getOrNull(index) + + if (current != null) { + post { + val text = when (current) { + is LyricItem.NormalLyric -> current.content + is LyricItem.WordsLyric -> current.getSentenceContent() + 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 new file mode 100644 index 000000000..3bac5580a --- /dev/null +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -0,0 +1,298 @@ +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.exoplayer.analytics.AnalyticsListener +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 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 +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 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 +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 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 getKoin().injectOrNull(named("history_analytics_listener")) + + private var exoPlayer: Player? = 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() + + setMediaNotificationProvider( + MNotificationProvider(this) + ) + + exoPlayer = ExoPlayer.Builder(this) + .setRenderersFactory(FadeTransitionRenderersFactory(this, this)) + .setHandleAudioBecomingNoisy(MPlayerKV.handleBecomeNoisy.value != false) + .setAudioAttributes(defaultAudioAttributes, MPlayerKV.handleAudioFocus.value != false) + .setMaxSeekToPreviousPositionMs(Long.MAX_VALUE) // 避免播放上一首需要点两次 + .build() + .apply { + historyAnalyticsListener?.let { addAnalyticsListener(it) } + addListener(MPlayerListener(this)) + } + .setUpQueueControl() + + mediaSession = MediaLibrarySession + .Builder(this, exoPlayer!!, MServiceCallback(exoPlayer!!)) + .setSessionActivity(getLauncherPendingIntent()) + .build() + + startListenForValuesUpdate() + } + + override fun onDestroy() { + // 释放相关实例 + exoPlayer?.stop() + exoPlayer?.release() + exoPlayer = null + mediaSession?.release() + mediaSession = null + super.onDestroy() + } + + override fun onGetSession( + controllerInfo: MediaSession.ControllerInfo + ): MediaLibrarySession? = mediaSession + + private fun startListenForValuesUpdate() = launch { + MPlayerKV.handleAudioFocus.flow().onEach { + withContext(Dispatchers.Main) { + exoPlayer?.setAudioAttributes(defaultAudioAttributes, it != false) + } + }.launchIn(this) + + MPlayerKV.handleBecomeNoisy.flow().onEach { + withContext(Dispatchers.Main) { + (exoPlayer as? ExoPlayer) + ?.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 + } +} + +@OptIn(UnstableApi::class) +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_AND_LIBRARY_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) + .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(LMedia.ROOT, "LMedia Library"), params) + ) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + if (parentId == LMedia.ROOT) { + return Futures.immediateFuture( + LibraryResult.ofItemList( + listOf( + buildBrowsableItem(LMedia.ALL_SONGS, "All Songs"), + buildBrowsableItem(LMedia.ALL_ARTISTS, "All Artists"), + buildBrowsableItem(LMedia.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(SessionError.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() + ) + + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo + ): ListenableFuture { + 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.isNullOrEmpty()) LMedia.mapItems(history) + else LMedia.getChildren(LMedia.ALL_SONGS) +} + +internal fun saveHistoryIds(mediaIds: List) { + MPlayerKV.historyPlaylistIds.set(mediaIds) +} \ No newline at end of file diff --git a/lplaylist/build.gradle.kts b/lplaylist/build.gradle.kts index 64458b45a..c9411068a 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,11 +28,13 @@ android { kotlinOptions { jvmTarget = "1.8" } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.version.get() - } +} + +composeCompiler { + enableStrongSkippingMode = true } 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 e5204430a..fb4c28330 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistActions.kt @@ -1,76 +1,151 @@ 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.common.base.Playable -import com.lalilu.component.extension.SelectAction -import com.lalilu.component.navigation.GlobalNavigator -import com.lalilu.lplaylist.entity.LPlaylist +import com.lalilu.RemixIcon +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.lplaylist.screen.PlaylistAddToScreen -import org.koin.java.KoinJavaComponent -import com.lalilu.component.R as componentR +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 -object PlaylistActions { - private val navigator: GlobalNavigator by KoinJavaComponent.inject(GlobalNavigator::class.java) - private val playlistRepo: PlaylistRepository by KoinJavaComponent.inject(PlaylistRepository::class.java) +@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() - /** - * 将指定歌曲添加至播放列表 - */ - 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 } - - navigator.navigateTo( - PlaylistAddToScreen( - ids = mediaIds, - callback = { - selector.clear() - navigator.goBack() - } - ) - ) + AppRouter.route("/playlist/add") + .with("mediaIds", items.map { it.id }) + .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 } +@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.id } + val playlistRepo = requestFor() - playlistRepo.addMediaIdsToFavourite(mediaIds) - ToastUtils.showShort("已添加${mediaIds.size}首歌曲至我喜欢") + playlistRepo?.let { + it.addMediaIdsToFavourite(items) + ToastUtils.showShort("已添加${items.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 } +@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" + ) - runCatching { - playlistRepo.removeByIds(ids = playlistIds) - ToastUtils.showShort("已删除${playlistIds.size}个歌单") - selector.remove(selectedPlaylist) - }.getOrElse { - it.printStackTrace() - ToastUtils.showShort("删除失败") + 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/PlaylistModule.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt index c3e1f126a..455436720 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/PlaylistModule.kt @@ -1,20 +1,23 @@ package com.lalilu.lplaylist -import com.lalilu.lplaylist.repository.PlaylistKV +import androidx.compose.runtime.collectAsState +import com.lalilu.component.SlotState 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 org.koin.core.module.dsl.factoryOf -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.module +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single -val PlaylistModule = module { - singleOf(::PlaylistKV) +@Module +@ComponentScan("com.lalilu.lplaylist") +object PlaylistModule - singleOf(::PlaylistRepositoryImpl) - factoryOf(::PlaylistDetailScreenModel) - factoryOf(::PlaylistCreateOrEditScreenModel) - single { get() } +@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/component/PlaylistCard.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/component/PlaylistCard.kt index 35a68c4d6..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,15 +2,16 @@ 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.heightIn +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.material.Text @@ -18,18 +19,21 @@ 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 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, @@ -43,44 +47,50 @@ 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 - .heightIn(min = 56.dp) + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(8.dp)) .background(bgColor) .combinedClickable( 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, ) } } @@ -90,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" ) @@ -100,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( @@ -108,15 +118,16 @@ fun PlaylistCard( label = "DragHandleVisibility" ) { Icon( - modifier = draggingModifier, - painter = painterResource(id = componentR.drawable.ic_draggable), + modifier = draggingModifier + .padding(start = 16.dp), + imageVector = RemixIcon.Editor.draggable, contentDescription = "DragHandle", ) } } } -@Preview +@Preview(showBackground = true) @Composable private fun PlaylistCardPreview() { val playlist = LPlaylist( @@ -145,7 +156,9 @@ private fun PlaylistCardListPreview() { ) Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { PlaylistCard( 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..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,53 +1,17 @@ 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 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 -) : 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) - } + 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/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..138f7bfc3 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/repository/PlaylistRepositoryImpl.kt @@ -7,26 +7,27 @@ 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 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() } @@ -91,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) { 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/PlaylistAddToScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt deleted file mode 100644 index e23b2ea48..000000000 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistAddToScreen.kt +++ /dev/null @@ -1,132 +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.GlobalNavigator -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, -) { - val navigator = koinInject() - - 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 = { navigator.navigateTo(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/PlaylistCreateOrEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt deleted file mode 100644 index 7f61bd024..000000000 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistCreateOrEditScreen.kt +++ /dev/null @@ -1,322 +0,0 @@ -package com.lalilu.lplaylist.screen - -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.rememberLazyListScrollToHelper -import com.lalilu.component.extension.toMutableState -import com.lalilu.component.navigation.GlobalNavigator -import com.lalilu.lplaylist.R -import com.lalilu.lplaylist.entity.LPlaylist -import com.lalilu.lplaylist.repository.PlaylistRepository -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 navigator: GlobalNavigator, - 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, - fitImePadding = 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() - ) - ) - navigator.goBack() - } - } - - val updatePlaylistAction = ScreenAction.StaticAction( - title = R.string.playlist_action_update_playlist, - icon = componentR.drawable.ic_check_line, - isLongClickAction = true, - fitImePadding = 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() - ) - ) - navigator.goBack() - } - } - - fun updateTargetPlaylistId(playlistId: String) { - this.playlistId.tryEmit(playlistId) - } -} - -/** - * [targetPlaylistId] 目标操作歌单的Id - */ -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 scrollToHelper = rememberLazyListScrollToHelper(listState = state) - - 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 - ) { - scrollToHelper.startRecord() - - item(key = "header") { - scrollToHelper.doRecord("header") - NavigatorHeader( - modifier = Modifier.statusBarsPadding(), - title = stringResource(id = headerTitleRes), - subTitle = stringResource(id = headerSubTitleRes), - ) - } - - editTextFor( - title = "主标题", - minLines = 1, - value = createOrEditSM.title, - onInit = { scrollToHelper.doRecord(it) }, - onFocus = onFocusCallback - ) - - editTextFor( - title = "简介/备注", - minLines = 3, - value = createOrEditSM.subTitle, - onInit = { scrollToHelper.doRecord(it) }, - 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/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt deleted file mode 100644 index e7035cb65..000000000 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistDetailScreen.kt +++ /dev/null @@ -1,207 +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.GlobalNavigator -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( - 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 navigator = koinInject() - 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 = { - navigator.navigateTo( - 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 e1a6fcc89..c50f2e02b 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -1,150 +1,119 @@ package com.lalilu.lplaylist.screen -import androidx.compose.foundation.ExperimentalFoundationApi -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.foundation.lazy.rememberLazyListState -import androidx.compose.material.Icon -import androidx.compose.material.IconButton +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.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.Modifier -import androidx.compose.ui.res.painterResource +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 cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.rememberScreenModel -import com.lalilu.component.LLazyColumn -import com.lalilu.component.base.DynamicScreen -import com.lalilu.component.base.NavigatorHeader -import com.lalilu.component.base.ScreenInfo +import androidx.compose.ui.unit.sp +import com.blankj.utilcode.util.ToastUtils +import com.lalilu.RemixIcon +import com.lalilu.component.LongClickableTextButton import com.lalilu.component.base.TabScreen -import com.lalilu.component.extension.rememberItemSelectHelper -import com.lalilu.component.navigation.GlobalNavigator -import com.lalilu.component.registerSelectPanel -import com.lalilu.lplaylist.PlaylistActions +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.base.songs.SongsSearcherPanel +import com.lalilu.component.base.songs.SongsSelectorPanel +import com.lalilu.component.extension.screenVM +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.lplaylist.repository.PlaylistRepository -import org.koin.compose.koinInject -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyColumnState -import com.lalilu.component.R as ComponentR +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 -class PlaylistScreenModel : ScreenModel { - val isSelecting = mutableStateOf(false) - 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 - ) +@Destination("/pages/playlist") +data object PlaylistScreen : TabScreen, ScreenBarFactory { + private fun readResolve(): Any = PlaylistScreen @Composable - override fun Content() { - PlaylistScreen() - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun DynamicScreen.PlaylistScreen( - playlistSM: PlaylistScreenModel = rememberScreenModel { PlaylistScreenModel() }, - playlistRepo: PlaylistRepository = koinInject(), - navigator: GlobalNavigator = 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) - } + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.playlist_screen_title) }, + icon = RemixIcon.Media.playListFill, + ) } - LaunchedEffect(Unit) { - playlistRepo.checkFavouriteExist() - } + @Composable + override fun Content() { + val vm = screenVM() + val state by vm.state - val selectHelper = rememberItemSelectHelper( - isSelecting = playlistSM.isSelecting, - selected = playlistSM.selectedItems - ) + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(PlaylistsAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(PlaylistsAction.SearchFor(it)) } + ) - registerSelectPanel( - selectActions = { listOf(PlaylistActions.removePlaylists) }, - selector = selectHelper - ) + 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 = { navigator.navigateTo(PlaylistCreateOrEditScreen()) } - ) { - Icon( - painter = painterResource(ComponentR.drawable.ic_add_line), - contentDescription = null - ) + LongClickableTextButton( + modifier = Modifier.fillMaxHeight(), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = 20.dp), + colors = ButtonDefaults.textButtonColors( + backgroundColor = color.copy(alpha = 0.15f), + contentColor = color + ), + 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 + ) + } } - } - } + ) + ) - items( - items = playlistState, - key = { it.id }, - contentType = { LPlaylist::class.java } - ) { playlist -> - ReorderableItem( - reorderableLazyListState = 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 { - navigator.navigateTo(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 = { + if (vm.selector.isSelecting.value) { + vm.selector.onSelect(it) + } else { + 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..15d7da637 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreenContent.kt @@ -0,0 +1,180 @@ +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/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/add/PlaylistAddToScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt new file mode 100644 index 000000000..308adec97 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/add/PlaylistAddToScreen.kt @@ -0,0 +1,81 @@ +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 = { stringResource(id = 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..e1d079cc4 --- /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.PlaylistEditScreen + + +@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(PlaylistEditScreen()) + ) + } + ) { + 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/create/PlaylistEditScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt new file mode 100644 index 000000000..6f82ea5c9 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/create/PlaylistEditScreen.kt @@ -0,0 +1,85 @@ +package com.lalilu.lplaylist.screen.create + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +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.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 + + +/** + * [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 = screenVM( + parameters = { parametersOf(playlistId) } + ) + + return remember { + listOfNotNull( + 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) } + ), + ) + } + } + + @Composable + override fun Content() { + val vm = screenVM( + parameters = { parametersOf(playlistId) } + ) + + PlaylistEditScreenContent( + isEditing = { vm.playlist.value != null }, + titleHint = { vm.playlist.value?.title ?: "" }, + subTitleHint = { vm.playlist.value?.subTitle ?: "" }, + 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/screen/detail/PlaylistDetailScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt new file mode 100644 index 000000000..5ffe330f8 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreen.kt @@ -0,0 +1,186 @@ +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.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 +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.component.extension.screenVM +import com.lalilu.lmedia.extension.SortStaticAction +import com.lalilu.lplaylist.R +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.deleteBinLine +import com.lalilu.remixicon.system.menuSearchLine +import com.zhangke.krouter.annotation.Destination +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, ScreenBarFactory { + override val key: ScreenKey = "${super.key}:$playlistId" + + @Composable + override fun provideScreenInfo(): ScreenInfo = remember { + ScreenInfo( + title = { stringResource(id = R.string.playlist_screen_detail) } + ) + } + + @Composable + override fun provideScreenActions(): List { + val vm = screenVM( + 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 = screenVM( + parameters = { parametersOf(playlistId) } + ) + + val state by vm.state + val songs by vm.songs + val playlist by vm.playlist + + SongsSortPanelDialog( + isVisible = { state.showSortPanel }, + onDismiss = { vm.intent(PlaylistDetailAction.HideSortPanel) }, + supportSortActions = vm.supportSortActions, + isSortActionSelected = { state.selectedSortAction == it }, + onSelectSortAction = { vm.intent(PlaylistDetailAction.SelectSortAction(it)) } + ) + + SongsHeaderJumperDialog( + isVisible = { state.showJumperDialog }, + onDismiss = { vm.intent(PlaylistDetailAction.HideJumperDialog) }, + items = { songs.keys }, + onSelectItem = { vm.intent(PlaylistDetailAction.LocaleToGroupItem(it)) } + ) + + SongsSearcherPanel( + isVisible = { state.showSearcherPanel }, + onDismiss = { vm.intent(PlaylistDetailAction.HideSearcherPanel) }, + keyword = { state.searchKeyWord }, + onUpdateKeyword = { vm.intent(PlaylistDetailAction.SearchFor(it)) } + ) + + 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() } + ), + 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) } + ), + requestFor( + qualifier = named("add_to_playlist_action"), + parameters = { parametersOf(vm.selector::selected) } + ) + ) + ) + + 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)) } + ) + } +} \ 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 new file mode 100644 index 000000000..5710dd973 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/detail/PlaylistDetailScreenContent.kt @@ -0,0 +1,284 @@ +package com.lalilu.lplaylist.screen.detail + +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.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 +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +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.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.component.state +import com.lalilu.lmedia.entity.LSong +import com.lalilu.lmedia.extension.GroupIdentity +import com.lalilu.lplayer.action.MediaControl +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 + +@Composable +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 listState: LazyListState = rememberLazyListState() + val stickyHeaderContentType = remember { "group" } + val favouriteIds = state("favourite_ids", emptyList()) + val scroller = rememberLazyListAnimateScroller( + listState = listState, + keys = keys + ) + + 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) + } + } + + 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() + .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") { + NavigatorHeader( + modifier = Modifier + .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) +// ) + } + ) { + IconButton(onClick = { + AppRouter.route("/pages/playlist/edit") + .with("playlistId", playlist?.id ?: "") + .push() + }) { + Icon( + imageVector = RemixIcon.Design.editBoxFill, + contentDescription = null + ) + } + } + } + + if (enableDraggable) { + itemsWithRecord( + items = playlistState, + key = { it.id }, + contentType = { it::class.java } + ) { item -> + ReorderableItem( + state = reorderableState, + key = item.id + ) { + SongCard( + dragModifier = Modifier.draggableHandle( + onDragStopped = { onUpdatePlaylist(playlistState.map { it.id }) } + ), + song = { item }, + isSelected = { isSelected(item) }, + isFavour = { favouriteIds.value.contains(item.id) }, + onClick = { + if (isSelecting()) { + onSelect(item) + } else { + MediaControl.playWithList( + mediaIds = playlistState.map(LSong::id), + mediaId = item.id + ) + } + }, + onLongClick = { + if (isSelecting()) { + onSelect(item) + } else { + AppRouter.route("/pages/songs/detail") + .with("mediaId", item.id) + .jump() + } + }, + onEnterSelect = { onSelect(item) } + ) + } + } + } 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) }, + isFavour = { favouriteIds.value.contains(item.id) }, + onClick = { + if (isSelecting()) { + onSelect(item) + } else { + MediaControl.playWithList( + mediaIds = playlistState.map(LSong::id), + mediaId = item.id + ) + } + }, + onLongClick = { + 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..9b6415338 --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistDetailVM.kt @@ -0,0 +1,179 @@ +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 + data class RemoveItems(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) } + } + + is PlaylistDetailAction.RemoveItems -> { + playlistRepo.removeMediaIdsFromPlaylist( + mediaIds = intent.mediaIds, + playlistId = playlistId + ) + } + + else -> { + LogUtils.i("Not implemented action: $intent") + } + } + } +} + 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..8563302ca --- /dev/null +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/viewmodel/PlaylistEditVM.kt @@ -0,0 +1,105 @@ +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 +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 com.zhangke.krouter.KRouter +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 + data object Delete : PlaylistEditAction +} + +sealed interface PlaylistEditEvent { + +} + +@OptIn(ExperimentalUuidApi::class, ExperimentalCoroutinesApi::class) +@KoinViewModel +data class PlaylistEditVM( + val playlistId: String?, + 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) + } + + is PlaylistEditAction.Delete -> { + playlistRepo.removeById(actualId) + + KRouter.route("/pages/playlist") + ?.let { AppRouter.intent(NavIntent.PopUtil(it)) } + } + + else -> {} + } + } +} \ No newline at end of file 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 diff --git a/lumo.properties b/lumo.properties new file mode 100644 index 000000000..d8ec3ecda --- /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=LumoTheme +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 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/settings.gradle.kts b/settings.gradle.kts index 60eb2369b..cb8e22e6e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,34 +3,31 @@ pluginManagement { google() mavenCentral() gradlePluginPortal() -// maven("https://maven.aliyun.com/repository/central") -// maven("https://maven.aliyun.com/repository/google") + maven("https://jitpack.io") } } dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() -// maven("https://maven.aliyun.com/repository/google") -// maven("https://maven.aliyun.com/repository/central") maven("https://jitpack.io") } } -rootProject.name = "lmusic" +rootProject.name = "LMusic" include(":app") -include(":ui") include(":common") +include(":component") +include(":crash") + include(":lmedia") include(":lplayer") +include(":lplayer:lib-decoder-flac") + include(":lplaylist") include(":lhistory") include(":lartist") include(":lalbum") -include(":ldictionary") -include(":crash") -include(":component") -include(":value-cat") \ No newline at end of file +include(":lfolder") 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 9842f0683..000000000 --- a/ui/build.gradle.kts +++ /dev/null @@ -1,35 +0,0 @@ -plugins { - id("com.android.library") - kotlin("android") -} - -android { - namespace = "com.lalilu.ui" - compileSdk = AndroidConfig.COMPILE_SDK_VERSION - - 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" - } -} - -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 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