diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dcea68c8..616d0501 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,8 @@ android { putBuildConfig(localProperties, "LAST_FM_API_KEY") putBuildConfig(localProperties, "LAST_FM_API_SECRET") putBuildConfig(localProperties, "MUSIXMATCH_API_KEY") + putBuildConfig(localProperties, "YTMUSIC_CLIENT_ID") + putBuildConfig(localProperties, "YTMUSIC_CLIENT_SECRET") } if (it.buildType == "release") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c8f09d8e..96495fd5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:localeConfig="@xml/locales_config" - android:enableOnBackInvokedCallback="true" + android:enableOnBackInvokedCallback="false" android:requestLegacyExternalStorage="true" android:extractNativeLibs="true" android:icon="@mipmap/ic_launcher" diff --git a/app/src/main/java/caios/android/kanade/di/AppModule.kt b/app/src/main/java/caios/android/kanade/di/AppModule.kt index da57ae6c..ec8229d9 100644 --- a/app/src/main/java/caios/android/kanade/di/AppModule.kt +++ b/app/src/main/java/caios/android/kanade/di/AppModule.kt @@ -32,6 +32,8 @@ object AppModule { lastFmApiKey = BuildConfig.LAST_FM_API_KEY, lastFmApiSecret = BuildConfig.LAST_FM_API_SECRET, musixmatchApiKey = BuildConfig.MUSIXMATCH_API_KEY, + ytMusicApiKey = BuildConfig.YTMUSIC_CLIENT_ID, + ytMusicApiSecret = BuildConfig.YTMUSIC_CLIENT_SECRET, ) } diff --git a/app/src/main/java/caios/android/kanade/navigation/KanadeNavHost.kt b/app/src/main/java/caios/android/kanade/navigation/KanadeNavHost.kt index fe3d95c4..1157eead 100644 --- a/app/src/main/java/caios/android/kanade/navigation/KanadeNavHost.kt +++ b/app/src/main/java/caios/android/kanade/navigation/KanadeNavHost.kt @@ -67,6 +67,8 @@ import caios.android.kanade.feature.setting.oss.settingLicenseScreen import caios.android.kanade.feature.setting.theme.navigateToSettingTheme import caios.android.kanade.feature.setting.theme.settingThemeScreen import caios.android.kanade.feature.setting.top.settingTopScreen +import caios.android.kanade.feature.setting.ytmusic.navigateToYTMusicLogin +import caios.android.kanade.feature.setting.ytmusic.ytmusicLoginDialog import caios.android.kanade.feature.share.ShareUtil import caios.android.kanade.feature.song.detail.navigateToSongDetail import caios.android.kanade.feature.song.detail.songDetailScreen @@ -417,6 +419,9 @@ fun KanadeNavHost( ) settingTopScreen( + navigateToYTMusicLogin = { + navController.navigateToYTMusicLogin() + }, navigateToEqualizer = { activity.startActivity( Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { @@ -460,5 +465,11 @@ fun KanadeNavHost( navController.popBackStack() }, ) + + ytmusicLoginDialog( + terminate = { + navController.popBackStack() + }, + ) } } diff --git a/app/src/main/java/caios/android/kanade/ui/KanadeApp.kt b/app/src/main/java/caios/android/kanade/ui/KanadeApp.kt index 77349a6e..b552c418 100644 --- a/app/src/main/java/caios/android/kanade/ui/KanadeApp.kt +++ b/app/src/main/java/caios/android/kanade/ui/KanadeApp.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues @@ -90,6 +91,12 @@ fun KanadeApp( val activity = (LocalContext.current as Activity) var isShowWelcomeScreen by remember { mutableStateOf(!isAgreedTeams || !isAllowedPermission) } + LaunchedEffect(true) { + if (userData.kanadeId.isBlank()) { + musicViewModel.initKanadeId() + } + } + KanadeBackground(modifier) { AnimatedContent( targetState = isShowWelcomeScreen && (!isAgreedTeams || !isAllowedPermission), @@ -277,6 +284,11 @@ private fun IdleScreen( ) }, ) { rootPadding -> + val padding = PaddingValues( + top = rootPadding.calculateTopPadding(), + bottom = bottomSheetPeekHeight, + ) + BottomSheetScaffold( modifier = Modifier .fillMaxSize() @@ -347,7 +359,7 @@ private fun IdleScreen( ) }, ) { - Box(Modifier.padding(rootPadding)) { + Box(Modifier.padding(padding)) { if (musicViewModel.uiState.isAnalyzing) { LoadingDialog(R.string.common_analyzing) } @@ -360,6 +372,7 @@ private fun IdleScreen( } .zIndex(if (appState.currentLibraryDestination == null) 0f else 1f) .alpha(topBarAlpha), + isEnableYTMusic = userData.isEnableYTMusic, active = isSearchActive, yOffset = toolbarOffset, onChangeActive = { isSearchActive = it }, diff --git a/app/src/main/java/caios/android/kanade/ui/KanadeTopBar.kt b/app/src/main/java/caios/android/kanade/ui/KanadeTopBar.kt index d353bf94..d7ee3dfb 100644 --- a/app/src/main/java/caios/android/kanade/ui/KanadeTopBar.kt +++ b/app/src/main/java/caios/android/kanade/ui/KanadeTopBar.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,6 +48,7 @@ import caios.android.kanade.core.model.music.Playlist import caios.android.kanade.core.model.music.Song import caios.android.kanade.feature.search.top.SearchRoute import caios.android.kanade.feature.search.top.SearchViewModel +import kotlinx.coroutines.launch @Suppress("ViewModelInjection") @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationGraphicsApi::class) @@ -54,6 +56,7 @@ import caios.android.kanade.feature.search.top.SearchViewModel fun KanadeTopBar( active: Boolean, yOffset: Dp, + isEnableYTMusic: Boolean, onChangeActive: (Boolean) -> Unit, onClickDrawerMenu: () -> Unit, navigateToArtistDetail: (Long) -> Unit, @@ -66,6 +69,7 @@ fun KanadeTopBar( modifier: Modifier = Modifier, ) { val density = LocalDensity.current + val scope = rememberCoroutineScope() val searchViewModel = hiltViewModel() val image = AnimatedImageVector.animatedVectorResource(R.drawable.av_drawer_to_arrow) @@ -85,7 +89,9 @@ fun KanadeTopBar( } LaunchedEffect(query) { - searchViewModel.search(listOf(query)) + if (!isEnableYTMusic) { + searchViewModel.search(listOf(query)) + } } Column( @@ -105,7 +111,13 @@ fun KanadeTopBar( SearchBar( query = query, onQueryChange = { query = it }, - onSearch = { }, + onSearch = { + if (isEnableYTMusic) { + scope.launch { + searchViewModel.search(listOf(query)) + } + } + }, active = active, onActiveChange = onChangeActive, colors = SearchBarDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), @@ -151,6 +163,7 @@ private fun Preview() { KanadeTopBar( active = false, yOffset = 0.dp, + isEnableYTMusic = false, onChangeActive = { }, onClickDrawerMenu = { }, navigateToArtistDetail = { }, diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index bd9c7927..8551c871 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -39,6 +39,10 @@ gradlePlugin { id = "kanade.library.glance" implementationClass = "AndroidLibraryGlanceConventionPlugin" } + register("androidLibraryChaquopy") { + id = "kanade.library.chaquopy" + implementationClass = "AndroidLibraryChaquopyConventionPlugin" + } register("androidHilt") { id = "kanade.hilt" implementationClass = "AndroidHiltConventionPlugin" diff --git a/build-logic/convention/src/main/java/AndroidLibraryChaquopyConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidLibraryChaquopyConventionPlugin.kt new file mode 100644 index 00000000..82f7aba5 --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidLibraryChaquopyConventionPlugin.kt @@ -0,0 +1,20 @@ + +import caios.android.kanade.configureChaquopy +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidLibraryChaquopyConventionPlugin: Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.chaquo.python") + } + + extensions.configure { + configureChaquopy(this) + } + } + } +} diff --git a/build-logic/convention/src/main/java/caios/android/kanade/Chaquopy.kt b/build-logic/convention/src/main/java/caios/android/kanade/Chaquopy.kt new file mode 100644 index 00000000..80e0f420 --- /dev/null +++ b/build-logic/convention/src/main/java/caios/android/kanade/Chaquopy.kt @@ -0,0 +1,13 @@ +package caios.android.kanade + +import com.android.build.api.dsl.CommonExtension + +internal fun configureChaquopy(commonExtension: CommonExtension<*, *, *, *, *>) { + commonExtension.apply { + defaultConfig { + ndk { + abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64") + } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index c068a39a..ee283941 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,5 +13,6 @@ plugins { alias(libs.plugins.detekt) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.libraries) apply false + alias(libs.plugins.chaquopy) apply false alias(libs.plugins.module.graph) apply false } diff --git a/core/common/src/main/java/caios/android/kanade/core/common/network/KanadeConfig.kt b/core/common/src/main/java/caios/android/kanade/core/common/network/KanadeConfig.kt index 123efec0..d1b803cc 100644 --- a/core/common/src/main/java/caios/android/kanade/core/common/network/KanadeConfig.kt +++ b/core/common/src/main/java/caios/android/kanade/core/common/network/KanadeConfig.kt @@ -10,6 +10,8 @@ data class KanadeConfig( val lastFmApiKey: String, val lastFmApiSecret: String, val musixmatchApiKey: String, + val ytMusicApiKey: String, + val ytMusicApiSecret: String, ) { companion object { fun dummy(): KanadeConfig { @@ -23,6 +25,8 @@ data class KanadeConfig( lastFmApiKey = "1919191919", lastFmApiSecret = "1919191919", musixmatchApiKey = "1919191919", + ytMusicApiKey = "1919191919", + ytMusicApiSecret = "1919191919", ) } } diff --git a/core/common/src/main/java/caios/android/kanade/core/common/network/util/CoroutineUtils.kt b/core/common/src/main/java/caios/android/kanade/core/common/network/util/CoroutineUtils.kt new file mode 100644 index 00000000..f359f79b --- /dev/null +++ b/core/common/src/main/java/caios/android/kanade/core/common/network/util/CoroutineUtils.kt @@ -0,0 +1,13 @@ +package caios.android.kanade.core.common.network.util + +import timber.log.Timber +import kotlin.coroutines.cancellation.CancellationException + +suspend fun suspendRunCatching(block: suspend () -> T): Result = try { + Result.success(block()) +} catch (cancellationException: CancellationException) { + throw cancellationException +} catch (exception: Exception) { + Timber.i(exception, "Failed to evaluate a suspendRunCatchingBlock. Returning failure Result") + Result.failure(exception) +} diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/KanadePreferencesDataStore.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/KanadePreferencesDataStore.kt index c9942eca..d5c1abb6 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/KanadePreferencesDataStore.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/KanadePreferencesDataStore.kt @@ -27,6 +27,7 @@ class KanadePreferencesDataStore @Inject constructor( val userData = userPreference.data .map { UserData( + kanadeId = it.kanadeId, themeConfig = when (it.themeConfig) { ThemeConfigProto.THEME_CONFIG_LIGHT -> ThemeConfig.Light ThemeConfigProto.THEME_CONFIG_DARK -> ThemeConfig.Dark @@ -51,6 +52,7 @@ class KanadePreferencesDataStore @Inject constructor( isIgnoreNotMusic = if (it.hasIsUseIgnoreNotMusic()) it.isUseIgnoreNotMusic else true, isAgreedPrivacyPolicy = if (it.hasIsAgreedPrivacyPolicy()) it.isAgreedPrivacyPolicy else false, isAgreedTermsOfService = if (it.hasIsAgreedTermsOfService()) it.isAgreedTermsOfService else false, + isEnableYTMusic = if (it.hasIsEnableYtmusic()) it.isEnableYtmusic else false, ) } @@ -121,6 +123,14 @@ class KanadePreferencesDataStore @Inject constructor( ) } + suspend fun setKanadeId(id: String) = withContext(ioDispatcher) { + userPreference.updateData { + it.copy { + this.kanadeId = id + } + } + } + suspend fun setThemeConfig(themeConfig: ThemeConfig) = withContext(ioDispatcher) { userPreference.updateData { it.copy { @@ -237,6 +247,14 @@ class KanadePreferencesDataStore @Inject constructor( } } + suspend fun setEnableYTMusic(isEnableYTMusic: Boolean) = withContext(ioDispatcher) { + userPreference.updateData { + it.copy { + this.isEnableYtmusic = isEnableYTMusic + } + } + } + suspend fun setShuffleMode(shuffleMode: ShuffleMode) = withContext(ioDispatcher) { musicPreference.updateData { it.copy { diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/DownloadPathPreference.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceDownloadPath.kt similarity index 93% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/DownloadPathPreference.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceDownloadPath.kt index 7962804e..639550af 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/DownloadPathPreference.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceDownloadPath.kt @@ -7,7 +7,7 @@ import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -class DownloadPathPreference @Inject constructor( +class PreferenceDownloadPath @Inject constructor( @ApplicationContext private val context: Context, ) { private val preference by lazy { context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE) } diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/EqualizerPreference.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceEqualizer.kt similarity index 98% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/EqualizerPreference.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceEqualizer.kt index d02424f4..8d8450b5 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/EqualizerPreference.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceEqualizer.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject -class EqualizerPreference @Inject constructor( +class PreferenceEqualizer @Inject constructor( @ApplicationContext private val context: Context, ) { private val preference by lazy { context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE) } diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/LyricsPreference.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceLyrics.kt similarity index 98% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/LyricsPreference.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceLyrics.kt index 0ebf3275..c2d27895 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/LyricsPreference.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceLyrics.kt @@ -17,7 +17,7 @@ import kotlinx.serialization.json.Json import java.io.File import javax.inject.Inject -class LyricsPreference @Inject constructor( +class PreferenceLyrics @Inject constructor( @ApplicationContext private val context: Context, @Dispatcher(KanadeDispatcher.IO) private val ioDispatcher: CoroutineDispatcher, @ApplicationScope private val scope: CoroutineScope, diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/TokenPreference.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceToken.kt similarity index 94% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/TokenPreference.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceToken.kt index 690427d9..851d1963 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/TokenPreference.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceToken.kt @@ -5,7 +5,7 @@ import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -class TokenPreference @Inject constructor( +class PreferenceToken @Inject constructor( @ApplicationContext private val context: Context, ) { private val preference by lazy { context.getSharedPreferences(PREFERENCE, Context.MODE_PRIVATE) } diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/VersionPreference.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceVersion.kt similarity index 96% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/VersionPreference.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceVersion.kt index bb2f649c..dc4d6adf 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/VersionPreference.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceVersion.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.json.Json import java.util.Locale import javax.inject.Inject -class VersionPreference @Inject constructor( +class PreferenceVersion @Inject constructor( @ApplicationContext private val context: Context, ) { private var data: List? = null diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/VolumePreference.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceVolume.kt similarity index 98% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/VolumePreference.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceVolume.kt index 63a5e424..3a600760 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/VolumePreference.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceVolume.kt @@ -17,7 +17,7 @@ import kotlinx.serialization.json.Json import java.io.File import javax.inject.Inject -class VolumePreference @Inject constructor( +class PreferenceVolume @Inject constructor( @ApplicationContext private val context: Context, @Dispatcher(KanadeDispatcher.IO) private val ioDispatcher: CoroutineDispatcher, @ApplicationScope private val scope: CoroutineScope, diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceYTMusic.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceYTMusic.kt new file mode 100644 index 00000000..62b68f96 --- /dev/null +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/PreferenceYTMusic.kt @@ -0,0 +1,40 @@ +package caios.android.kanade.core.datastore + +import android.content.Context +import caios.android.kanade.core.model.entity.YTMusicOAuthToken +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json +import java.io.File +import javax.inject.Inject + +class PreferenceYTMusic @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val tokenFile get() = File(context.filesDir, FILE_NAME) + + fun saveToken(token: YTMusicOAuthToken) { + Json.encodeToString(YTMusicOAuthToken.serializer(), token).also { + tokenFile.writeText(it) + } + } + + fun removeToken() { + tokenFile.delete() + } + + fun getToken(): YTMusicOAuthToken? { + if (!tokenFile.exists()) return null + + return tokenFile.readText().let { + Json.decodeFromString(YTMusicOAuthToken.serializer(), it) + } + } + + fun getTokenFilePath(): String { + return tokenFile.absolutePath + } + + companion object { + private const val FILE_NAME = "YTMusicToken.json" + } +} diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/di/DataStoreModule.kt index d8e02a74..757fede4 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/di/DataStoreModule.kt @@ -7,18 +7,12 @@ import androidx.datastore.dataStoreFile import caios.android.kanade.core.common.network.Dispatcher import caios.android.kanade.core.common.network.KanadeDispatcher import caios.android.kanade.core.common.network.di.ApplicationScope -import caios.android.kanade.core.datastore.DownloadPathPreference -import caios.android.kanade.core.datastore.EqualizerPreference -import caios.android.kanade.core.datastore.LyricsPreference import caios.android.kanade.core.datastore.MusicPreference -import caios.android.kanade.core.datastore.MusicPreferenceSerializer import caios.android.kanade.core.datastore.QueuePreference -import caios.android.kanade.core.datastore.QueuePreferenceSerializer -import caios.android.kanade.core.datastore.TokenPreference import caios.android.kanade.core.datastore.UserPreference -import caios.android.kanade.core.datastore.UserPreferenceSerializer -import caios.android.kanade.core.datastore.VersionPreference -import caios.android.kanade.core.datastore.VolumePreference +import caios.android.kanade.core.datastore.serializer.MusicPreferenceSerializer +import caios.android.kanade.core.datastore.serializer.QueuePreferenceSerializer +import caios.android.kanade.core.datastore.serializer.UserPreferenceSerializer import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -76,56 +70,4 @@ object DataStoreModule { produceFile = { context.dataStoreFile("queue_preference.pb") }, ) } - - @Provides - @Singleton - fun providesLyricsPreference( - @ApplicationContext context: Context, - @Dispatcher(KanadeDispatcher.IO) io: CoroutineDispatcher, - @ApplicationScope scope: CoroutineScope, - ): LyricsPreference { - return LyricsPreference(context, io, CoroutineScope(scope.coroutineContext + io)) - } - - @Provides - @Singleton - fun provideTokenPreference( - @ApplicationContext context: Context, - ): TokenPreference { - return TokenPreference(context) - } - - @Provides - @Singleton - fun provideVersionPreference( - @ApplicationContext context: Context, - ): VersionPreference { - return VersionPreference(context) - } - - @Provides - @Singleton - fun providesVolumePreference( - @ApplicationContext context: Context, - @Dispatcher(KanadeDispatcher.IO) io: CoroutineDispatcher, - @ApplicationScope scope: CoroutineScope, - ): VolumePreference { - return VolumePreference(context, io, CoroutineScope(scope.coroutineContext + io)) - } - - @Provides - @Singleton - fun providesDownloadPathPreference( - @ApplicationContext context: Context, - ): DownloadPathPreference { - return DownloadPathPreference(context) - } - - @Provides - @Singleton - fun providesEqualizerPreference( - @ApplicationContext context: Context, - ): EqualizerPreference { - return EqualizerPreference(context) - } } diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/MusicPreferenceSerializer.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/MusicPreferenceSerializer.kt similarity index 87% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/MusicPreferenceSerializer.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/MusicPreferenceSerializer.kt index 56548223..0814ca27 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/MusicPreferenceSerializer.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/MusicPreferenceSerializer.kt @@ -1,7 +1,8 @@ -package caios.android.kanade.core.datastore +package caios.android.kanade.core.datastore.serializer import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer +import caios.android.kanade.core.datastore.MusicPreference import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream import java.io.OutputStream diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/QueuePreferenceSerializer.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/QueuePreferenceSerializer.kt similarity index 87% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/QueuePreferenceSerializer.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/QueuePreferenceSerializer.kt index 4741596a..87a48ba7 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/QueuePreferenceSerializer.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/QueuePreferenceSerializer.kt @@ -1,7 +1,8 @@ -package caios.android.kanade.core.datastore +package caios.android.kanade.core.datastore.serializer import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer +import caios.android.kanade.core.datastore.QueuePreference import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream import java.io.OutputStream diff --git a/core/datastore/src/main/java/caios/android/kanade/core/datastore/UserPreferenceSerializer.kt b/core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/UserPreferenceSerializer.kt similarity index 87% rename from core/datastore/src/main/java/caios/android/kanade/core/datastore/UserPreferenceSerializer.kt rename to core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/UserPreferenceSerializer.kt index 371b2589..661918ca 100644 --- a/core/datastore/src/main/java/caios/android/kanade/core/datastore/UserPreferenceSerializer.kt +++ b/core/datastore/src/main/java/caios/android/kanade/core/datastore/serializer/UserPreferenceSerializer.kt @@ -1,7 +1,8 @@ -package caios.android.kanade.core.datastore +package caios.android.kanade.core.datastore.serializer import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer +import caios.android.kanade.core.datastore.UserPreference import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream import java.io.OutputStream diff --git a/core/datastore/src/main/proto/user_preference.proto b/core/datastore/src/main/proto/user_preference.proto index 27334fd2..eb3c43fe 100644 --- a/core/datastore/src/main/proto/user_preference.proto +++ b/core/datastore/src/main/proto/user_preference.proto @@ -22,4 +22,7 @@ message UserPreference { optional bool is_use_ignore_not_music = 11; optional bool is_agreed_privacy_policy = 12; optional bool is_agreed_terms_of_service = 13; + optional bool is_enable_ytmusic = 14; + + optional string kanade_id = 999; } diff --git a/core/datastore/src/main/res/raw/versions.json b/core/datastore/src/main/res/raw/versions.json index ee1a505d..d3961e77 100644 --- a/core/datastore/src/main/res/raw/versions.json +++ b/core/datastore/src/main/res/raw/versions.json @@ -257,5 +257,12 @@ "date": "2022/10/01", "logJp": "・軽微なバグ修正", "logEn": "・Minor bug fixes" + }, + { + "versionName": "2.0.2", + "versionCode": 134, + "date": "2022/10/16", + "logJp": "・予測型戻るアニメーションを有効にしている場合にボトムシートを戻るボタンで閉じることのできない問題を修正\n・再生待ちリスト画面とプレイリスト詳細画面にて楽曲の削除(横スワイプ)をキャンセルすることのできない問題を修正", + "logEn": "・Fixed an issue where the bottom sheet could not be closed with the back button when predictive back animation was enabled.\n・Fixed an issue where it was not possible to cancel song deletion (horizontal swipe) on the playback list screen and playlist details screen." } ] diff --git a/core/design/src/main/res/values/strings.xml b/core/design/src/main/res/values/strings.xml index df188c99..c44aa7c7 100644 --- a/core/design/src/main/res/values/strings.xml +++ b/core/design/src/main/res/values/strings.xml @@ -88,6 +88,7 @@ Search library No media matched + Youtube Music と連携中です\nインクリメンタルサーチは利用できません Inferred from the keywords you entered, we are also searching for media containing the following keywords. \n\n[%1$s] @@ -246,6 +247,7 @@ Settings + 連携 Theme App theme Change the app theme. @@ -264,6 +266,10 @@ Stop on task kill Stop playing music when exiting the app. Library + Youtube Music と連携 (未提供) + 連携を行うと、Youtube Music のデータを Kanade で使用できるようになります。この機能は次回以降のアップデートで提供予定です。 + 連携情報を削除 + Youtube Music との連携情報を削除します Song scan Scans and registers songs that the app cannot recognize. Ignore short songs @@ -288,6 +294,16 @@ Sets the app\'s theme color. Cannot be used at the same time as Dynamic Color. Development mode You are trying to turn on this mode.Please enter the developer\'s PIN.\n\n[Warning] When this mode is turned on, you will not be able to receive any support. + Google アカウントにサインイン + Youtube Music との連携には Google アカウントにサインインする必要があります。サインイン画面に表示されているコードが以下と一致しているかご確認の上、サインインを行ってください。\n\nサインイン後は Kanade を開き、「Youtube Music と連携」ボタンを押してください。 + サインイン + Youtube Music と連携 + Google アカウントにサインイン後に以下の「連携」ボタンを押してください。 + 連携 + Youtube Music と連携しました + Youtube Music と連携しましたが、この機能は次回以降のアップデートで提供されます + 連携に失敗しました + 連携情報を削除しました Select share destination diff --git a/core/model/src/main/java/caios/android/kanade/core/model/UserData.kt b/core/model/src/main/java/caios/android/kanade/core/model/UserData.kt index 8e362cbe..dec8ac7b 100644 --- a/core/model/src/main/java/caios/android/kanade/core/model/UserData.kt +++ b/core/model/src/main/java/caios/android/kanade/core/model/UserData.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable @Stable data class UserData( + val kanadeId: String, val themeConfig: ThemeConfig, val themeColorConfig: ThemeColorConfig, val isAgreedPrivacyPolicy: Boolean, @@ -17,12 +18,14 @@ data class UserData( val isStopWhenTaskkill: Boolean, val isIgnoreShortMusic: Boolean, val isIgnoreNotMusic: Boolean, + val isEnableYTMusic: Boolean, ) { val hasPrivilege get() = isPlusMode || isDeveloperMode companion object { fun dummy(): UserData { return UserData( + kanadeId = "", themeConfig = ThemeConfig.System, themeColorConfig = ThemeColorConfig.Default, isAgreedPrivacyPolicy = false, @@ -36,6 +39,7 @@ data class UserData( isStopWhenTaskkill = false, isIgnoreShortMusic = true, isIgnoreNotMusic = true, + isEnableYTMusic = false, ) } } diff --git a/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicInfo.kt b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicInfo.kt new file mode 100644 index 00000000..2564952c --- /dev/null +++ b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicInfo.kt @@ -0,0 +1,15 @@ +package caios.android.kanade.core.model.entity + +object YTMusicInfo { + const val YTM_DOMAIN = "https://music.youtube.com" + const val YTM_BASE_API = "$YTM_DOMAIN/youtubei/v1/" + const val YTM_PARAMS = "?alt=json" + const val YTM_PARAMS_KEY = "&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" + + const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0" + + const val OAUTH_SCOPE = "https://www.googleapis.com/auth/youtube" + const val OAUTH_CODE_URL = "https://www.youtube.com/o/oauth2/device/code" + const val OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" + const val OAUTH_USER_AGENT = "$USER_AGENT Cobalt/Version" +} diff --git a/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthCode.kt b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthCode.kt new file mode 100644 index 00000000..08861240 --- /dev/null +++ b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthCode.kt @@ -0,0 +1,18 @@ +package caios.android.kanade.core.model.entity + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class YTMusicOAuthCode( + @SerialName("device_code") + val deviceCode: String, + @SerialName("expires_in") + val expiresIn: Int, + @SerialName("interval") + val interval: Int, + @SerialName("user_code") + val userCode: String, + @SerialName("verification_url") + val verificationUrl: String, +) diff --git a/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthRefreshToken.kt b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthRefreshToken.kt new file mode 100644 index 00000000..0166defb --- /dev/null +++ b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthRefreshToken.kt @@ -0,0 +1,18 @@ +package caios.android.kanade.core.model.entity + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class YTMusicOAuthRefreshToken( + @SerialName("access_token") + val accessToken: String, + @SerialName("scope") + val scope: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("expires_at") + val expiresAt: Int = 0, + @SerialName("expires_in") + val expiresIn: Int = 3600, +) diff --git a/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthToken.kt b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthToken.kt new file mode 100644 index 00000000..16410538 --- /dev/null +++ b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicOAuthToken.kt @@ -0,0 +1,20 @@ +package caios.android.kanade.core.model.entity + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class YTMusicOAuthToken( + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String, + @SerialName("scope") + val scope: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("expires_at") + val expiresAt: Int = 0, + @SerialName("expires_in") + val expiresIn: Int = 3600, +) diff --git a/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicSearch.kt b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicSearch.kt new file mode 100644 index 00000000..cd9e7b58 --- /dev/null +++ b/core/model/src/main/java/caios/android/kanade/core/model/entity/YTMusicSearch.kt @@ -0,0 +1,84 @@ +package caios.android.kanade.core.model.entity + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class YTMusicSearch( + @SerialName("album") + val album: Album?, + @SerialName("artist") + val artist: String?, + @SerialName("artists") + val artists: List?, + @SerialName("author") + val author: String?, + @SerialName("browseId") + val browseId: String?, + @SerialName("category") + val category: String, + @SerialName("duration") + val duration: String?, + @SerialName("duration_seconds") + val durationSeconds: Int?, + @SerialName("feedbackTokens") + val feedbackTokens: FeedbackTokens?, + @SerialName("isExplicit") + val isExplicit: Boolean?, + @SerialName("itemCount") + val itemCount: String?, + @SerialName("name") + val name: String?, + @SerialName("radioId") + val radioId: String?, + @SerialName("resultType") + val resultType: String, + @SerialName("shuffleId") + val shuffleId: String?, + @SerialName("thumbnails") + val thumbnails: List, + @SerialName("title") + val title: String?, + @SerialName("videoId") + val videoId: String?, + @SerialName("videoType") + val videoType: String?, + @SerialName("views") + val views: String?, + @SerialName("year") + val year: String?, +) { + @Serializable + data class Album( + @SerialName("id") + val id: String?, + @SerialName("name") + val name: String, + ) + + @Serializable + data class Artist( + @SerialName("id") + val id: String?, + @SerialName("name") + val name: String, + ) + + @Serializable + data class FeedbackTokens( + @SerialName("add") + val add: String?, + @SerialName("remove") + val remove: String?, + ) + + @Serializable + data class Thumbnail( + @SerialName("height") + val height: Int, + @SerialName("url") + val url: String, + @SerialName("width") + val width: Int, + ) +} diff --git a/core/music/build.gradle.kts b/core/music/build.gradle.kts index 084c19fe..22ca2940 100644 --- a/core/music/build.gradle.kts +++ b/core/music/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("kanade.library") id("kanade.library.compose") + id("kanade.library.chaquopy") id("kanade.hilt") id("kanade.detekt") } @@ -9,6 +10,16 @@ android { namespace = "caios.android.kanade.core.music" } +chaquopy { + defaultConfig { + version = "3.8" + + pip { + install("ytmusicapi") + } + } +} + dependencies { implementation(project(":core:common")) implementation(project(":core:model")) diff --git a/core/music/src/main/java/caios/android/kanade/core/music/MusicViewModel.kt b/core/music/src/main/java/caios/android/kanade/core/music/MusicViewModel.kt index 517fe3a5..53894044 100644 --- a/core/music/src/main/java/caios/android/kanade/core/music/MusicViewModel.kt +++ b/core/music/src/main/java/caios/android/kanade/core/music/MusicViewModel.kt @@ -34,6 +34,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import timber.log.Timber +import java.util.UUID import javax.inject.Inject @Stable @@ -93,6 +94,15 @@ class MusicViewModel @Inject constructor( } } + fun initKanadeId() { + viewModelScope.launch { + val id = UUID.randomUUID().toString() + + userDataRepository.setKanadeId(id) + Timber.d("Initailize KanadeID to $id") + } + } + fun initYoutubeDL(context: Context) { viewModelScope.launch { runCatching { diff --git a/core/music/src/main/java/caios/android/kanade/core/music/YTMusic.kt b/core/music/src/main/java/caios/android/kanade/core/music/YTMusic.kt new file mode 100644 index 00000000..4e7ed4d8 --- /dev/null +++ b/core/music/src/main/java/caios/android/kanade/core/music/YTMusic.kt @@ -0,0 +1,158 @@ +package caios.android.kanade.core.music + +import android.content.Context +import caios.android.kanade.core.common.network.Dispatcher +import caios.android.kanade.core.common.network.KanadeDispatcher +import caios.android.kanade.core.common.network.util.suspendRunCatching +import caios.android.kanade.core.model.entity.YTMusicOAuthCode +import caios.android.kanade.core.model.entity.YTMusicOAuthRefreshToken +import caios.android.kanade.core.model.entity.YTMusicOAuthToken +import caios.android.kanade.core.model.entity.YTMusicSearch +import caios.android.kanade.core.repository.YTMusicRepository +import com.chaquo.python.PyObject +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import timber.log.Timber +import javax.inject.Inject + +interface YTMusic { + fun isInitialized(): Boolean + + suspend fun getOAuthCode(): Result + suspend fun getOAuthToken(code: YTMusicOAuthCode): Result + + suspend fun removeToken() + suspend fun refreshToken(token: YTMusicOAuthToken): Result + suspend fun search(query: String, filters: Filters? = null, scopes: Scopes? = null): Result> + + enum class Language(val value: String) { + ENGLISH("en"), + SPANISH("es"), + FRENCH("fr"), + ITALIAN("it"), + JAPANESE("ja"), + KOREAN("ko"), + PORTUGUESE("pt"), + RUSSIAN("ru"), + TURKISH("tr"), + CHINESE_CHINA("zh_CN"), + CHINESE_TAIWAN("zh_TW"), + } + + enum class Filters(val value: String) { + ALBUMS("albums"), + ARTISTS("artists"), + PLAYLISTS("playlists"), + COMMUNITY_PLAYLISTS("community_playlists"), + FEATURED_PLAYLIST("featured_playlists"), + SONGS("songs"), + VIDEOS("videos"), + PROFILES("profiles"), + } + + enum class Scopes(val value: String) { + LIBRARY("library"), + UPLOADS("uploads"), + } +} + +class YTMusicImpl @Inject constructor( + private val ytMusicRepository: YTMusicRepository, + @ApplicationContext private val context: Context, + @Dispatcher(KanadeDispatcher.IO) private val ioDispatcher: CoroutineDispatcher, +) : YTMusic { + + @OptIn(ExperimentalSerializationApi::class) + private val formatter = Json { + isLenient = true + prettyPrint = true + ignoreUnknownKeys = true + coerceInputValues = true + encodeDefaults = true + explicitNulls = false + } + + override fun isInitialized(): Boolean { + return ytMusicRepository.getOAuthToken() != null + } + + override suspend fun getOAuthCode(): Result = withContext(ioDispatcher) { + suspendRunCatching { + ytMusicRepository.getOAuthCode()!! + } + } + + override suspend fun getOAuthToken(code: YTMusicOAuthCode): Result = withContext(ioDispatcher) { + suspendRunCatching { + ytMusicRepository.getOAuthToken(code)!!.also { + ytMusicRepository.saveToken(it) + } + } + } + + override suspend fun removeToken() { + ytMusicRepository.removeToken() + } + + override suspend fun refreshToken(token: YTMusicOAuthToken): Result = withContext(ioDispatcher) { + suspendRunCatching { + ytMusicRepository.refreshToken(token)!! + } + } + + override suspend fun search(query: String, filters: YTMusic.Filters?, scopes: YTMusic.Scopes?): Result> = withContext(ioDispatcher) { + suspendRunCatching { + launchPythonScript { + val serializer = ListSerializer(YTMusicSearch.serializer()) + val result = it.callAttr("search", query, filters?.value, scopes?.value).toString() + .replaceSingleQuotesWithDoubleQuotes() + .replace("None", "null") + + return@launchPythonScript formatter.decodeFromString(serializer, result) + }!! + } + } + + private suspend fun launchPythonScript(action: (PyObject) -> T): T? { + return try { + if (!Python.isStarted()) { + Python.start(AndroidPlatform(context)) + } + + checkTokenExpired() + + val python = Python.getInstance() + val module = python.getModule("ytmusic") + + action.invoke(module.callAttr("YTMusicClient", ytMusicRepository.getTokenFilePath())) + } catch (e: Throwable) { + Timber.w(e) + null + } + } + + private suspend fun checkTokenExpired() { + val token = ytMusicRepository.getOAuthToken() ?: error("Token is not saved.") + + if (token.expiresAt < (System.currentTimeMillis() / 1000)) { + val refreshToken = ytMusicRepository.refreshToken(token)!! + val newToken = token.copy( + accessToken = refreshToken.accessToken, + expiresAt = refreshToken.expiresAt, + expiresIn = refreshToken.expiresIn, + ) + + ytMusicRepository.saveToken(newToken) + } + } + + private fun String.replaceSingleQuotesWithDoubleQuotes(): String { + return "'(.*?)'".toRegex().replace(this) { "\"${it.groupValues[1]}\"" } + } +} diff --git a/core/music/src/main/java/caios/android/kanade/core/music/analyzer/VolumeAnalyzer.kt b/core/music/src/main/java/caios/android/kanade/core/music/analyzer/VolumeAnalyzer.kt index 7e6824db..ff97a322 100644 --- a/core/music/src/main/java/caios/android/kanade/core/music/analyzer/VolumeAnalyzer.kt +++ b/core/music/src/main/java/caios/android/kanade/core/music/analyzer/VolumeAnalyzer.kt @@ -1,7 +1,7 @@ package caios.android.kanade.core.music.analyzer import caios.android.kanade.core.common.network.di.ApplicationScope -import caios.android.kanade.core.datastore.VolumePreference +import caios.android.kanade.core.datastore.PreferenceVolume import caios.android.kanade.core.model.music.Song import caios.android.kanade.core.model.music.Volume import caios.android.kanade.core.repository.MusicRepository @@ -19,7 +19,7 @@ import javax.inject.Inject class VolumeAnalyzer @Inject constructor( private val musicRepository: MusicRepository, - private val volumePreference: VolumePreference, + private val preferenceVolume: PreferenceVolume, @ApplicationScope private val scope: CoroutineScope, ) { private val cache = ConcurrentHashMap() @@ -27,7 +27,7 @@ class VolumeAnalyzer @Inject constructor( init { scope.launch { - volumePreference.data.collect { lyrics -> + preferenceVolume.data.collect { lyrics -> cache.clear() cache.putAll(lyrics.associateBy { it.songId }) @@ -52,7 +52,7 @@ class VolumeAnalyzer @Inject constructor( analyzeVolume(song, file!!) } }.getOrNull()?.also { - volumePreference.save(it) + preferenceVolume.save(it) } } diff --git a/core/music/src/main/java/caios/android/kanade/core/music/di/MusicModule.kt b/core/music/src/main/java/caios/android/kanade/core/music/di/MusicModule.kt index 0e40b77b..c7e93352 100644 --- a/core/music/src/main/java/caios/android/kanade/core/music/di/MusicModule.kt +++ b/core/music/src/main/java/caios/android/kanade/core/music/di/MusicModule.kt @@ -1,40 +1,30 @@ package caios.android.kanade.core.music.di -import android.content.Context -import caios.android.kanade.core.common.network.di.ApplicationScope import caios.android.kanade.core.music.MusicController import caios.android.kanade.core.music.MusicControllerImpl import caios.android.kanade.core.music.QueueManager import caios.android.kanade.core.music.QueueManagerImpl -import caios.android.kanade.core.repository.MusicRepository +import caios.android.kanade.core.music.YTMusic +import caios.android.kanade.core.music.YTMusicImpl +import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -class MusicModule { +interface MusicModule { - @Provides + @Binds @Singleton - fun provideMusicController( - musicRepository: MusicRepository, - musicQueue: QueueManager, - @ApplicationContext context: Context, - @ApplicationScope scope: CoroutineScope, - ): MusicController { - return MusicControllerImpl(musicRepository, musicQueue, context, scope) - } + fun bindsMusicController(musicController: MusicControllerImpl): MusicController - @Provides + @Binds @Singleton - fun provideQueueManager( - musicRepository: MusicRepository, - ): QueueManager { - return QueueManagerImpl(musicRepository) - } + fun bindsQueueManager(queueManager: QueueManagerImpl): QueueManager + + @Binds + @Singleton + fun bindsYTMusic(ytMusic: YTMusicImpl): YTMusic } diff --git a/core/music/src/main/python/ytmusic.py b/core/music/src/main/python/ytmusic.py new file mode 100644 index 00000000..fd50f8c1 --- /dev/null +++ b/core/music/src/main/python/ytmusic.py @@ -0,0 +1,9 @@ +from ytmusicapi import YTMusic + +class YTMusicClient: + + def __init__(self, file_path): + self.ytmusic = YTMusic(file_path) + + def search(self, query, filter, scope): + return self.ytmusic.search(query, filter=filter, scope=scope) diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/AlbumRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/AlbumRepository.kt index db6ec7de..e1b6b67e 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/AlbumRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/AlbumRepository.kt @@ -1,8 +1,16 @@ package caios.android.kanade.core.repository +import android.provider.MediaStore import caios.android.kanade.core.model.music.Album +import caios.android.kanade.core.model.music.Artwork import caios.android.kanade.core.model.music.Song import caios.android.kanade.core.model.player.MusicConfig +import caios.android.kanade.core.model.player.MusicOrder +import caios.android.kanade.core.model.player.MusicOrderOption +import caios.android.kanade.core.repository.util.sortList +import okhttp3.internal.toImmutableMap +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject interface AlbumRepository { @@ -20,3 +28,110 @@ interface AlbumRepository { fun fetchArtwork() fun albumsSort(albums: List, musicConfig: MusicConfig): List } + +class AlbumRepositoryImpl @Inject constructor( + private val songRepository: SongRepository, + private val artworkRepository: ArtworkRepository, +) : AlbumRepository { + + private val cache = ConcurrentHashMap() + + override fun clear() { + cache.clear() + } + + override fun get(albumId: Long): Album? = cache[albumId] + + override fun gets(albumIds: List): List = albumIds.mapNotNull { get(it) } + + override fun gets(): List = cache.values.toList() + + override suspend fun album(albumId: Long, musicConfig: MusicConfig): Album { + val albumArtworks = artworkRepository.albumArtworks.toImmutableMap() + val cursor = songRepository.makeCursor( + selection = MediaStore.Audio.AudioColumns.ALBUM_ID + "=?", + selectionValues = listOf(albumId.toString()), + musicOrders = getSongLoaderOrder(musicConfig), + ) + val songs = songRepository.songs(cursor) + + return Album( + album = songs.firstOrNull()?.album ?: "", + albumId = albumId, + songs = songs, + artwork = albumArtworks[albumId] ?: Artwork.Unknown, + ) + } + + override suspend fun albums(musicConfig: MusicConfig): List { + val cursor = songRepository.makeCursor( + selection = "", + selectionValues = emptyList(), + musicOrders = getSongLoaderOrder(musicConfig), + ) + val songs = songRepository.songs(cursor) + + return splitIntoAlbums(songs, musicConfig) + } + + override suspend fun albums(query: String, musicConfig: MusicConfig): List { + val cursor = songRepository.makeCursor( + selection = MediaStore.Audio.AudioColumns.ALBUM + " LIKE ?", + selectionValues = listOf("%$query%"), + musicOrders = getSongLoaderOrder(musicConfig), + ) + val songs = songRepository.songs(cursor) + + return splitIntoAlbums(songs, musicConfig) + } + + override fun splitIntoAlbums(songs: List, musicConfig: MusicConfig): List { + val albumArtworks = artworkRepository.albumArtworks.toImmutableMap() + val albums = songs + .groupBy { it.albumId } + .map { (albumId, songs) -> + Album( + album = songs.first().album, + albumId = albumId, + songs = songs, + artwork = albumArtworks[albumId] ?: Artwork.Unknown, + ).also { + cache[albumId] = it + } + } + + return albumsSort(albums, musicConfig) + } + + override fun fetchArtwork() { + for ((albumId, artwork) in artworkRepository.albumArtworks.toImmutableMap()) { + val data = cache[albumId] ?: continue + if (data.artwork == artwork) continue + + val songs = data.songs.mapNotNull { songRepository.get(it.id) } + + cache[albumId] = data.copy( + artwork = artwork, + songs = songs, + ) + } + } + + override fun albumsSort(albums: List, musicConfig: MusicConfig): List { + val order = musicConfig.albumOrder + val option = order.option + + require(option is MusicOrderOption.Album) { "MusicOrderOption is not Album" } + + return when (option) { + MusicOrderOption.Album.NAME -> albums.sortList({ it.album }, order = order.order) + MusicOrderOption.Album.TRACKS -> albums.sortList({ it.songs.size }, { it.album }, order = order.order) + MusicOrderOption.Album.ARTIST -> albums.sortList({ it.artist }, { it.album }, order = order.order) + MusicOrderOption.Album.YEAR -> albums.sortList({ it.year }, { it.album }, order = order.order) + } + } + + private fun getSongLoaderOrder(musicConfig: MusicConfig): Array { + return arrayOf(musicConfig.songOrder) + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/ArtistRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/ArtistRepository.kt index 9eb81cfa..43dd95af 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/ArtistRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/ArtistRepository.kt @@ -1,8 +1,16 @@ package caios.android.kanade.core.repository +import android.provider.MediaStore import caios.android.kanade.core.model.music.Artist +import caios.android.kanade.core.model.music.Artwork import caios.android.kanade.core.model.music.Song import caios.android.kanade.core.model.player.MusicConfig +import caios.android.kanade.core.model.player.MusicOrder +import caios.android.kanade.core.model.player.MusicOrderOption +import caios.android.kanade.core.repository.util.sortList +import okhttp3.internal.toImmutableMap +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject interface ArtistRepository { @@ -21,3 +29,118 @@ interface ArtistRepository { fun fetchArtwork() fun artistsSort(artists: List, musicConfig: MusicConfig): List } + +class ArtistRepositoryImpl @Inject constructor( + private val songRepository: SongRepository, + private val albumRepository: AlbumRepository, + private val artworkRepository: ArtworkRepository, +) : ArtistRepository { + + private val cache = ConcurrentHashMap() + + override fun clear() { + cache.clear() + } + + override fun get(artistId: Long): Artist? = cache[artistId] + + override fun gets(artistIds: List): List = artistIds.mapNotNull { get(it) } + + override fun gets(): List = cache.values.toList() + + override suspend fun artist(artistId: Long, musicConfig: MusicConfig): Artist { + val artistArtworks = artworkRepository.artistArtworks.toImmutableMap() + val cursor = songRepository.makeCursor( + selection = MediaStore.Audio.AudioColumns.ARTIST_ID + "=?", + selectionValues = listOf(artistId.toString()), + musicOrders = getSongLoaderOrder(musicConfig), + ) + val songs = songRepository.songs(cursor) + + return Artist( + artist = songs.firstOrNull()?.artist ?: "", + artistId = artistId, + albums = albumRepository.splitIntoAlbums(songs, musicConfig), + artwork = artistArtworks[artistId] ?: Artwork.Unknown, + ) + } + + override suspend fun artists(musicConfig: MusicConfig): List { + val cursor = songRepository.makeCursor( + selection = "", + selectionValues = emptyList(), + musicOrders = getSongLoaderOrder(musicConfig), + ) + val songs = songRepository.songs(cursor) + + return splitIntoArtists(songs, musicConfig) + } + + override suspend fun artists(query: String, musicConfig: MusicConfig): List { + val cursor = songRepository.makeCursor( + selection = MediaStore.Audio.AudioColumns.ARTIST + " LIKE ?", + selectionValues = listOf("%$query%"), + musicOrders = getSongLoaderOrder(musicConfig), + ) + val songs = songRepository.songs(cursor) + + return splitIntoArtists(songs, musicConfig) + } + + override fun splitIntoArtists(songs: List, musicConfig: MusicConfig): List { + val artistArtworks = artworkRepository.artistArtworks.toImmutableMap() + val albums = albumRepository.splitIntoAlbums(songs, musicConfig) + val artists = albums + .groupBy { it.artistId } + .map { (artistId, albums) -> + Artist( + artist = albums.first().artist, + artistId = artistId, + albums = albums, + artwork = artistArtworks[artistId] ?: Artwork.Unknown, + ).also { + cache[artistId] = it + } + } + + return artistsSort(artists, musicConfig) + } + + override fun fetchArtwork() { + val albumArtworks = artworkRepository.albumArtworks.toImmutableMap() + + for ((artistId, artwork) in artworkRepository.artistArtworks.toImmutableMap()) { + val data = cache[artistId] ?: continue + if (data.artwork == artwork) continue + + val albums = data.albums.map { + it.copy( + artwork = albumArtworks[it.albumId] ?: Artwork.Unknown, + songs = it.songs.mapNotNull { song -> songRepository.get(song.id) }, + ) + } + + cache[artistId] = data.copy( + artwork = artwork, + albums = albums, + ) + } + } + + override fun artistsSort(artists: List, musicConfig: MusicConfig): List { + val order = musicConfig.artistOrder + val option = order.option + + require(option is MusicOrderOption.Artist) { "MusicOrderOption is not Artist" } + + return when (option) { + MusicOrderOption.Artist.NAME -> artists.sortList({ it.artist }, order = order.order) + MusicOrderOption.Artist.TRACKS -> artists.sortList({ it.songs.size }, order = order.order) + MusicOrderOption.Artist.ALBUMS -> artists.sortList({ it.albums.size }, order = order.order) + } + } + + private fun getSongLoaderOrder(musicConfig: MusicConfig): Array { + return arrayOf(musicConfig.songOrder) + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/ArtworkRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/ArtworkRepository.kt index 8f84306e..37ab05b1 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/ArtworkRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/ArtworkRepository.kt @@ -1,8 +1,21 @@ package caios.android.kanade.core.repository +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import caios.android.kanade.core.common.network.Dispatcher +import caios.android.kanade.core.common.network.KanadeDispatcher +import caios.android.kanade.core.database.artwork.ArtworkDao +import caios.android.kanade.core.database.artwork.ArtworkEntity import caios.android.kanade.core.model.music.Album import caios.android.kanade.core.model.music.Artist import caios.android.kanade.core.model.music.Artwork +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject interface ArtworkRepository { @@ -23,3 +36,147 @@ interface ArtworkRepository { suspend fun fetchArtistArtwork(artists: List): Boolean suspend fun fetchAlbumArtwork(albums: List): Boolean } + +class ArtworkRepositoryImpl @Inject constructor( + private val artworkDao: ArtworkDao, + @ApplicationContext private val context: Context, + @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, +) : ArtworkRepository { + + private var _artistArtwork = mutableMapOf() + private var _albumArtwork = mutableMapOf() + + override val artistArtworks: Map + get() = _artistArtwork.toMap() + + override val albumArtworks: Map + get() = _albumArtwork.toMap() + + override fun clear() { + _artistArtwork.clear() + _albumArtwork.clear() + } + + override suspend fun artistArtworks(): Map = withContext(dispatcher) { + artworkDao.loadArtists().associate { entity -> + entity.artistId!! to entity.toArtwork() + } + } + + override suspend fun albumArtworks(): Map = withContext(dispatcher) { + artworkDao.loadAlbums().associate { entity -> + entity.albumId!! to entity.toArtwork() + } + } + + override suspend fun artistArtworks(artistIds: List): Map = withContext(dispatcher) { + artworkDao.loadArtists(artistIds).associate { entity -> + entity.artistId!! to entity.toArtwork() + } + } + + override suspend fun albumArtworks(albumIds: List): Map = withContext(dispatcher) { + artworkDao.loadAlbums(albumIds).associate { entity -> + entity.albumId!! to entity.toArtwork() + } + } + + override suspend fun artistArtwork(artistId: Long): Artwork = withContext(dispatcher) { + artworkDao.loadArtist(artistId)?.toArtwork() ?: Artwork.Unknown + } + + override suspend fun albumArtwork(albumId: Long): Artwork = withContext(dispatcher) { + artworkDao.loadAlbum(albumId)?.toArtwork() ?: Artwork.Unknown + } + + override suspend fun fetchArtistArtwork(artists: List): Boolean = withContext(dispatcher) { + val registeredArtworks = artworkDao.loadArtists() + val registeredIds = registeredArtworks.map { it.artistId } + val uris = artists.filterNot { registeredIds.contains(it.artistId) } + + if (uris.isEmpty()) { + Timber.d("Don't necessarily to fetch artist artwork. [fetched=${registeredIds.size}]") + + applyArtistArtworks(artworkDao.loadArtists().associate { it.artistId!! to it.toArtwork() }) + + return@withContext false + } + + val entities = uris.map { + ArtworkEntity( + id = 0, + artistId = it.artistId, + internal = it.artist, + ) + } + + artworkDao.insert(*entities.toTypedArray()) + + applyArtistArtworks(artworkDao.loadArtists().associate { it.artistId!! to it.toArtwork() }) + + return@withContext true + } + + override suspend fun fetchAlbumArtwork(albums: List): Boolean = withContext(dispatcher) { + val registeredIds = artworkDao.loadAlbums().map { it.albumId } + val uris = albums + .filterNot { registeredIds.contains(it.albumId) } + .associateWith { getMediaStoreAlbumCoverUri(it.albumId) } + + if (uris.isEmpty()) { + Timber.d("Don't necessarily to fetch album artwork. [fetched=${registeredIds.size}]") + + applyAlbumArtworks(artworkDao.loadAlbums().associate { it.albumId!! to it.toArtwork() }) + + return@withContext false + } + + val entities = uris.map { + ArtworkEntity( + id = 0, + albumId = it.key.albumId, + mediaStore = it.value?.toString(), + internal = it.key.album, + ) + } + + artworkDao.insert(*entities.toTypedArray()) + + applyAlbumArtworks(artworkDao.loadAlbums().associate { it.albumId!! to it.toArtwork() }) + + return@withContext true + } + + private fun ArtworkEntity.toArtwork(): Artwork { + return when { + !web.isNullOrBlank() -> Artwork.Web(web!!) + !mediaStore.isNullOrBlank() -> Artwork.MediaStore(mediaStore!!.toUri()) + !internal.isNullOrBlank() -> Artwork.Internal(internal!!) + else -> Artwork.Unknown + } + } + + private fun applyArtistArtworks(artworks: Map) { + _artistArtwork = artworks.toMutableMap() + } + + private fun applyAlbumArtworks(artworks: Map) { + _albumArtwork = artworks.toMutableMap() + } + + private fun getMediaStoreAlbumCoverUri(albumId: Long): Uri? { + val sArtworkUri = "content://media/external/audio/albumart".toUri() + val uri = ContentUris.withAppendedId(sArtworkUri, albumId) + + return if (isExistUri(uri)) uri else null + } + + private fun isExistUri(uri: Uri): Boolean { + return runCatching { + context.contentResolver.openInputStream(uri) + }.fold( + onSuccess = { true }, + onFailure = { false }, + ) + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultAlbumRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultAlbumRepository.kt deleted file mode 100644 index 6b37df07..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultAlbumRepository.kt +++ /dev/null @@ -1,120 +0,0 @@ -package caios.android.kanade.core.repository - -import android.provider.MediaStore.Audio.AudioColumns -import caios.android.kanade.core.model.music.Album -import caios.android.kanade.core.model.music.Artwork -import caios.android.kanade.core.model.music.Song -import caios.android.kanade.core.model.player.MusicConfig -import caios.android.kanade.core.model.player.MusicOrder -import caios.android.kanade.core.model.player.MusicOrderOption -import caios.android.kanade.core.repository.util.sortList -import okhttp3.internal.toImmutableMap -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject - -class DefaultAlbumRepository @Inject constructor( - private val songRepository: SongRepository, - private val artworkRepository: ArtworkRepository, -) : AlbumRepository { - - private val cache = ConcurrentHashMap() - - override fun clear() { - cache.clear() - } - - override fun get(albumId: Long): Album? = cache[albumId] - - override fun gets(albumIds: List): List = albumIds.mapNotNull { get(it) } - - override fun gets(): List = cache.values.toList() - - override suspend fun album(albumId: Long, musicConfig: MusicConfig): Album { - val albumArtworks = artworkRepository.albumArtworks.toImmutableMap() - val cursor = songRepository.makeCursor( - selection = AudioColumns.ALBUM_ID + "=?", - selectionValues = listOf(albumId.toString()), - musicOrders = getSongLoaderOrder(musicConfig), - ) - val songs = songRepository.songs(cursor) - - return Album( - album = songs.firstOrNull()?.album ?: "", - albumId = albumId, - songs = songs, - artwork = albumArtworks[albumId] ?: Artwork.Unknown, - ) - } - - override suspend fun albums(musicConfig: MusicConfig): List { - val cursor = songRepository.makeCursor( - selection = "", - selectionValues = emptyList(), - musicOrders = getSongLoaderOrder(musicConfig), - ) - val songs = songRepository.songs(cursor) - - return splitIntoAlbums(songs, musicConfig) - } - - override suspend fun albums(query: String, musicConfig: MusicConfig): List { - val cursor = songRepository.makeCursor( - selection = AudioColumns.ALBUM + " LIKE ?", - selectionValues = listOf("%$query%"), - musicOrders = getSongLoaderOrder(musicConfig), - ) - val songs = songRepository.songs(cursor) - - return splitIntoAlbums(songs, musicConfig) - } - - override fun splitIntoAlbums(songs: List, musicConfig: MusicConfig): List { - val albumArtworks = artworkRepository.albumArtworks.toImmutableMap() - val albums = songs - .groupBy { it.albumId } - .map { (albumId, songs) -> - Album( - album = songs.first().album, - albumId = albumId, - songs = songs, - artwork = albumArtworks[albumId] ?: Artwork.Unknown, - ).also { - cache[albumId] = it - } - } - - return albumsSort(albums, musicConfig) - } - - override fun fetchArtwork() { - for ((albumId, artwork) in artworkRepository.albumArtworks.toImmutableMap()) { - val data = cache[albumId] ?: continue - if (data.artwork == artwork) continue - - val songs = data.songs.mapNotNull { songRepository.get(it.id) } - - cache[albumId] = data.copy( - artwork = artwork, - songs = songs, - ) - } - } - - override fun albumsSort(albums: List, musicConfig: MusicConfig): List { - val order = musicConfig.albumOrder - val option = order.option - - require(option is MusicOrderOption.Album) { "MusicOrderOption is not Album" } - - return when (option) { - MusicOrderOption.Album.NAME -> albums.sortList({ it.album }, order = order.order) - MusicOrderOption.Album.TRACKS -> albums.sortList({ it.songs.size }, { it.album }, order = order.order) - MusicOrderOption.Album.ARTIST -> albums.sortList({ it.artist }, { it.album }, order = order.order) - MusicOrderOption.Album.YEAR -> albums.sortList({ it.year }, { it.album }, order = order.order) - } - } - - private fun getSongLoaderOrder(musicConfig: MusicConfig): Array { - return arrayOf(musicConfig.songOrder) - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultArtistRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultArtistRepository.kt deleted file mode 100644 index 14fcdec8..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultArtistRepository.kt +++ /dev/null @@ -1,128 +0,0 @@ -package caios.android.kanade.core.repository - -import android.provider.MediaStore.Audio.AudioColumns -import caios.android.kanade.core.model.music.Artist -import caios.android.kanade.core.model.music.Artwork -import caios.android.kanade.core.model.music.Song -import caios.android.kanade.core.model.player.MusicConfig -import caios.android.kanade.core.model.player.MusicOrder -import caios.android.kanade.core.model.player.MusicOrderOption -import caios.android.kanade.core.repository.util.sortList -import okhttp3.internal.toImmutableMap -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject - -class DefaultArtistRepository @Inject constructor( - private val songRepository: SongRepository, - private val albumRepository: AlbumRepository, - private val artworkRepository: ArtworkRepository, -) : ArtistRepository { - - private val cache = ConcurrentHashMap() - - override fun clear() { - cache.clear() - } - - override fun get(artistId: Long): Artist? = cache[artistId] - - override fun gets(artistIds: List): List = artistIds.mapNotNull { get(it) } - - override fun gets(): List = cache.values.toList() - - override suspend fun artist(artistId: Long, musicConfig: MusicConfig): Artist { - val artistArtworks = artworkRepository.artistArtworks.toImmutableMap() - val cursor = songRepository.makeCursor( - selection = AudioColumns.ARTIST_ID + "=?", - selectionValues = listOf(artistId.toString()), - musicOrders = getSongLoaderOrder(musicConfig), - ) - val songs = songRepository.songs(cursor) - - return Artist( - artist = songs.firstOrNull()?.artist ?: "", - artistId = artistId, - albums = albumRepository.splitIntoAlbums(songs, musicConfig), - artwork = artistArtworks[artistId] ?: Artwork.Unknown, - ) - } - - override suspend fun artists(musicConfig: MusicConfig): List { - val cursor = songRepository.makeCursor( - selection = "", - selectionValues = emptyList(), - musicOrders = getSongLoaderOrder(musicConfig), - ) - val songs = songRepository.songs(cursor) - - return splitIntoArtists(songs, musicConfig) - } - - override suspend fun artists(query: String, musicConfig: MusicConfig): List { - val cursor = songRepository.makeCursor( - selection = AudioColumns.ARTIST + " LIKE ?", - selectionValues = listOf("%$query%"), - musicOrders = getSongLoaderOrder(musicConfig), - ) - val songs = songRepository.songs(cursor) - - return splitIntoArtists(songs, musicConfig) - } - - override fun splitIntoArtists(songs: List, musicConfig: MusicConfig): List { - val artistArtworks = artworkRepository.artistArtworks.toImmutableMap() - val albums = albumRepository.splitIntoAlbums(songs, musicConfig) - val artists = albums - .groupBy { it.artistId } - .map { (artistId, albums) -> - Artist( - artist = albums.first().artist, - artistId = artistId, - albums = albums, - artwork = artistArtworks[artistId] ?: Artwork.Unknown, - ).also { - cache[artistId] = it - } - } - - return artistsSort(artists, musicConfig) - } - - override fun fetchArtwork() { - val albumArtworks = artworkRepository.albumArtworks.toImmutableMap() - - for ((artistId, artwork) in artworkRepository.artistArtworks.toImmutableMap()) { - val data = cache[artistId] ?: continue - if (data.artwork == artwork) continue - - val albums = data.albums.map { - it.copy( - artwork = albumArtworks[it.albumId] ?: Artwork.Unknown, - songs = it.songs.mapNotNull { song -> songRepository.get(song.id) }, - ) - } - - cache[artistId] = data.copy( - artwork = artwork, - albums = albums, - ) - } - } - - override fun artistsSort(artists: List, musicConfig: MusicConfig): List { - val order = musicConfig.artistOrder - val option = order.option - - require(option is MusicOrderOption.Artist) { "MusicOrderOption is not Artist" } - - return when (option) { - MusicOrderOption.Artist.NAME -> artists.sortList({ it.artist }, order = order.order) - MusicOrderOption.Artist.TRACKS -> artists.sortList({ it.songs.size }, order = order.order) - MusicOrderOption.Artist.ALBUMS -> artists.sortList({ it.albums.size }, order = order.order) - } - } - - private fun getSongLoaderOrder(musicConfig: MusicConfig): Array { - return arrayOf(musicConfig.songOrder) - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultArtworkRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultArtworkRepository.kt deleted file mode 100644 index c7ceada0..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultArtworkRepository.kt +++ /dev/null @@ -1,162 +0,0 @@ -package caios.android.kanade.core.repository - -import android.content.ContentUris -import android.content.Context -import android.net.Uri -import androidx.core.net.toUri -import caios.android.kanade.core.common.network.Dispatcher -import caios.android.kanade.core.common.network.KanadeDispatcher -import caios.android.kanade.core.database.artwork.ArtworkDao -import caios.android.kanade.core.database.artwork.ArtworkEntity -import caios.android.kanade.core.model.music.Album -import caios.android.kanade.core.model.music.Artist -import caios.android.kanade.core.model.music.Artwork -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import timber.log.Timber -import javax.inject.Inject - -class DefaultArtworkRepository @Inject constructor( - private val artworkDao: ArtworkDao, - @ApplicationContext private val context: Context, - @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, -) : ArtworkRepository { - - private var _artistArtwork = mutableMapOf() - private var _albumArtwork = mutableMapOf() - - override val artistArtworks: Map - get() = _artistArtwork.toMap() - - override val albumArtworks: Map - get() = _albumArtwork.toMap() - - override fun clear() { - _artistArtwork.clear() - _albumArtwork.clear() - } - - override suspend fun artistArtworks(): Map = withContext(dispatcher) { - artworkDao.loadArtists().associate { entity -> - entity.artistId!! to entity.toArtwork() - } - } - - override suspend fun albumArtworks(): Map = withContext(dispatcher) { - artworkDao.loadAlbums().associate { entity -> - entity.albumId!! to entity.toArtwork() - } - } - - override suspend fun artistArtworks(artistIds: List): Map = withContext(dispatcher) { - artworkDao.loadArtists(artistIds).associate { entity -> - entity.artistId!! to entity.toArtwork() - } - } - - override suspend fun albumArtworks(albumIds: List): Map = withContext(dispatcher) { - artworkDao.loadAlbums(albumIds).associate { entity -> - entity.albumId!! to entity.toArtwork() - } - } - - override suspend fun artistArtwork(artistId: Long): Artwork = withContext(dispatcher) { - artworkDao.loadArtist(artistId)?.toArtwork() ?: Artwork.Unknown - } - - override suspend fun albumArtwork(albumId: Long): Artwork = withContext(dispatcher) { - artworkDao.loadAlbum(albumId)?.toArtwork() ?: Artwork.Unknown - } - - override suspend fun fetchArtistArtwork(artists: List): Boolean = withContext(dispatcher) { - val registeredArtworks = artworkDao.loadArtists() - val registeredIds = registeredArtworks.map { it.artistId } - val uris = artists.filterNot { registeredIds.contains(it.artistId) } - - if (uris.isEmpty()) { - Timber.d("Don't necessarily to fetch artist artwork. [fetched=${registeredIds.size}]") - - applyArtistArtworks(artworkDao.loadArtists().associate { it.artistId!! to it.toArtwork() }) - - return@withContext false - } - - val entities = uris.map { - ArtworkEntity( - id = 0, - artistId = it.artistId, - internal = it.artist, - ) - } - - artworkDao.insert(*entities.toTypedArray()) - - applyArtistArtworks(artworkDao.loadArtists().associate { it.artistId!! to it.toArtwork() }) - - return@withContext true - } - - override suspend fun fetchAlbumArtwork(albums: List): Boolean = withContext(dispatcher) { - val registeredIds = artworkDao.loadAlbums().map { it.albumId } - val uris = albums - .filterNot { registeredIds.contains(it.albumId) } - .associateWith { getMediaStoreAlbumCoverUri(it.albumId) } - - if (uris.isEmpty()) { - Timber.d("Don't necessarily to fetch album artwork. [fetched=${registeredIds.size}]") - - applyAlbumArtworks(artworkDao.loadAlbums().associate { it.albumId!! to it.toArtwork() }) - - return@withContext false - } - - val entities = uris.map { - ArtworkEntity( - id = 0, - albumId = it.key.albumId, - mediaStore = it.value?.toString(), - internal = it.key.album, - ) - } - - artworkDao.insert(*entities.toTypedArray()) - - applyAlbumArtworks(artworkDao.loadAlbums().associate { it.albumId!! to it.toArtwork() }) - - return@withContext true - } - - private fun ArtworkEntity.toArtwork(): Artwork { - return when { - !web.isNullOrBlank() -> Artwork.Web(web!!) - !mediaStore.isNullOrBlank() -> Artwork.MediaStore(mediaStore!!.toUri()) - !internal.isNullOrBlank() -> Artwork.Internal(internal!!) - else -> Artwork.Unknown - } - } - - private fun applyArtistArtworks(artworks: Map) { - _artistArtwork = artworks.toMutableMap() - } - - private fun applyAlbumArtworks(artworks: Map) { - _albumArtwork = artworks.toMutableMap() - } - - private fun getMediaStoreAlbumCoverUri(albumId: Long): Uri? { - val sArtworkUri = "content://media/external/audio/albumart".toUri() - val uri = ContentUris.withAppendedId(sArtworkUri, albumId) - - return if (isExistUri(uri)) uri else null - } - - private fun isExistUri(uri: Uri): Boolean { - return runCatching { - context.contentResolver.openInputStream(uri) - }.fold( - onSuccess = { true }, - onFailure = { false }, - ) - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultExternalPlaylistRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultExternalPlaylistRepository.kt deleted file mode 100644 index a6afbee4..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultExternalPlaylistRepository.kt +++ /dev/null @@ -1,120 +0,0 @@ -package caios.android.kanade.core.repository - -import android.content.ContentValues -import android.content.Context -import android.provider.MediaStore -import caios.android.kanade.core.model.music.ExternalPlaylist -import caios.android.kanade.core.model.music.Playlist -import caios.android.kanade.core.model.music.PlaylistItem -import dagger.hilt.android.qualifiers.ApplicationContext -import java.time.LocalDateTime -import javax.inject.Inject - -class DefaultExternalPlaylistRepository @Inject constructor( - private val songRepository: SongRepository, - private val playlistRepository: PlaylistRepository, - @ApplicationContext private val context: Context, -) : ExternalPlaylistRepository { - - private val contentResolver get() = context.contentResolver - - override suspend fun getExternalPlaylists(): List { - val uri = MediaStore.Audio.Playlists.getContentUri("external") - val cursor = contentResolver.query(uri, null, null, null, "") - - if (cursor == null || !cursor.moveToFirst()) return emptyList() - - val playlistNameColumn = cursor.getColumnIndex(MediaStore.Audio.Playlists.NAME) - val playlistIdColumn = cursor.getColumnIndex(MediaStore.Audio.Playlists._ID) - val playlistDataList = mutableListOf() - - do { - val playlistName = cursor.getString(playlistNameColumn) - val playlistId = cursor.getLong(playlistIdColumn) - - val playlistUri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId) - val playlistCursor = contentResolver.query(playlistUri, null, null, null, "") - - if (playlistCursor != null && playlistCursor.moveToFirst()) { - val songIds = mutableListOf() - val idColumn = playlistCursor.getColumnIndex(MediaStore.Audio.Playlists.Members.AUDIO_ID) - - do { - val id = playlistCursor.getInt(idColumn) - songIds.add(id.toLong()) - } while (playlistCursor.moveToNext()) - - playlistDataList.add(ExternalPlaylist(playlistId, playlistName, songIds)) - } else { - playlistDataList.add(ExternalPlaylist(playlistId, playlistName, emptyList())) - } - - playlistCursor?.close() - } while (cursor.moveToNext()) - - cursor.close() - - return playlistDataList - } - - override suspend fun export(playlist: Playlist) { - val beforePlaylists = getExternalPlaylists() - - val uri = MediaStore.Audio.Playlists.getContentUri("external") - val contentValue = ContentValues().apply { - put(MediaStore.Audio.Playlists.NAME, playlist.name) - } - - contentResolver.insert(uri, contentValue) - - val afterPlaylists = getExternalPlaylists() - val playlistId = (afterPlaylists - beforePlaylists.toSet()).elementAtOrNull(0)?.id ?: return - - addMusic(playlistId, playlist.songs.map { it.id }) - } - - override suspend fun import(externalPlaylistId: Long) { - val externalPlaylist = getExternalPlaylists().find { it.id == externalPlaylistId }!! - val songs = externalPlaylist.songIds.mapNotNull { songRepository.get(it) } - val items = songs.mapIndexed { index, song -> PlaylistItem(0, song, index) } - val playlist = Playlist(0, externalPlaylist.name, items.toSet(), createdAt = LocalDateTime.now()) - - playlistRepository.create(playlist) - } - - private fun addMusic(playlistId: Long, songIds: List) { - var nextOrder = getMaxPlayOrder(playlistId)?.plus(1) ?: 0 - val playlistUri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId) - - for (musicId in songIds) { - contentResolver.insert( - playlistUri, - ContentValues().apply { - put(MediaStore.Audio.Playlists.Members.AUDIO_ID, musicId) - put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, nextOrder) - }, - ) - - nextOrder += 1 - } - } - - private fun getMaxPlayOrder(playlistId: Long): Int? { - val playlistUri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId) - val playlistCursor = contentResolver.query(playlistUri, null, null, null, "") - - if (playlistCursor == null || !playlistCursor.moveToFirst()) return null - - val orderList = mutableListOf() - val orderColumn = playlistCursor.getColumnIndex(MediaStore.Audio.Playlists.Members.PLAY_ORDER) - - do { - val order = playlistCursor.getInt(orderColumn) - orderList.add(order) - } while (playlistCursor.moveToNext()) - - playlistCursor.close() - - return orderList.maxOrNull() - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultLastFmRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultLastFmRepository.kt deleted file mode 100644 index 0c9f262a..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultLastFmRepository.kt +++ /dev/null @@ -1,341 +0,0 @@ -package caios.android.kanade.core.repository - -import Ignores -import android.content.Context -import caios.android.kanade.core.common.network.Dispatcher -import caios.android.kanade.core.common.network.KanadeConfig -import caios.android.kanade.core.common.network.KanadeDispatcher -import caios.android.kanade.core.database.album_detail.AlbumDetailDao -import caios.android.kanade.core.database.album_detail.AlbumDetailEntity -import caios.android.kanade.core.database.album_detail.AlbumDetailModel -import caios.android.kanade.core.database.album_detail.AlbumTagEntity -import caios.android.kanade.core.database.album_detail.AlbumTrackEntity -import caios.android.kanade.core.database.artist_detail.ArtistDetailDao -import caios.android.kanade.core.database.artist_detail.ArtistDetailEntity -import caios.android.kanade.core.database.artist_detail.ArtistDetailModel -import caios.android.kanade.core.database.artist_detail.ArtistTagEntity -import caios.android.kanade.core.database.artist_detail.SimilarArtistEntity -import caios.android.kanade.core.database.artwork.ArtworkDao -import caios.android.kanade.core.database.artwork.ArtworkEntity -import caios.android.kanade.core.model.entity.LastFmAlbumDetailEntity -import caios.android.kanade.core.model.entity.LastFmArtistDetailEntity -import caios.android.kanade.core.model.music.Album -import caios.android.kanade.core.model.music.AlbumDetail -import caios.android.kanade.core.model.music.Artist -import caios.android.kanade.core.model.music.ArtistDetail -import caios.android.kanade.core.model.music.FmTag -import caios.android.kanade.core.repository.util.parse -import dagger.hilt.android.qualifiers.ApplicationContext -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.request.url -import io.ktor.client.statement.bodyAsText -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import org.jsoup.Jsoup -import timber.log.Timber -import java.io.File -import javax.inject.Inject - -class DefaultLastFmRepository @Inject constructor( - private val client: HttpClient, - private val artistRepository: ArtistRepository, - private val albumRepository: AlbumRepository, - private val artistDetailDao: ArtistDetailDao, - private val albumDetailDao: AlbumDetailDao, - private val artworkDao: ArtworkDao, - private val kanadeConfig: KanadeConfig, - @ApplicationContext private val context: Context, - @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, -) : LastFmRepository { - - private val formatter = Json { ignoreUnknownKeys = true } - - private val _artistDetail = MutableStateFlow(mutableMapOf()) - private val _albumDetail = MutableStateFlow(mutableMapOf()) - - override val artistDetails: SharedFlow> = _artistDetail.asSharedFlow() - override val albumDetails: SharedFlow> = _albumDetail.asSharedFlow() - - override fun getArtistDetails(): Map = _artistDetail.value.toMap() - override fun getAlbumDetails(): Map = _albumDetail.value.toMap() - - override suspend fun artistDetail(artist: Artist): ArtistDetail? = withContext(dispatcher) { - _artistDetail.value[artist.artistId] ?: kotlin.runCatching { - fetchArtistInfo(artist.artist)?.let { - ArtistDetail( - data = artist, - mbid = it.artist.mbid, - url = it.artist.url, - imageUrl = fetchArtistArtwork(it.artist.url), - tags = it.artist.tags.tag.map { tag -> FmTag(name = tag.name, url = tag.url) }, - similarArtists = it.artist.similar.artist.map { artist -> - ArtistDetail.SimilarArtist( - name = artist.name, - url = artist.url, - imageUrl = artist.image.findLast { image -> image.text.isNotBlank() }?.text, - ) - }, - biography = it.artist.biography.content.replace(Regex(""), "").ifBlank { null }, - ) - } - }.getOrNull()?.also { - saveArtistDetail(it) - _artistDetail.value = _artistDetail.value.toMutableMap().apply { this[artist.artistId] = it } - } - } - - override suspend fun albumDetail(album: Album): AlbumDetail? = withContext(dispatcher) { - _albumDetail.value[album.albumId] ?: kotlin.runCatching { - fetchAlbumInfo(album.artist, album.album)?.let { - AlbumDetail( - data = album, - mbid = it.album.mbid, - imageUrl = it.album.images.findLast { image -> image.text.isNotBlank() }?.text, - tags = it.album.tags.tag.map { tag -> FmTag(tag.name, tag.url) }, - tracks = it.album.tracks.track.map { track -> - AlbumDetail.Track( - track = track.attr.rank, - musicName = track.name, - url = track.url, - ) - }, - ) - } - }.getOrNull()?.also { - saveAlbumDetail(it) - _albumDetail.value = _albumDetail.value.toMutableMap().apply { this[album.albumId] = it } - } - } - - override suspend fun fetchArtistDetails(): Unit = withContext(dispatcher) { - artistDetailDao.loadAll().map { it.toData() }.onEach { detail -> - if (detail != null) { - _artistDetail.value[detail.data.artistId] = detail - } - } - } - - override suspend fun fetchAlbumDetails(): Unit = withContext(dispatcher) { - albumDetailDao.loadAll().map { it.toData() }.onEach { detail -> - if (detail != null) { - _albumDetail.value[detail.data.albumId] = detail - } - } - } - - private suspend fun fetchAlbumInfo(artistName: String, albumName: String): LastFmAlbumDetailEntity? { - return try { - client.get { - url(ENDPOINT) - parameter("api_key", kanadeConfig.lastFmApiKey) - parameter("lang", "ja") - parameter("autocorrect", 1) - parameter("format", "json") - parameter("artist", artistName) - parameter("album", albumName) - parameter("method", "album.getinfo") - }.parse() - } catch (e: Throwable) { - Timber.w(e) - null - } - } - - private suspend fun fetchArtistInfo(artistName: String): LastFmArtistDetailEntity? { - return try { - client.get { - url(ENDPOINT) - parameter("api_key", kanadeConfig.lastFmApiKey) - parameter("lang", "ja") - parameter("autocorrect", 1) - parameter("format", "json") - parameter("artist", artistName) - parameter("method", "artist.getinfo") - }.parse() - } catch (e: Throwable) { - Timber.w(e) - null - } - } - - private suspend fun fetchArtistArtwork(artistFmUrl: String): String? { - return runCatching { - val html = client.get(artistFmUrl).bodyAsText() - val doc = Jsoup.parse(html) - - doc.selectFirst(".header-new-background-image")?.attr("content") - }.getOrNull() - } - - override fun setIgnoreArtist(artist: Artist) { - val ignores = getIgnores() - val newIgnores = ignores.copy(artistIds = ignores.artistIds.toMutableList().apply { add(artist.artistId) }) - - saveIgnores(newIgnores) - } - - override fun setIgnoreAlbum(album: Album) { - val ignores = getIgnores() - val newIgnores = ignores.copy(albumIds = ignores.albumIds.toMutableList().apply { add(album.albumId) }) - - saveIgnores(newIgnores) - } - - override fun getIgnores(): Ignores { - val file = File(context.filesDir, FILE_NAME) - if (!file.exists()) return Ignores(emptyList(), emptyList()) - - return formatter.decodeFromString(Ignores.serializer(), file.readText()) - } - - private fun saveIgnores(ignores: Ignores) { - val file = File(context.filesDir, FILE_NAME) - file.writeText(formatter.encodeToString(Ignores.serializer(), ignores)) - } - - private suspend fun saveArtistDetail(artistDetail: ArtistDetail) = withContext(dispatcher) { - val entity = ArtistDetailEntity( - id = 0, - artistId = artistDetail.data.artistId, - artistName = artistDetail.data.artist, - mbid = artistDetail.mbid, - url = artistDetail.url, - imageUrl = artistDetail.imageUrl, - biography = artistDetail.biography, - ) - - val similarArtists = artistDetail.similarArtists.map { artist -> - SimilarArtistEntity( - id = 0, - artistId = artistDetail.data.artistId, - name = artist.name, - url = artist.url, - imageUrl = artist.imageUrl, - ) - } - - val tags = artistDetail.tags.map { tag -> - ArtistTagEntity( - id = 0, - artistId = artistDetail.data.artistId, - name = tag.name, - url = tag.url, - ) - } - - artistDetailDao.delete(artistDetail.data.artistId) - artistDetailDao.insertArtistDetail(entity) - artistDetailDao.insertSimilarArtist(*similarArtists.toTypedArray()) - artistDetailDao.insertArtistTag(*tags.toTypedArray()) - - val artwork = artworkDao.loadArtist(artistDetail.data.artistId) - - if (artwork == null) { - artworkDao.insert( - ArtworkEntity( - id = 0, - artistId = artistDetail.data.artistId, - web = artistDetail.imageUrl, - ), - ) - } else { - artworkDao.update(artwork.copy(web = artistDetail.imageUrl)) - } - } - - private suspend fun saveAlbumDetail(albumDetail: AlbumDetail) = withContext(dispatcher) { - val entity = AlbumDetailEntity( - id = 0, - albumId = albumDetail.data.albumId, - artistName = albumDetail.data.artist, - albumName = albumDetail.data.album, - mbid = albumDetail.mbid, - imageUrl = albumDetail.imageUrl, - ) - - val tracks = albumDetail.tracks.map { track -> - AlbumTrackEntity( - id = 0, - albumId = albumDetail.data.albumId, - track = track.track, - name = track.musicName, - url = track.url, - ) - } - - val tags = albumDetail.tags.map { tag -> - AlbumTagEntity( - id = 0, - albumId = albumDetail.data.albumId, - name = tag.name, - url = tag.url, - ) - } - - albumDetailDao.delete(albumDetail.data.albumId) - albumDetailDao.insertAlbumDetail(entity) - albumDetailDao.insertTrack(*tracks.toTypedArray()) - albumDetailDao.insertTag(*tags.toTypedArray()) - - val artwork = artworkDao.loadAlbum(albumDetail.data.albumId) - - if (artwork == null) { - artworkDao.insert( - ArtworkEntity( - id = 0, - albumId = albumDetail.data.albumId, - web = albumDetail.imageUrl, - ), - ) - } else { - artworkDao.update(artwork.copy(web = albumDetail.imageUrl)) - } - } - - private fun ArtistDetailModel.toData(): ArtistDetail? { - val artist = artistRepository.get(artistDetail.artistId) ?: return null - return ArtistDetail( - data = artist, - mbid = artistDetail.mbid, - url = artistDetail.url, - imageUrl = artistDetail.imageUrl, - tags = tags.map { tag -> FmTag(tag.name, tag.url) }, - similarArtists = similarArtists.map { data -> - ArtistDetail.SimilarArtist( - name = data.name, - url = data.url, - imageUrl = data.imageUrl, - ) - }, - biography = artistDetail.biography, - ) - } - - private fun AlbumDetailModel.toData(): AlbumDetail? { - val album = albumRepository.get(albumDetail.albumId) ?: return null - return AlbumDetail( - data = album, - mbid = albumDetail.mbid, - imageUrl = albumDetail.imageUrl, - tags = tags.map { tag -> FmTag(tag.name, tag.url) }, - tracks = tracks.map { track -> - AlbumDetail.Track( - track = track.track, - url = track.url, - musicName = track.name, - ) - }, - ) - } - - companion object { - private const val ENDPOINT = "http://ws.audioscrobbler.com/2.0/" - private const val FILE_NAME = "MusicDetailIgnores.json" - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultMusicRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultMusicRepository.kt deleted file mode 100644 index 9f9a1bc8..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultMusicRepository.kt +++ /dev/null @@ -1,245 +0,0 @@ -package caios.android.kanade.core.repository - -import caios.android.kanade.core.common.network.Dispatcher -import caios.android.kanade.core.common.network.KanadeDispatcher -import caios.android.kanade.core.datastore.KanadePreferencesDataStore -import caios.android.kanade.core.model.music.Album -import caios.android.kanade.core.model.music.AlbumDetail -import caios.android.kanade.core.model.music.Artist -import caios.android.kanade.core.model.music.ArtistDetail -import caios.android.kanade.core.model.music.LastQueue -import caios.android.kanade.core.model.music.Lyrics -import caios.android.kanade.core.model.music.PlayHistory -import caios.android.kanade.core.model.music.Playlist -import caios.android.kanade.core.model.music.PlaylistItem -import caios.android.kanade.core.model.music.Song -import caios.android.kanade.core.model.player.MusicConfig -import caios.android.kanade.core.model.player.MusicOrder -import caios.android.kanade.core.model.player.RepeatMode -import caios.android.kanade.core.model.player.ShuffleMode -import caios.android.kanade.core.repository.di.LyricsKugou -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flowOn -import java.io.File -import java.time.LocalDateTime -import javax.inject.Inject - -class DefaultMusicRepository @Inject constructor( - private val kanadePreferencesDataStore: KanadePreferencesDataStore, - private val songRepository: SongRepository, - private val artistRepository: ArtistRepository, - private val albumRepository: AlbumRepository, - private val playlistRepository: PlaylistRepository, - private val artworkRepository: ArtworkRepository, - private val playHistoryRepository: PlayHistoryRepository, - private val lastFmRepository: LastFmRepository, - @LyricsKugou private val lyricsRepository: LyricsRepository, - @Dispatcher(KanadeDispatcher.Main) private val main: CoroutineDispatcher, -) : MusicRepository { - - private val _updateFlag = MutableStateFlow(0) - - override val config: Flow = kanadePreferencesDataStore.musicConfig.flowOn(main) - override val lastQueue: Flow = kanadePreferencesDataStore.lastQueue.flowOn(main) - override val updateFlag: StateFlow = _updateFlag.asStateFlow() - - override val songs: List get() = songRepository.gets() - override val artists: List get() = artistRepository.gets() - override val albums: List get() = albumRepository.gets() - override val playlists: List get() = playlistRepository.gets() - override val playHistory: List get() = playHistoryRepository.gets() - - override suspend fun clear() { - songRepository.clear() - artistRepository.clear() - albumRepository.clear() - playlistRepository.clear() - artworkRepository.clear() - } - - override suspend fun refresh() { - _updateFlag.tryEmit((updateFlag.firstOrNull() ?: 0) + 1) - } - - override fun sortedSongs(musicConfig: MusicConfig): List { - return songRepository.songsSort(songs, musicConfig) - } - - override fun sortedArtists(musicConfig: MusicConfig): List { - return artistRepository.artistsSort(artists, musicConfig) - } - - override fun sortedAlbums(musicConfig: MusicConfig): List { - return albumRepository.albumsSort(albums, musicConfig) - } - - override fun sortedPlaylists(musicConfig: MusicConfig): List { - return playlistRepository.playlistSort(playlists, musicConfig) - } - - override fun getSong(songId: Long): Song? { - return songRepository.get(songId) ?: songs.find { it.id == songId } - } - - override fun getArtist(artistId: Long): Artist? { - return artistRepository.get(artistId) ?: artists.find { it.artistId == artistId } - } - - override fun getAlbum(albumId: Long): Album? { - return albumRepository.get(albumId) ?: albums.find { it.albumId == albumId } - } - - override fun getPlaylist(playlistId: Long): Playlist? { - return playlistRepository.get(playlistId) ?: playlists.find { it.id == playlistId } - } - - override fun getLyrics(song: Song): Lyrics? { - return lyricsRepository.get(song) - } - - override fun getPlayHistory(song: Song): List { - return playHistoryRepository.gets(song) - } - - override fun getArtistDetail(artist: Artist): ArtistDetail? { - return lastFmRepository.getArtistDetails()[artist.artistId] - } - - override fun getAlbumDetail(album: Album): AlbumDetail? { - return lastFmRepository.getAlbumDetails()[album.albumId] - } - - override suspend fun saveQueue(currentQueue: List, originalQueue: List, index: Int) { - kanadePreferencesDataStore.setLastQueue( - currentItems = currentQueue.map { it.id }, - originalItems = originalQueue.map { it.id }, - index = index, - ) - } - - override suspend fun saveProgress(progress: Long) { - kanadePreferencesDataStore.setLastQueueProgress(progress) - } - - override suspend fun fetchSongs(musicConfig: MusicConfig?) { - songRepository.songs(musicConfig ?: config.first()) - } - - override suspend fun fetchArtists(musicConfig: MusicConfig?) { - artistRepository.artists(musicConfig ?: config.first()) - } - - override suspend fun fetchAlbums(musicConfig: MusicConfig?) { - albumRepository.albums(musicConfig ?: config.first()) - } - - override suspend fun fetchPlaylist(musicConfig: MusicConfig?) { - playlistRepository.playlists(musicConfig ?: config.first()) - } - - override suspend fun fetchArtistArtwork() { - artworkRepository.fetchArtistArtwork(artists) - songRepository.fetchArtwork() - artistRepository.fetchArtwork() - } - - override suspend fun fetchAlbumArtwork() { - artworkRepository.fetchAlbumArtwork(albums) - songRepository.fetchArtwork() - albumRepository.fetchArtwork() - } - - override suspend fun fetchLyrics(song: Song) { - lyricsRepository.lyrics(song) - } - - override suspend fun fetchPlayHistory() { - playHistoryRepository.playHistories() - } - - override suspend fun createPlaylist(name: String, songs: List) { - val items = songs.mapIndexed { index, song -> PlaylistItem(0, song, index) } - val playlist = Playlist(0, name, items.toSet(), createdAt = LocalDateTime.now()) - - playlistRepository.create(playlist) - } - - override suspend fun removePlaylist(playlist: Playlist) { - playlistRepository.remove(playlist) - } - - override suspend fun renamePlaylist(playlist: Playlist, name: String) { - playlistRepository.rename(playlist, name) - } - - override suspend fun addToPlaylist(playlist: Playlist, songs: List) { - playlistRepository.addItems(playlist.id, songs) - } - - override suspend fun removeFromPlaylist(playlist: Playlist, index: Int) { - playlistRepository.removeItem(playlist.id, index) - } - - override suspend fun moveItemInPlaylist(playlist: Playlist, fromIndex: Int, toIndex: Int) { - playlistRepository.moveItem(playlist.id, fromIndex, toIndex) - } - - override suspend fun isFavorite(song: Song): Boolean { - return playlistRepository.isFavorite(song) - } - - override suspend fun addToFavorite(song: Song) { - playlistRepository.addToFavorite(song) - } - - override suspend fun removeFromFavorite(song: Song) { - playlistRepository.removeFromFavorite(song) - } - - override suspend fun addToPlayHistory(song: Song) { - playHistoryRepository.add(song) - } - - override suspend fun getPlayedCount(): Map { - return playHistoryRepository - .gets() - .groupBy { it.song } - .map { it.key to it.value.size } - .sortedBy { it.second } - .toMap() - } - - override suspend fun setShuffleMode(mode: ShuffleMode) { - kanadePreferencesDataStore.setShuffleMode(mode) - } - - override suspend fun setRepeatMode(mode: RepeatMode) { - kanadePreferencesDataStore.setRepeatMode(mode) - } - - override suspend fun setSongOrder(musicOrder: MusicOrder) { - kanadePreferencesDataStore.setSongOrder(musicOrder) - } - - override suspend fun setArtistOrder(musicOrder: MusicOrder) { - kanadePreferencesDataStore.setArtistOrder(musicOrder) - } - - override suspend fun setAlbumOrder(musicOrder: MusicOrder) { - kanadePreferencesDataStore.setAlbumOrder(musicOrder) - } - - override suspend fun setPlaylistOrder(musicOrder: MusicOrder) { - kanadePreferencesDataStore.setPlaylistOrder(musicOrder) - } - - override suspend fun useSongFile(song: Song, action: (File?) -> T): T { - return songRepository.useFile(song, action) - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultPlayHistoryRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultPlayHistoryRepository.kt deleted file mode 100644 index 4f33f096..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultPlayHistoryRepository.kt +++ /dev/null @@ -1,75 +0,0 @@ -package caios.android.kanade.core.repository - -import caios.android.kanade.core.common.network.Dispatcher -import caios.android.kanade.core.common.network.KanadeDispatcher -import caios.android.kanade.core.database.history.PlayHistoryDao -import caios.android.kanade.core.database.history.PlayHistoryEntity -import caios.android.kanade.core.model.music.PlayHistory -import caios.android.kanade.core.model.music.Song -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.withContext -import java.time.LocalDateTime -import javax.inject.Inject - -class DefaultPlayHistoryRepository @Inject constructor( - private val songRepository: SongRepository, - private val playHistoryDao: PlayHistoryDao, - @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, -) : PlayHistoryRepository { - - private val cache = mutableListOf() - private val _data = MutableStateFlow(emptyList()) - - override val data: SharedFlow> = _data.asSharedFlow() - - override fun gets(song: Song): List = cache.filter { it.song == song }.sortedByDescending { it.playedAt } - - override fun gets(): List = cache.toList().sortedByDescending { it.playedAt } - - override suspend fun playHistory(song: Song): List = withContext(dispatcher) { - val histories = playHistoryDao.loadAll().mapNotNull { it.toModel() } - val data = histories.filter { it.song == song } - - return@withContext data.sortedBy { it.playedAt } - } - - override suspend fun playHistories(): List = withContext(dispatcher) { - playHistoryDao.loadAll().mapNotNull { it.toModel() }.sortedByDescending { it.playedAt }.also { - cache.clear() - cache.addAll(it) - _data.value = cache - } - } - - override fun add(song: Song) { - val data = PlayHistory( - id = 0, - song = song, - playedAt = LocalDateTime.now(), - ) - - playHistoryDao.insert(data.toEntity()) - } - - private fun PlayHistory.toEntity(): PlayHistoryEntity { - return PlayHistoryEntity( - id = id, - songId = song.id, - duration = song.duration, - createdAt = playedAt.toString(), - ) - } - - private fun PlayHistoryEntity.toModel(): PlayHistory? { - val song = songRepository.get(songId) ?: return null - - return PlayHistory( - id = id, - song = song, - playedAt = LocalDateTime.parse(createdAt), - ) - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultPlaylistRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultPlaylistRepository.kt deleted file mode 100644 index 735866ba..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultPlaylistRepository.kt +++ /dev/null @@ -1,212 +0,0 @@ -package caios.android.kanade.core.repository - -import caios.android.kanade.core.common.network.Dispatcher -import caios.android.kanade.core.common.network.KanadeDispatcher -import caios.android.kanade.core.database.playlist.PlaylistDao -import caios.android.kanade.core.database.playlist.PlaylistEntity -import caios.android.kanade.core.database.playlist.PlaylistItemEntity -import caios.android.kanade.core.database.playlist.PlaylistModel -import caios.android.kanade.core.model.music.Playlist -import caios.android.kanade.core.model.music.PlaylistItem -import caios.android.kanade.core.model.music.Song -import caios.android.kanade.core.model.player.MusicConfig -import caios.android.kanade.core.model.player.MusicOrderOption -import caios.android.kanade.core.repository.util.sortList -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.time.LocalDateTime -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject - -class DefaultPlaylistRepository @Inject constructor( - private val songRepository: SongRepository, - private val playlistDao: PlaylistDao, - @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, -) : PlaylistRepository { - - private val cache = ConcurrentHashMap() - private val _data = MutableStateFlow(emptyList()) - - override val data: SharedFlow> = _data.asSharedFlow() - - override fun clear() { - cache.clear() - _data.value = emptyList() - } - - override fun get(playlistId: Long): Playlist? = cache[playlistId] - - override fun gets(): List = cache.values.toList() - - private suspend fun fetchPlaylist() = withContext(dispatcher) { - cache.clear() - - playlistDao.loadAll().map { it.toData() }.onEach { cache[it.id] = it } - _data.value = cache.values.toList() - } - - override suspend fun playlist(playlistId: Long): Playlist? = withContext(dispatcher) { - playlistDao.load(playlistId)?.toData()?.also { - cache[playlistId] = it - _data.value = cache.values.toList() - } - } - - override suspend fun playlists(musicConfig: MusicConfig): List = withContext(dispatcher) { - val playlists = playlistDao.loadAll().map { it.toData() }.onEach { - cache[it.id] = it - _data.value = cache.values.toList() - } - playlistSort(playlists, musicConfig) - } - - override suspend fun create(playlist: Playlist): Unit = withContext(dispatcher) { - if (gets().any { it.name == playlist.name }) { - Timber.d("Playlist name is duplicated") - return@withContext - } - - val model = playlist.toModel() - val playlistId = playlistDao.insertPlaylist(model.playlist) - - playlistDao.insertPlaylistItem(*model.items.map { it.copy(playlistId = playlistId) }.toTypedArray()) - fetchPlaylist() - } - - override suspend fun remove(playlist: Playlist): Unit = withContext(dispatcher) { - playlistDao.delete(playlist.id) - fetchPlaylist() - } - - override suspend fun rename(playlist: Playlist, name: String) = withContext(dispatcher) { - val model = playlist.toModel() - val entity = model.playlist.copy(name = name) - - playlistDao.updatePlaylist(entity) - fetchPlaylist() - } - - override suspend fun addItems(playlistId: Long, songs: List) = withContext(dispatcher) { - val playlist = playlistDao.load(playlistId) ?: return@withContext - val items = songs.mapIndexed { index, song -> - PlaylistItemEntity( - id = 0, - playlistId = playlistId, - index = playlist.items.size + index, - songId = song.id, - ) - } - - playlistDao.insertPlaylistItem(*items.toTypedArray()) - fetchPlaylist() - } - - override suspend fun removeItem(playlistId: Long, index: Int) = withContext(dispatcher) { - val playlist = playlistDao.load(playlistId) ?: return@withContext - val items = playlist.items.toMutableList().apply { - removeIf { it.index == index } - sortedBy { it.index } - } - - playlistDao.deleteItem(playlist.items.find { it.index == index }!!.id) - playlistDao.updatePlaylistItem(*items.mapIndexed { i, item -> item.copy(index = i) }.toTypedArray()) - fetchPlaylist() - } - - override suspend fun moveItem(playlistId: Long, fromIndex: Int, toIndex: Int): Unit = withContext(dispatcher) { - playlistDao.changeIndexTransaction(playlistId, fromIndex, toIndex) - } - - override suspend fun isFavorite(song: Song): Boolean { - val favorite = cache.values.find { it.isSystemPlaylist } ?: return false - return favorite.items.any { it.song.id == song.id } - } - - override suspend fun addToFavorite(song: Song) { - val favorite = cache.values.find { it.isSystemPlaylist } - - if (favorite != null) { - addItems(favorite.id, listOf(song)) - } else { - createFavoritePlaylist(song) - } - } - - override suspend fun removeFromFavorite(song: Song) { - val favorite = cache.values.find { it.isSystemPlaylist } ?: return - removeItem(favorite.id, favorite.items.find { it.song.id == song.id }!!.index) - } - - override fun playlistSort(playlists: List, musicConfig: MusicConfig): List { - val order = musicConfig.playlistOrder - val option = order.option - - require(option is MusicOrderOption.Playlist) { "MusicOrderOption is not Playlist" } - - return when (option) { - MusicOrderOption.Playlist.NAME -> playlists.sortList({ it.name }, order = order.order) - MusicOrderOption.Playlist.TRACKS -> playlists.sortList({ it.items.size }, order = order.order) - } - } - - private suspend fun createFavoritePlaylist(song: Song): Playlist { - val favorite = Playlist( - id = 0, - name = "Favorite", - items = setOf( - PlaylistItem( - id = 0, - song = song, - index = 0, - ), - ), - isSystemPlaylist = true, - createdAt = LocalDateTime.now(), - ) - - create(favorite) - - return favorite - } - - private fun PlaylistModel.toData(): Playlist { - return Playlist( - id = playlist.id, - name = playlist.name, - items = items.mapNotNull { item -> - songRepository.get(item.songId)?.let { - PlaylistItem( - id = item.id, - song = it, - index = item.index, - ) - } - }.toSet(), - isSystemPlaylist = playlist.isSystemPlaylist, - createdAt = LocalDateTime.parse(playlist.createdAt), - ) - } - - private fun Playlist.toModel(): PlaylistModel { - return PlaylistModel().apply { - playlist = PlaylistEntity( - id = id, - name = name, - isSystemPlaylist = isSystemPlaylist, - createdAt = createdAt.toString(), - ) - items = this@toModel.items.map { - PlaylistItemEntity( - id = it.id, - playlistId = id, - index = it.index, - songId = it.song.id, - ) - } - } - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultSongRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultSongRepository.kt deleted file mode 100644 index 6ed9af03..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultSongRepository.kt +++ /dev/null @@ -1,228 +0,0 @@ -package caios.android.kanade.core.repository - -import android.content.ContentUris -import android.content.Context -import android.database.Cursor -import android.net.Uri -import android.os.Build -import android.provider.BaseColumns -import android.provider.MediaStore -import android.provider.MediaStore.Audio.AudioColumns -import android.provider.MediaStore.Audio.Media -import android.webkit.MimeTypeMap -import caios.android.kanade.core.model.music.Artwork -import caios.android.kanade.core.model.music.Song -import caios.android.kanade.core.model.player.MusicConfig -import caios.android.kanade.core.model.player.MusicOrder -import caios.android.kanade.core.model.player.MusicOrderOption -import caios.android.kanade.core.repository.util.getInt -import caios.android.kanade.core.repository.util.getLong -import caios.android.kanade.core.repository.util.getString -import caios.android.kanade.core.repository.util.getStringOrNull -import caios.android.kanade.core.repository.util.sortList -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first -import okhttp3.internal.toImmutableMap -import timber.log.Timber -import java.io.File -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject - -class DefaultSongRepository @Inject constructor( - @ApplicationContext private val context: Context, - private val artworkRepository: ArtworkRepository, - private val userDataRepository: UserDataRepository, -) : SongRepository { - - private val cache = ConcurrentHashMap() - - private val baseProjection = arrayOf( - BaseColumns._ID, - AudioColumns.TITLE, - AudioColumns.TRACK, - AudioColumns.YEAR, - AudioColumns.DURATION, - Media.DATA, - AudioColumns.DATE_MODIFIED, - AudioColumns.ALBUM_ID, - AudioColumns.ALBUM, - AudioColumns.ARTIST_ID, - AudioColumns.ARTIST, - AudioColumns.MIME_TYPE, - ) - - override fun clear() { - cache.clear() - } - - override fun get(songId: Long): Song? = cache[songId] - - override fun gets(songIds: List): List = songIds.mapNotNull { get(it) } - - override fun gets(): List = cache.values.toList() - - override suspend fun song(songId: Long, musicConfig: MusicConfig): Song? { - return song( - makeCursor( - selection = AudioColumns._ID, - selectionValues = listOf(songId.toString()), - musicOrders = arrayOf(musicConfig.songOrder), - ), - ) - } - - override suspend fun song(cursor: Cursor?): Song? { - val song = if (cursor != null && cursor.moveToFirst()) getSong(cursor) else null - cursor?.close() - return song - } - - override suspend fun songs(musicConfig: MusicConfig): List { - return songs(makeCursor(musicOrders = arrayOf(musicConfig.songOrder))) - } - - override suspend fun songs(cursor: Cursor?): List { - val songs = mutableListOf() - if (cursor != null && cursor.moveToFirst()) { - do { - songs.add(getSong(cursor)) - } while (cursor.moveToNext()) - } - cursor?.close() - return songs - } - - override suspend fun useFile(song: Song, action: (File?) -> T): T { - val file = kotlin.runCatching { - val outputFileName = "${song.id}.${MimeTypeMap.getSingleton().getExtensionFromMimeType(song.mimeType)}" - val outputFile = File(context.cacheDir, outputFileName) - - context.contentResolver.openInputStream(song.uri)?.use { inputStream -> - inputStream.copyTo(outputFile.outputStream()) - } - - if (outputFile.exists()) outputFile else null - }.getOrNull() - - val result = action.invoke(file) - - file?.delete() - - return result - } - - override suspend fun makeCursor( - selection: String, - selectionValues: List, - vararg musicOrders: MusicOrder, - ): Cursor? { - val order = musicOrders.joinToString(separator = ", ") { it.create() } - val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Media.getContentUri(MediaStore.VOLUME_EXTERNAL) else Media.EXTERNAL_CONTENT_URI - - var selectionFinal = AudioColumns.TITLE + " != ''" - var selectionValuesFinal = emptyArray() - - if (selection.isNotBlank()) { - selectionFinal += " AND $selection" - selectionValuesFinal += selectionValues - } - - if (userDataRepository.userData.first().isIgnoreNotMusic) { - selectionFinal += " AND ${AudioColumns.IS_MUSIC}=1" - } - - if (userDataRepository.userData.first().isIgnoreShortMusic) { - selectionFinal += " AND ${Media.DURATION} >= 5000" - } - - return try { - context.contentResolver.query( - uri, - baseProjection, - selectionFinal, - selectionValuesFinal, - order, - ) - } catch (ex: SecurityException) { - Timber.w(ex, "Permission denied") - return null - } - } - - override suspend fun delete(songIds: List) { - val contentResolver = context.contentResolver - - for (uri in songIds.map { ContentUris.withAppendedId(Media.EXTERNAL_CONTENT_URI, it) }) { - contentResolver.delete(uri, null, null) - } - } - - override fun fetchArtwork() { - for ((albumId, artwork) in artworkRepository.albumArtworks.toImmutableMap()) { - for (song in cache.values.filter { it.albumId == albumId }) { - cache[song.id] = song.copy(albumArtwork = artwork) - } - } - - for ((artistId, artwork) in artworkRepository.artistArtworks.toImmutableMap()) { - for (song in cache.values.filter { it.artistId == artistId }) { - cache[song.id] = song.copy(artistArtwork = artwork) - } - } - } - - override fun songsSort(songs: List, musicConfig: MusicConfig): List { - val order = musicConfig.songOrder - val option = order.option - - require(option is MusicOrderOption.Song) { "MusicOrderOption is not Song" } - - return when (option) { - MusicOrderOption.Song.NAME -> songs.sortList({ it.title }, { it.artist }, order = order.order) - MusicOrderOption.Song.ARTIST -> songs.sortList({ it.artist }, { it.title }, order = order.order) - MusicOrderOption.Song.ALBUM -> songs.sortList({ it.album }, { it.title }, order = order.order) - MusicOrderOption.Song.DURATION -> songs.sortList({ it.duration }, { it.title }, order = order.order) - MusicOrderOption.Song.YEAR -> songs.sortList({ it.year }, { it.title }, order = order.order) - MusicOrderOption.Song.TRACK -> songs.sortList({ it.track }, { it.title }, order = order.order) - } - } - - private fun getSong(cursor: Cursor): Song { - val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Media.getContentUri(MediaStore.VOLUME_EXTERNAL) else Media.EXTERNAL_CONTENT_URI - val albumArtworks = artworkRepository.albumArtworks.toImmutableMap() - val artistArtworks = artworkRepository.artistArtworks.toImmutableMap() - - val id = cursor.getLong(AudioColumns._ID) - val title = cursor.getString(AudioColumns.TITLE) - val trackNumber = cursor.getInt(AudioColumns.TRACK) - val year = cursor.getInt(AudioColumns.YEAR) - val duration = cursor.getLong(AudioColumns.DURATION) - val data = cursor.getString(AudioColumns.DATA) - val dateModified = cursor.getLong(AudioColumns.DATE_MODIFIED) - val albumId = cursor.getLong(AudioColumns.ALBUM_ID) - val albumName = cursor.getStringOrNull(AudioColumns.ALBUM) - val artistId = cursor.getLong(AudioColumns.ARTIST_ID) - val artistName = cursor.getStringOrNull(AudioColumns.ARTIST) - val mimeType = cursor.getString(AudioColumns.MIME_TYPE) - - return Song( - id = id, - title = title, - artist = artistName ?: "", - artistId = artistId, - album = albumName ?: "", - albumId = albumId, - duration = duration, - year = year, - track = trackNumber, - mimeType = mimeType, - data = data, - dateModified = dateModified, - uri = Uri.withAppendedPath(uri, id.toString()), - albumArtwork = albumArtworks[albumId] ?: Artwork.Unknown, - artistArtwork = artistArtworks[artistId] ?: Artwork.Unknown, - ).also { - cache[id] = it - } - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultUserDataRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultUserDataRepository.kt deleted file mode 100644 index 6affd60f..00000000 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/DefaultUserDataRepository.kt +++ /dev/null @@ -1,67 +0,0 @@ -package caios.android.kanade.core.repository - -import caios.android.kanade.core.datastore.KanadePreferencesDataStore -import caios.android.kanade.core.model.ThemeColorConfig -import caios.android.kanade.core.model.ThemeConfig -import caios.android.kanade.core.model.UserData -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class DefaultUserDataRepository @Inject constructor( - private val kanadePreferencesDataStore: KanadePreferencesDataStore, -) : UserDataRepository { - - override val userData: Flow = kanadePreferencesDataStore.userData - - override suspend fun setThemeConfig(themeConfig: ThemeConfig) { - kanadePreferencesDataStore.setThemeConfig(themeConfig) - } - - override suspend fun setThemeColorConfig(themeColorConfig: ThemeColorConfig) { - kanadePreferencesDataStore.setThemeColorConfig(themeColorConfig) - } - - override suspend fun setDeveloperMode(isDeveloperMode: Boolean) { - kanadePreferencesDataStore.setDeveloperMode(isDeveloperMode) - } - - override suspend fun setPlusMode(isPlusMode: Boolean) { - kanadePreferencesDataStore.setPlusMode(isPlusMode) - } - - override suspend fun setUseDynamicColor(useDynamicColor: Boolean) { - kanadePreferencesDataStore.setUseDynamicColor(useDynamicColor) - } - - override suspend fun setUseDynamicNormalizer(useDynamicNormalizer: Boolean) { - kanadePreferencesDataStore.setUseDynamicNormalizer(useDynamicNormalizer) - } - - override suspend fun setUseOneStepBack(isOneStepBack: Boolean) { - kanadePreferencesDataStore.setUseOneStepBack(isOneStepBack) - } - - override suspend fun setUseKeepAudioFocus(isKeepAudioFocus: Boolean) { - kanadePreferencesDataStore.setUseKeepAudioFocus(isKeepAudioFocus) - } - - override suspend fun setUseStopWhenTaskkill(isStopWhenTaskkill: Boolean) { - kanadePreferencesDataStore.setUseStopWhenTaskkill(isStopWhenTaskkill) - } - - override suspend fun setUseIgnoreShortMusic(isIgnoreShortMusic: Boolean) { - kanadePreferencesDataStore.setUseIgnoreShortMusic(isIgnoreShortMusic) - } - - override suspend fun setUseIgnoreNotMusic(isIgnoreNotMusic: Boolean) { - kanadePreferencesDataStore.setUseIgnoreNotMusic(isIgnoreNotMusic) - } - - override suspend fun setAgreedPrivacyPolicy(isAgreedPrivacyPolicy: Boolean) { - kanadePreferencesDataStore.setAgreedPrivacyPolicy(isAgreedPrivacyPolicy) - } - - override suspend fun setAgreedTermsOfService(isAgreedTermsOfService: Boolean) { - kanadePreferencesDataStore.setAgreedTermsOfService(isAgreedTermsOfService) - } -} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/ExternalPlaylistRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/ExternalPlaylistRepository.kt index 5d69429f..70a61bb7 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/ExternalPlaylistRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/ExternalPlaylistRepository.kt @@ -1,12 +1,126 @@ package caios.android.kanade.core.repository +import android.content.ContentValues +import android.content.Context +import android.provider.MediaStore import caios.android.kanade.core.model.music.ExternalPlaylist import caios.android.kanade.core.model.music.Playlist +import caios.android.kanade.core.model.music.PlaylistItem +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.LocalDateTime +import javax.inject.Inject interface ExternalPlaylistRepository { - suspend fun getExternalPlaylists(): List - suspend fun export(playlist: Playlist) suspend fun import(externalPlaylistId: Long) } + +class ExternalPlaylistRepositoryImpl @Inject constructor( + private val songRepository: SongRepository, + private val playlistRepository: PlaylistRepository, + @ApplicationContext private val context: Context, +) : ExternalPlaylistRepository { + + private val contentResolver get() = context.contentResolver + + override suspend fun getExternalPlaylists(): List { + val uri = MediaStore.Audio.Playlists.getContentUri("external") + val cursor = contentResolver.query(uri, null, null, null, "") + + if (cursor == null || !cursor.moveToFirst()) return emptyList() + + val playlistNameColumn = cursor.getColumnIndex(MediaStore.Audio.Playlists.NAME) + val playlistIdColumn = cursor.getColumnIndex(MediaStore.Audio.Playlists._ID) + val playlistDataList = mutableListOf() + + do { + val playlistName = cursor.getString(playlistNameColumn) + val playlistId = cursor.getLong(playlistIdColumn) + + val playlistUri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId) + val playlistCursor = contentResolver.query(playlistUri, null, null, null, "") + + if (playlistCursor != null && playlistCursor.moveToFirst()) { + val songIds = mutableListOf() + val idColumn = playlistCursor.getColumnIndex(MediaStore.Audio.Playlists.Members.AUDIO_ID) + + do { + val id = playlistCursor.getInt(idColumn) + songIds.add(id.toLong()) + } while (playlistCursor.moveToNext()) + + playlistDataList.add(ExternalPlaylist(playlistId, playlistName, songIds)) + } else { + playlistDataList.add(ExternalPlaylist(playlistId, playlistName, emptyList())) + } + + playlistCursor?.close() + } while (cursor.moveToNext()) + + cursor.close() + + return playlistDataList + } + + override suspend fun export(playlist: Playlist) { + val beforePlaylists = getExternalPlaylists() + + val uri = MediaStore.Audio.Playlists.getContentUri("external") + val contentValue = ContentValues().apply { + put(MediaStore.Audio.Playlists.NAME, playlist.name) + } + + contentResolver.insert(uri, contentValue) + + val afterPlaylists = getExternalPlaylists() + val playlistId = (afterPlaylists - beforePlaylists.toSet()).elementAtOrNull(0)?.id ?: return + + addMusic(playlistId, playlist.songs.map { it.id }) + } + + override suspend fun import(externalPlaylistId: Long) { + val externalPlaylist = getExternalPlaylists().find { it.id == externalPlaylistId }!! + val songs = externalPlaylist.songIds.mapNotNull { songRepository.get(it) } + val items = songs.mapIndexed { index, song -> PlaylistItem(0, song, index) } + val playlist = Playlist(0, externalPlaylist.name, items.toSet(), createdAt = LocalDateTime.now()) + + playlistRepository.create(playlist) + } + + private fun addMusic(playlistId: Long, songIds: List) { + var nextOrder = getMaxPlayOrder(playlistId)?.plus(1) ?: 0 + val playlistUri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId) + + for (musicId in songIds) { + contentResolver.insert( + playlistUri, + ContentValues().apply { + put(MediaStore.Audio.Playlists.Members.AUDIO_ID, musicId) + put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, nextOrder) + }, + ) + + nextOrder += 1 + } + } + + private fun getMaxPlayOrder(playlistId: Long): Int? { + val playlistUri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId) + val playlistCursor = contentResolver.query(playlistUri, null, null, null, "") + + if (playlistCursor == null || !playlistCursor.moveToFirst()) return null + + val orderList = mutableListOf() + val orderColumn = playlistCursor.getColumnIndex(MediaStore.Audio.Playlists.Members.PLAY_ORDER) + + do { + val order = playlistCursor.getInt(orderColumn) + orderList.add(order) + } while (playlistCursor.moveToNext()) + + playlistCursor.close() + + return orderList.maxOrNull() + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/KugouLyricsRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/KugouLyricsRepository.kt index 7557135e..b0687c26 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/KugouLyricsRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/KugouLyricsRepository.kt @@ -1,7 +1,7 @@ package caios.android.kanade.core.repository import caios.android.kanade.core.common.network.di.ApplicationScope -import caios.android.kanade.core.datastore.LyricsPreference +import caios.android.kanade.core.datastore.PreferenceLyrics import caios.android.kanade.core.model.entity.KugouLyricsEntity import caios.android.kanade.core.model.entity.KugouSongEntity import caios.android.kanade.core.model.music.Lyrics @@ -24,7 +24,7 @@ import javax.inject.Inject class KugouLyricsRepository @Inject constructor( private val client: HttpClient, - private val lyricsPreference: LyricsPreference, + private val preferenceLyrics: PreferenceLyrics, @ApplicationScope private val scope: CoroutineScope, ) : LyricsRepository { @@ -33,7 +33,7 @@ class KugouLyricsRepository @Inject constructor( init { scope.launch { - lyricsPreference.data.collect { lyrics -> + preferenceLyrics.data.collect { lyrics -> cache.clear() cache.putAll(lyrics.associateBy { it.songId }) @@ -45,7 +45,7 @@ class KugouLyricsRepository @Inject constructor( override val data: SharedFlow> = _data.asSharedFlow() override suspend fun save(lyrics: Lyrics) { - lyricsPreference.save(lyrics) + preferenceLyrics.save(lyrics) } override fun get(song: Song): Lyrics? { @@ -63,7 +63,7 @@ class KugouLyricsRepository @Inject constructor( parseLrc(song, lyrics) }.getOrNull()?.also { - lyricsPreference.save(it) + preferenceLyrics.save(it) } } diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/LastFmRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/LastFmRepository.kt index c5242790..0a4c28e2 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/LastFmRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/LastFmRepository.kt @@ -1,11 +1,46 @@ package caios.android.kanade.core.repository import Ignores +import android.content.Context +import caios.android.kanade.core.common.network.Dispatcher +import caios.android.kanade.core.common.network.KanadeConfig +import caios.android.kanade.core.common.network.KanadeDispatcher +import caios.android.kanade.core.database.album_detail.AlbumDetailDao +import caios.android.kanade.core.database.album_detail.AlbumDetailEntity +import caios.android.kanade.core.database.album_detail.AlbumDetailModel +import caios.android.kanade.core.database.album_detail.AlbumTagEntity +import caios.android.kanade.core.database.album_detail.AlbumTrackEntity +import caios.android.kanade.core.database.artist_detail.ArtistDetailDao +import caios.android.kanade.core.database.artist_detail.ArtistDetailEntity +import caios.android.kanade.core.database.artist_detail.ArtistDetailModel +import caios.android.kanade.core.database.artist_detail.ArtistTagEntity +import caios.android.kanade.core.database.artist_detail.SimilarArtistEntity +import caios.android.kanade.core.database.artwork.ArtworkDao +import caios.android.kanade.core.database.artwork.ArtworkEntity +import caios.android.kanade.core.model.entity.LastFmAlbumDetailEntity +import caios.android.kanade.core.model.entity.LastFmArtistDetailEntity import caios.android.kanade.core.model.music.Album import caios.android.kanade.core.model.music.AlbumDetail import caios.android.kanade.core.model.music.Artist import caios.android.kanade.core.model.music.ArtistDetail +import caios.android.kanade.core.model.music.FmTag +import caios.android.kanade.core.repository.util.parse +import dagger.hilt.android.qualifiers.ApplicationContext +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.url +import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.jsoup.Jsoup +import timber.log.Timber +import java.io.File +import javax.inject.Inject interface LastFmRepository { val artistDetails: SharedFlow> @@ -25,3 +60,301 @@ interface LastFmRepository { fun getIgnores(): Ignores } + +class LastFmRepositoryImpl @Inject constructor( + private val client: HttpClient, + private val artistRepository: ArtistRepository, + private val albumRepository: AlbumRepository, + private val artistDetailDao: ArtistDetailDao, + private val albumDetailDao: AlbumDetailDao, + private val artworkDao: ArtworkDao, + private val kanadeConfig: KanadeConfig, + @ApplicationContext private val context: Context, + @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, +) : LastFmRepository { + + private val formatter = Json { ignoreUnknownKeys = true } + + private val _artistDetail = MutableStateFlow(mutableMapOf()) + private val _albumDetail = MutableStateFlow(mutableMapOf()) + + override val artistDetails: SharedFlow> = _artistDetail.asSharedFlow() + override val albumDetails: SharedFlow> = _albumDetail.asSharedFlow() + + override fun getArtistDetails(): Map = _artistDetail.value.toMap() + override fun getAlbumDetails(): Map = _albumDetail.value.toMap() + + override suspend fun artistDetail(artist: Artist): ArtistDetail? = withContext(dispatcher) { + _artistDetail.value[artist.artistId] ?: kotlin.runCatching { + fetchArtistInfo(artist.artist)?.let { + ArtistDetail( + data = artist, + mbid = it.artist.mbid, + url = it.artist.url, + imageUrl = fetchArtistArtwork(it.artist.url), + tags = it.artist.tags.tag.map { tag -> FmTag(name = tag.name, url = tag.url) }, + similarArtists = it.artist.similar.artist.map { artist -> + ArtistDetail.SimilarArtist( + name = artist.name, + url = artist.url, + imageUrl = artist.image.findLast { image -> image.text.isNotBlank() }?.text, + ) + }, + biography = it.artist.biography.content.replace(Regex(""), "").ifBlank { null }, + ) + } + }.getOrNull()?.also { + saveArtistDetail(it) + _artistDetail.value = _artistDetail.value.toMutableMap().apply { this[artist.artistId] = it } + } + } + + override suspend fun albumDetail(album: Album): AlbumDetail? = withContext(dispatcher) { + _albumDetail.value[album.albumId] ?: kotlin.runCatching { + fetchAlbumInfo(album.artist, album.album)?.let { + AlbumDetail( + data = album, + mbid = it.album.mbid, + imageUrl = it.album.images.findLast { image -> image.text.isNotBlank() }?.text, + tags = it.album.tags.tag.map { tag -> FmTag(tag.name, tag.url) }, + tracks = it.album.tracks.track.map { track -> + AlbumDetail.Track( + track = track.attr.rank, + musicName = track.name, + url = track.url, + ) + }, + ) + } + }.getOrNull()?.also { + saveAlbumDetail(it) + _albumDetail.value = _albumDetail.value.toMutableMap().apply { this[album.albumId] = it } + } + } + + override suspend fun fetchArtistDetails(): Unit = withContext(dispatcher) { + artistDetailDao.loadAll().map { it.toData() }.onEach { detail -> + if (detail != null) { + _artistDetail.value[detail.data.artistId] = detail + } + } + } + + override suspend fun fetchAlbumDetails(): Unit = withContext(dispatcher) { + albumDetailDao.loadAll().map { it.toData() }.onEach { detail -> + if (detail != null) { + _albumDetail.value[detail.data.albumId] = detail + } + } + } + + private suspend fun fetchAlbumInfo(artistName: String, albumName: String): LastFmAlbumDetailEntity? { + return try { + client.get { + url(ENDPOINT) + parameter("api_key", kanadeConfig.lastFmApiKey) + parameter("lang", "ja") + parameter("autocorrect", 1) + parameter("format", "json") + parameter("artist", artistName) + parameter("album", albumName) + parameter("method", "album.getinfo") + }.parse() + } catch (e: Throwable) { + Timber.w(e) + null + } + } + + private suspend fun fetchArtistInfo(artistName: String): LastFmArtistDetailEntity? { + return try { + client.get { + url(ENDPOINT) + parameter("api_key", kanadeConfig.lastFmApiKey) + parameter("lang", "ja") + parameter("autocorrect", 1) + parameter("format", "json") + parameter("artist", artistName) + parameter("method", "artist.getinfo") + }.parse() + } catch (e: Throwable) { + Timber.w(e) + null + } + } + + private suspend fun fetchArtistArtwork(artistFmUrl: String): String? { + return runCatching { + val html = client.get(artistFmUrl).bodyAsText() + val doc = Jsoup.parse(html) + + doc.selectFirst(".header-new-background-image")?.attr("content") + }.getOrNull() + } + + override fun setIgnoreArtist(artist: Artist) { + val ignores = getIgnores() + val newIgnores = ignores.copy(artistIds = ignores.artistIds.toMutableList().apply { add(artist.artistId) }) + + saveIgnores(newIgnores) + } + + override fun setIgnoreAlbum(album: Album) { + val ignores = getIgnores() + val newIgnores = ignores.copy(albumIds = ignores.albumIds.toMutableList().apply { add(album.albumId) }) + + saveIgnores(newIgnores) + } + + override fun getIgnores(): Ignores { + val file = File(context.filesDir, FILE_NAME) + if (!file.exists()) return Ignores(emptyList(), emptyList()) + + return formatter.decodeFromString(Ignores.serializer(), file.readText()) + } + + private fun saveIgnores(ignores: Ignores) { + val file = File(context.filesDir, FILE_NAME) + file.writeText(formatter.encodeToString(Ignores.serializer(), ignores)) + } + + private suspend fun saveArtistDetail(artistDetail: ArtistDetail) = withContext(dispatcher) { + val entity = ArtistDetailEntity( + id = 0, + artistId = artistDetail.data.artistId, + artistName = artistDetail.data.artist, + mbid = artistDetail.mbid, + url = artistDetail.url, + imageUrl = artistDetail.imageUrl, + biography = artistDetail.biography, + ) + + val similarArtists = artistDetail.similarArtists.map { artist -> + SimilarArtistEntity( + id = 0, + artistId = artistDetail.data.artistId, + name = artist.name, + url = artist.url, + imageUrl = artist.imageUrl, + ) + } + + val tags = artistDetail.tags.map { tag -> + ArtistTagEntity( + id = 0, + artistId = artistDetail.data.artistId, + name = tag.name, + url = tag.url, + ) + } + + artistDetailDao.delete(artistDetail.data.artistId) + artistDetailDao.insertArtistDetail(entity) + artistDetailDao.insertSimilarArtist(*similarArtists.toTypedArray()) + artistDetailDao.insertArtistTag(*tags.toTypedArray()) + + val artwork = artworkDao.loadArtist(artistDetail.data.artistId) + + if (artwork == null) { + artworkDao.insert( + ArtworkEntity( + id = 0, + artistId = artistDetail.data.artistId, + web = artistDetail.imageUrl, + ), + ) + } else { + artworkDao.update(artwork.copy(web = artistDetail.imageUrl)) + } + } + + private suspend fun saveAlbumDetail(albumDetail: AlbumDetail) = withContext(dispatcher) { + val entity = AlbumDetailEntity( + id = 0, + albumId = albumDetail.data.albumId, + artistName = albumDetail.data.artist, + albumName = albumDetail.data.album, + mbid = albumDetail.mbid, + imageUrl = albumDetail.imageUrl, + ) + + val tracks = albumDetail.tracks.map { track -> + AlbumTrackEntity( + id = 0, + albumId = albumDetail.data.albumId, + track = track.track, + name = track.musicName, + url = track.url, + ) + } + + val tags = albumDetail.tags.map { tag -> + AlbumTagEntity( + id = 0, + albumId = albumDetail.data.albumId, + name = tag.name, + url = tag.url, + ) + } + + albumDetailDao.delete(albumDetail.data.albumId) + albumDetailDao.insertAlbumDetail(entity) + albumDetailDao.insertTrack(*tracks.toTypedArray()) + albumDetailDao.insertTag(*tags.toTypedArray()) + + val artwork = artworkDao.loadAlbum(albumDetail.data.albumId) + + if (artwork == null) { + artworkDao.insert( + ArtworkEntity( + id = 0, + albumId = albumDetail.data.albumId, + web = albumDetail.imageUrl, + ), + ) + } else { + artworkDao.update(artwork.copy(web = albumDetail.imageUrl)) + } + } + + private fun ArtistDetailModel.toData(): ArtistDetail? { + val artist = artistRepository.get(artistDetail.artistId) ?: return null + return ArtistDetail( + data = artist, + mbid = artistDetail.mbid, + url = artistDetail.url, + imageUrl = artistDetail.imageUrl, + tags = tags.map { tag -> FmTag(tag.name, tag.url) }, + similarArtists = similarArtists.map { data -> + ArtistDetail.SimilarArtist( + name = data.name, + url = data.url, + imageUrl = data.imageUrl, + ) + }, + biography = artistDetail.biography, + ) + } + + private fun AlbumDetailModel.toData(): AlbumDetail? { + val album = albumRepository.get(albumDetail.albumId) ?: return null + return AlbumDetail( + data = album, + mbid = albumDetail.mbid, + imageUrl = albumDetail.imageUrl, + tags = tags.map { tag -> FmTag(tag.name, tag.url) }, + tracks = tracks.map { track -> + AlbumDetail.Track( + track = track.track, + url = track.url, + musicName = track.name, + ) + }, + ) + } + + companion object { + private const val ENDPOINT = "http://ws.audioscrobbler.com/2.0/" + private const val FILE_NAME = "MusicDetailIgnores.json" + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/MusicRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/MusicRepository.kt index 15a782c6..cab9286a 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/MusicRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/MusicRepository.kt @@ -1,5 +1,8 @@ package caios.android.kanade.core.repository +import caios.android.kanade.core.common.network.Dispatcher +import caios.android.kanade.core.common.network.KanadeDispatcher +import caios.android.kanade.core.datastore.KanadePreferencesDataStore import caios.android.kanade.core.model.music.Album import caios.android.kanade.core.model.music.AlbumDetail import caios.android.kanade.core.model.music.Artist @@ -8,14 +11,24 @@ import caios.android.kanade.core.model.music.LastQueue import caios.android.kanade.core.model.music.Lyrics import caios.android.kanade.core.model.music.PlayHistory import caios.android.kanade.core.model.music.Playlist +import caios.android.kanade.core.model.music.PlaylistItem import caios.android.kanade.core.model.music.Song import caios.android.kanade.core.model.player.MusicConfig import caios.android.kanade.core.model.player.MusicOrder import caios.android.kanade.core.model.player.RepeatMode import caios.android.kanade.core.model.player.ShuffleMode +import caios.android.kanade.core.repository.di.LyricsKugou +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOn import java.io.File +import java.time.LocalDateTime +import javax.inject.Inject interface MusicRepository { @@ -82,3 +95,217 @@ interface MusicRepository { suspend fun useSongFile(song: Song, action: (File?) -> T): T } + +class MusicRepositoryImpl @Inject constructor( + private val kanadePreferencesDataStore: KanadePreferencesDataStore, + private val songRepository: SongRepository, + private val artistRepository: ArtistRepository, + private val albumRepository: AlbumRepository, + private val playlistRepository: PlaylistRepository, + private val artworkRepository: ArtworkRepository, + private val playHistoryRepository: PlayHistoryRepository, + private val lastFmRepository: LastFmRepository, + @LyricsKugou private val lyricsRepository: LyricsRepository, + @Dispatcher(KanadeDispatcher.Main) private val main: CoroutineDispatcher, +) : MusicRepository { + + private val _updateFlag = MutableStateFlow(0) + + override val config: Flow = kanadePreferencesDataStore.musicConfig.flowOn(main) + override val lastQueue: Flow = kanadePreferencesDataStore.lastQueue.flowOn(main) + override val updateFlag: StateFlow = _updateFlag.asStateFlow() + + override val songs: List get() = songRepository.gets() + override val artists: List get() = artistRepository.gets() + override val albums: List get() = albumRepository.gets() + override val playlists: List get() = playlistRepository.gets() + override val playHistory: List get() = playHistoryRepository.gets() + + override suspend fun clear() { + songRepository.clear() + artistRepository.clear() + albumRepository.clear() + playlistRepository.clear() + artworkRepository.clear() + } + + override suspend fun refresh() { + _updateFlag.tryEmit((updateFlag.firstOrNull() ?: 0) + 1) + } + + override fun sortedSongs(musicConfig: MusicConfig): List { + return songRepository.songsSort(songs, musicConfig) + } + + override fun sortedArtists(musicConfig: MusicConfig): List { + return artistRepository.artistsSort(artists, musicConfig) + } + + override fun sortedAlbums(musicConfig: MusicConfig): List { + return albumRepository.albumsSort(albums, musicConfig) + } + + override fun sortedPlaylists(musicConfig: MusicConfig): List { + return playlistRepository.playlistSort(playlists, musicConfig) + } + + override fun getSong(songId: Long): Song? { + return songRepository.get(songId) ?: songs.find { it.id == songId } + } + + override fun getArtist(artistId: Long): Artist? { + return artistRepository.get(artistId) ?: artists.find { it.artistId == artistId } + } + + override fun getAlbum(albumId: Long): Album? { + return albumRepository.get(albumId) ?: albums.find { it.albumId == albumId } + } + + override fun getPlaylist(playlistId: Long): Playlist? { + return playlistRepository.get(playlistId) ?: playlists.find { it.id == playlistId } + } + + override fun getLyrics(song: Song): Lyrics? { + return lyricsRepository.get(song) + } + + override fun getPlayHistory(song: Song): List { + return playHistoryRepository.gets(song) + } + + override fun getArtistDetail(artist: Artist): ArtistDetail? { + return lastFmRepository.getArtistDetails()[artist.artistId] + } + + override fun getAlbumDetail(album: Album): AlbumDetail? { + return lastFmRepository.getAlbumDetails()[album.albumId] + } + + override suspend fun saveQueue(currentQueue: List, originalQueue: List, index: Int) { + kanadePreferencesDataStore.setLastQueue( + currentItems = currentQueue.map { it.id }, + originalItems = originalQueue.map { it.id }, + index = index, + ) + } + + override suspend fun saveProgress(progress: Long) { + kanadePreferencesDataStore.setLastQueueProgress(progress) + } + + override suspend fun fetchSongs(musicConfig: MusicConfig?) { + songRepository.songs(musicConfig ?: config.first()) + } + + override suspend fun fetchArtists(musicConfig: MusicConfig?) { + artistRepository.artists(musicConfig ?: config.first()) + } + + override suspend fun fetchAlbums(musicConfig: MusicConfig?) { + albumRepository.albums(musicConfig ?: config.first()) + } + + override suspend fun fetchPlaylist(musicConfig: MusicConfig?) { + playlistRepository.playlists(musicConfig ?: config.first()) + } + + override suspend fun fetchArtistArtwork() { + artworkRepository.fetchArtistArtwork(artists) + songRepository.fetchArtwork() + artistRepository.fetchArtwork() + } + + override suspend fun fetchAlbumArtwork() { + artworkRepository.fetchAlbumArtwork(albums) + songRepository.fetchArtwork() + albumRepository.fetchArtwork() + } + + override suspend fun fetchLyrics(song: Song) { + lyricsRepository.lyrics(song) + } + + override suspend fun fetchPlayHistory() { + playHistoryRepository.playHistories() + } + + override suspend fun createPlaylist(name: String, songs: List) { + val items = songs.mapIndexed { index, song -> PlaylistItem(0, song, index) } + val playlist = Playlist(0, name, items.toSet(), createdAt = LocalDateTime.now()) + + playlistRepository.create(playlist) + } + + override suspend fun removePlaylist(playlist: Playlist) { + playlistRepository.remove(playlist) + } + + override suspend fun renamePlaylist(playlist: Playlist, name: String) { + playlistRepository.rename(playlist, name) + } + + override suspend fun addToPlaylist(playlist: Playlist, songs: List) { + playlistRepository.addItems(playlist.id, songs) + } + + override suspend fun removeFromPlaylist(playlist: Playlist, index: Int) { + playlistRepository.removeItem(playlist.id, index) + } + + override suspend fun moveItemInPlaylist(playlist: Playlist, fromIndex: Int, toIndex: Int) { + playlistRepository.moveItem(playlist.id, fromIndex, toIndex) + } + + override suspend fun isFavorite(song: Song): Boolean { + return playlistRepository.isFavorite(song) + } + + override suspend fun addToFavorite(song: Song) { + playlistRepository.addToFavorite(song) + } + + override suspend fun removeFromFavorite(song: Song) { + playlistRepository.removeFromFavorite(song) + } + + override suspend fun addToPlayHistory(song: Song) { + playHistoryRepository.add(song) + } + + override suspend fun getPlayedCount(): Map { + return playHistoryRepository + .gets() + .groupBy { it.song } + .map { it.key to it.value.size } + .sortedBy { it.second } + .toMap() + } + + override suspend fun setShuffleMode(mode: ShuffleMode) { + kanadePreferencesDataStore.setShuffleMode(mode) + } + + override suspend fun setRepeatMode(mode: RepeatMode) { + kanadePreferencesDataStore.setRepeatMode(mode) + } + + override suspend fun setSongOrder(musicOrder: MusicOrder) { + kanadePreferencesDataStore.setSongOrder(musicOrder) + } + + override suspend fun setArtistOrder(musicOrder: MusicOrder) { + kanadePreferencesDataStore.setArtistOrder(musicOrder) + } + + override suspend fun setAlbumOrder(musicOrder: MusicOrder) { + kanadePreferencesDataStore.setAlbumOrder(musicOrder) + } + + override suspend fun setPlaylistOrder(musicOrder: MusicOrder) { + kanadePreferencesDataStore.setPlaylistOrder(musicOrder) + } + + override suspend fun useSongFile(song: Song, action: (File?) -> T): T { + return songRepository.useFile(song, action) + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/MusixmatchLyricsRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/MusixmatchLyricsRepository.kt index 47ec42f7..6aff5680 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/MusixmatchLyricsRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/MusixmatchLyricsRepository.kt @@ -2,8 +2,8 @@ package caios.android.kanade.core.repository import caios.android.kanade.core.common.network.KanadeConfig import caios.android.kanade.core.common.network.di.ApplicationScope -import caios.android.kanade.core.datastore.LyricsPreference -import caios.android.kanade.core.datastore.TokenPreference +import caios.android.kanade.core.datastore.PreferenceLyrics +import caios.android.kanade.core.datastore.PreferenceToken import caios.android.kanade.core.model.entity.MusixmatchLyricsEntity import caios.android.kanade.core.model.entity.MusixmatchSongsEntity import caios.android.kanade.core.model.music.Lyrics @@ -26,8 +26,8 @@ import javax.inject.Inject class MusixmatchLyricsRepository @Inject constructor( private val client: HttpClient, - private val lyricsPreference: LyricsPreference, - private val tokenPreference: TokenPreference, + private val preferenceLyrics: PreferenceLyrics, + private val preferenceToken: PreferenceToken, private val kanadeConfig: KanadeConfig, @ApplicationScope private val scope: CoroutineScope, ) : LyricsRepository { @@ -38,7 +38,7 @@ class MusixmatchLyricsRepository @Inject constructor( init { scope.launch { - lyricsPreference.data.collect { lyrics -> + preferenceLyrics.data.collect { lyrics -> cache.clear() cache.putAll(lyrics.associateBy { it.songId }) @@ -50,7 +50,7 @@ class MusixmatchLyricsRepository @Inject constructor( override val data: SharedFlow> = _data.asSharedFlow() override suspend fun save(lyrics: Lyrics) { - lyricsPreference.save(lyrics) + preferenceLyrics.save(lyrics) } override fun get(song: Song): Lyrics? { @@ -59,7 +59,7 @@ class MusixmatchLyricsRepository @Inject constructor( override suspend fun lyrics(song: Song): Lyrics? { return kotlin.runCatching { - val token = tokenPreference.get(TokenPreference.KEY_MUSIXMATCH) ?: if (kanadeConfig.isDebug) kanadeConfig.musixmatchApiKey else return@runCatching null + val token = preferenceToken.get(PreferenceToken.KEY_MUSIXMATCH) ?: if (kanadeConfig.isDebug) kanadeConfig.musixmatchApiKey else return@runCatching null val songs = fetchSongs(token, song.title, song.artist) ?: return@runCatching null val track = findTrack(songs.message.body.trackList, (song.duration / 1000).toInt()) ?: return@runCatching null val entity = fetchLyrics(token, track.track.trackId) ?: return@runCatching null @@ -68,7 +68,7 @@ class MusixmatchLyricsRepository @Inject constructor( parseLrc(song, entity.message.body.subtitle.subtitleBody) }.fold( - onSuccess = { lyrics -> lyrics?.also { lyricsPreference.save(it) } }, + onSuccess = { lyrics -> lyrics?.also { preferenceLyrics.save(it) } }, onFailure = { Timber.w(it) null diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/PlayHistoryRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/PlayHistoryRepository.kt index d50a4820..3d2f7992 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/PlayHistoryRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/PlayHistoryRepository.kt @@ -1,8 +1,18 @@ package caios.android.kanade.core.repository +import caios.android.kanade.core.common.network.Dispatcher +import caios.android.kanade.core.common.network.KanadeDispatcher +import caios.android.kanade.core.database.history.PlayHistoryDao +import caios.android.kanade.core.database.history.PlayHistoryEntity import caios.android.kanade.core.model.music.PlayHistory import caios.android.kanade.core.model.music.Song +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext +import java.time.LocalDateTime +import javax.inject.Inject interface PlayHistoryRepository { @@ -16,3 +26,63 @@ interface PlayHistoryRepository { fun add(song: Song) } + +class PlayHistoryRepositoryImpl @Inject constructor( + private val songRepository: SongRepository, + private val playHistoryDao: PlayHistoryDao, + @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, +) : PlayHistoryRepository { + + private val cache = mutableListOf() + private val _data = MutableStateFlow(emptyList()) + + override val data: SharedFlow> = _data.asSharedFlow() + + override fun gets(song: Song): List = cache.filter { it.song == song }.sortedByDescending { it.playedAt } + + override fun gets(): List = cache.toList().sortedByDescending { it.playedAt } + + override suspend fun playHistory(song: Song): List = withContext(dispatcher) { + val histories = playHistoryDao.loadAll().mapNotNull { it.toModel() } + val data = histories.filter { it.song == song } + + return@withContext data.sortedBy { it.playedAt } + } + + override suspend fun playHistories(): List = withContext(dispatcher) { + playHistoryDao.loadAll().mapNotNull { it.toModel() }.sortedByDescending { it.playedAt }.also { + cache.clear() + cache.addAll(it) + _data.value = cache + } + } + + override fun add(song: Song) { + val data = PlayHistory( + id = 0, + song = song, + playedAt = LocalDateTime.now(), + ) + + playHistoryDao.insert(data.toEntity()) + } + + private fun PlayHistory.toEntity(): PlayHistoryEntity { + return PlayHistoryEntity( + id = id, + songId = song.id, + duration = song.duration, + createdAt = playedAt.toString(), + ) + } + + private fun PlayHistoryEntity.toModel(): PlayHistory? { + val song = songRepository.get(songId) ?: return null + + return PlayHistory( + id = id, + song = song, + playedAt = LocalDateTime.parse(createdAt), + ) + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/PlaylistRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/PlaylistRepository.kt index fbabab66..b27e9995 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/PlaylistRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/PlaylistRepository.kt @@ -1,9 +1,26 @@ package caios.android.kanade.core.repository +import caios.android.kanade.core.common.network.Dispatcher +import caios.android.kanade.core.common.network.KanadeDispatcher +import caios.android.kanade.core.database.playlist.PlaylistDao +import caios.android.kanade.core.database.playlist.PlaylistEntity +import caios.android.kanade.core.database.playlist.PlaylistItemEntity +import caios.android.kanade.core.database.playlist.PlaylistModel import caios.android.kanade.core.model.music.Playlist +import caios.android.kanade.core.model.music.PlaylistItem import caios.android.kanade.core.model.music.Song import caios.android.kanade.core.model.player.MusicConfig +import caios.android.kanade.core.model.player.MusicOrderOption +import caios.android.kanade.core.repository.util.sortList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.time.LocalDateTime +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject interface PlaylistRepository { @@ -31,3 +48,192 @@ interface PlaylistRepository { fun playlistSort(playlists: List, musicConfig: MusicConfig): List } + +class PlaylistRepositoryImpl @Inject constructor( + private val songRepository: SongRepository, + private val playlistDao: PlaylistDao, + @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, +) : PlaylistRepository { + + private val cache = ConcurrentHashMap() + private val _data = MutableStateFlow(emptyList()) + + override val data: SharedFlow> = _data.asSharedFlow() + + override fun clear() { + cache.clear() + _data.value = emptyList() + } + + override fun get(playlistId: Long): Playlist? = cache[playlistId] + + override fun gets(): List = cache.values.toList() + + private suspend fun fetchPlaylist() = withContext(dispatcher) { + cache.clear() + + playlistDao.loadAll().map { it.toData() }.onEach { cache[it.id] = it } + _data.value = cache.values.toList() + } + + override suspend fun playlist(playlistId: Long): Playlist? = withContext(dispatcher) { + playlistDao.load(playlistId)?.toData()?.also { + cache[playlistId] = it + _data.value = cache.values.toList() + } + } + + override suspend fun playlists(musicConfig: MusicConfig): List = withContext(dispatcher) { + val playlists = playlistDao.loadAll().map { it.toData() }.onEach { + cache[it.id] = it + _data.value = cache.values.toList() + } + playlistSort(playlists, musicConfig) + } + + override suspend fun create(playlist: Playlist): Unit = withContext(dispatcher) { + if (gets().any { it.name == playlist.name }) { + Timber.d("Playlist name is duplicated") + return@withContext + } + + val model = playlist.toModel() + val playlistId = playlistDao.insertPlaylist(model.playlist) + + playlistDao.insertPlaylistItem(*model.items.map { it.copy(playlistId = playlistId) }.toTypedArray()) + fetchPlaylist() + } + + override suspend fun remove(playlist: Playlist): Unit = withContext(dispatcher) { + playlistDao.delete(playlist.id) + fetchPlaylist() + } + + override suspend fun rename(playlist: Playlist, name: String) = withContext(dispatcher) { + val model = playlist.toModel() + val entity = model.playlist.copy(name = name) + + playlistDao.updatePlaylist(entity) + fetchPlaylist() + } + + override suspend fun addItems(playlistId: Long, songs: List) = withContext(dispatcher) { + val playlist = playlistDao.load(playlistId) ?: return@withContext + val items = songs.mapIndexed { index, song -> + PlaylistItemEntity( + id = 0, + playlistId = playlistId, + index = playlist.items.size + index, + songId = song.id, + ) + } + + playlistDao.insertPlaylistItem(*items.toTypedArray()) + fetchPlaylist() + } + + override suspend fun removeItem(playlistId: Long, index: Int) = withContext(dispatcher) { + val playlist = playlistDao.load(playlistId) ?: return@withContext + val items = playlist.items.toMutableList().apply { + removeIf { it.index == index } + sortedBy { it.index } + } + + playlistDao.deleteItem(playlist.items.find { it.index == index }!!.id) + playlistDao.updatePlaylistItem(*items.mapIndexed { i, item -> item.copy(index = i) }.toTypedArray()) + fetchPlaylist() + } + + override suspend fun moveItem(playlistId: Long, fromIndex: Int, toIndex: Int): Unit = withContext(dispatcher) { + playlistDao.changeIndexTransaction(playlistId, fromIndex, toIndex) + } + + override suspend fun isFavorite(song: Song): Boolean { + val favorite = cache.values.find { it.isSystemPlaylist } ?: return false + return favorite.items.any { it.song.id == song.id } + } + + override suspend fun addToFavorite(song: Song) { + val favorite = cache.values.find { it.isSystemPlaylist } + + if (favorite != null) { + addItems(favorite.id, listOf(song)) + } else { + createFavoritePlaylist(song) + } + } + + override suspend fun removeFromFavorite(song: Song) { + val favorite = cache.values.find { it.isSystemPlaylist } ?: return + removeItem(favorite.id, favorite.items.find { it.song.id == song.id }!!.index) + } + + override fun playlistSort(playlists: List, musicConfig: MusicConfig): List { + val order = musicConfig.playlistOrder + val option = order.option + + require(option is MusicOrderOption.Playlist) { "MusicOrderOption is not Playlist" } + + return when (option) { + MusicOrderOption.Playlist.NAME -> playlists.sortList({ it.name }, order = order.order) + MusicOrderOption.Playlist.TRACKS -> playlists.sortList({ it.items.size }, order = order.order) + } + } + + private suspend fun createFavoritePlaylist(song: Song): Playlist { + val favorite = Playlist( + id = 0, + name = "Favorite", + items = setOf( + PlaylistItem( + id = 0, + song = song, + index = 0, + ), + ), + isSystemPlaylist = true, + createdAt = LocalDateTime.now(), + ) + + create(favorite) + + return favorite + } + + private fun PlaylistModel.toData(): Playlist { + return Playlist( + id = playlist.id, + name = playlist.name, + items = items.mapNotNull { item -> + songRepository.get(item.songId)?.let { + PlaylistItem( + id = item.id, + song = it, + index = item.index, + ) + } + }.toSet(), + isSystemPlaylist = playlist.isSystemPlaylist, + createdAt = LocalDateTime.parse(playlist.createdAt), + ) + } + + private fun Playlist.toModel(): PlaylistModel { + return PlaylistModel().apply { + playlist = PlaylistEntity( + id = id, + name = name, + isSystemPlaylist = isSystemPlaylist, + createdAt = createdAt.toString(), + ) + items = this@toModel.items.map { + PlaylistItemEntity( + id = it.id, + playlistId = id, + index = it.index, + songId = it.song.id, + ) + } + } + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/SongRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/SongRepository.kt index 6ab7ecea..3d01265f 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/SongRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/SongRepository.kt @@ -1,12 +1,31 @@ package caios.android.kanade.core.repository +import android.content.ContentUris +import android.content.Context import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.provider.BaseColumns +import android.provider.MediaStore +import android.webkit.MimeTypeMap import caios.android.kanade.core.model.Order +import caios.android.kanade.core.model.music.Artwork import caios.android.kanade.core.model.music.Song import caios.android.kanade.core.model.player.MusicConfig import caios.android.kanade.core.model.player.MusicOrder import caios.android.kanade.core.model.player.MusicOrderOption +import caios.android.kanade.core.repository.util.getInt +import caios.android.kanade.core.repository.util.getLong +import caios.android.kanade.core.repository.util.getString +import caios.android.kanade.core.repository.util.getStringOrNull +import caios.android.kanade.core.repository.util.sortList +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import okhttp3.internal.toImmutableMap +import timber.log.Timber import java.io.File +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject interface SongRepository { @@ -34,3 +53,202 @@ interface SongRepository { fun fetchArtwork() fun songsSort(songs: List, musicConfig: MusicConfig): List } + +class SongRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val artworkRepository: ArtworkRepository, + private val userDataRepository: UserDataRepository, +) : SongRepository { + + private val cache = ConcurrentHashMap() + + private val baseProjection = arrayOf( + BaseColumns._ID, + MediaStore.Audio.AudioColumns.TITLE, + MediaStore.Audio.AudioColumns.TRACK, + MediaStore.Audio.AudioColumns.YEAR, + MediaStore.Audio.AudioColumns.DURATION, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.AudioColumns.DATE_MODIFIED, + MediaStore.Audio.AudioColumns.ALBUM_ID, + MediaStore.Audio.AudioColumns.ALBUM, + MediaStore.Audio.AudioColumns.ARTIST_ID, + MediaStore.Audio.AudioColumns.ARTIST, + MediaStore.Audio.AudioColumns.MIME_TYPE, + ) + + override fun clear() { + cache.clear() + } + + override fun get(songId: Long): Song? = cache[songId] + + override fun gets(songIds: List): List = songIds.mapNotNull { get(it) } + + override fun gets(): List = cache.values.toList() + + override suspend fun song(songId: Long, musicConfig: MusicConfig): Song? { + return song( + makeCursor( + selection = MediaStore.Audio.AudioColumns._ID, + selectionValues = listOf(songId.toString()), + musicOrders = arrayOf(musicConfig.songOrder), + ), + ) + } + + override suspend fun song(cursor: Cursor?): Song? { + val song = if (cursor != null && cursor.moveToFirst()) getSong(cursor) else null + cursor?.close() + return song + } + + override suspend fun songs(musicConfig: MusicConfig): List { + return songs(makeCursor(musicOrders = arrayOf(musicConfig.songOrder))) + } + + override suspend fun songs(cursor: Cursor?): List { + val songs = mutableListOf() + if (cursor != null && cursor.moveToFirst()) { + do { + songs.add(getSong(cursor)) + } while (cursor.moveToNext()) + } + cursor?.close() + return songs + } + + override suspend fun useFile(song: Song, action: (File?) -> T): T { + val file = kotlin.runCatching { + val outputFileName = "${song.id}.${MimeTypeMap.getSingleton().getExtensionFromMimeType(song.mimeType)}" + val outputFile = File(context.cacheDir, outputFileName) + + context.contentResolver.openInputStream(song.uri)?.use { inputStream -> + inputStream.copyTo(outputFile.outputStream()) + } + + if (outputFile.exists()) outputFile else null + }.getOrNull() + + val result = action.invoke(file) + + file?.delete() + + return result + } + + override suspend fun makeCursor( + selection: String, + selectionValues: List, + vararg musicOrders: MusicOrder, + ): Cursor? { + val order = musicOrders.joinToString(separator = ", ") { it.create() } + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) else MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + + var selectionFinal = MediaStore.Audio.AudioColumns.TITLE + " != ''" + var selectionValuesFinal = emptyArray() + + if (selection.isNotBlank()) { + selectionFinal += " AND $selection" + selectionValuesFinal += selectionValues + } + + if (userDataRepository.userData.first().isIgnoreNotMusic) { + selectionFinal += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1" + } + + if (userDataRepository.userData.first().isIgnoreShortMusic) { + selectionFinal += " AND ${MediaStore.Audio.Media.DURATION} >= 5000" + } + + return try { + context.contentResolver.query( + uri, + baseProjection, + selectionFinal, + selectionValuesFinal, + order, + ) + } catch (ex: SecurityException) { + Timber.w(ex, "Permission denied") + return null + } + } + + override suspend fun delete(songIds: List) { + val contentResolver = context.contentResolver + + for (uri in songIds.map { ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, it) }) { + contentResolver.delete(uri, null, null) + } + } + + override fun fetchArtwork() { + for ((albumId, artwork) in artworkRepository.albumArtworks.toImmutableMap()) { + for (song in cache.values.filter { it.albumId == albumId }) { + cache[song.id] = song.copy(albumArtwork = artwork) + } + } + + for ((artistId, artwork) in artworkRepository.artistArtworks.toImmutableMap()) { + for (song in cache.values.filter { it.artistId == artistId }) { + cache[song.id] = song.copy(artistArtwork = artwork) + } + } + } + + override fun songsSort(songs: List, musicConfig: MusicConfig): List { + val order = musicConfig.songOrder + val option = order.option + + require(option is MusicOrderOption.Song) { "MusicOrderOption is not Song" } + + return when (option) { + MusicOrderOption.Song.NAME -> songs.sortList({ it.title }, { it.artist }, order = order.order) + MusicOrderOption.Song.ARTIST -> songs.sortList({ it.artist }, { it.title }, order = order.order) + MusicOrderOption.Song.ALBUM -> songs.sortList({ it.album }, { it.title }, order = order.order) + MusicOrderOption.Song.DURATION -> songs.sortList({ it.duration }, { it.title }, order = order.order) + MusicOrderOption.Song.YEAR -> songs.sortList({ it.year }, { it.title }, order = order.order) + MusicOrderOption.Song.TRACK -> songs.sortList({ it.track }, { it.title }, order = order.order) + } + } + + private fun getSong(cursor: Cursor): Song { + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) else MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + val albumArtworks = artworkRepository.albumArtworks.toImmutableMap() + val artistArtworks = artworkRepository.artistArtworks.toImmutableMap() + + val id = cursor.getLong(MediaStore.Audio.AudioColumns._ID) + val title = cursor.getString(MediaStore.Audio.AudioColumns.TITLE) + val trackNumber = cursor.getInt(MediaStore.Audio.AudioColumns.TRACK) + val year = cursor.getInt(MediaStore.Audio.AudioColumns.YEAR) + val duration = cursor.getLong(MediaStore.Audio.AudioColumns.DURATION) + val data = cursor.getString(MediaStore.Audio.AudioColumns.DATA) + val dateModified = cursor.getLong(MediaStore.Audio.AudioColumns.DATE_MODIFIED) + val albumId = cursor.getLong(MediaStore.Audio.AudioColumns.ALBUM_ID) + val albumName = cursor.getStringOrNull(MediaStore.Audio.AudioColumns.ALBUM) + val artistId = cursor.getLong(MediaStore.Audio.AudioColumns.ARTIST_ID) + val artistName = cursor.getStringOrNull(MediaStore.Audio.AudioColumns.ARTIST) + val mimeType = cursor.getString(MediaStore.Audio.AudioColumns.MIME_TYPE) + + return Song( + id = id, + title = title, + artist = artistName ?: "", + artistId = artistId, + album = albumName ?: "", + albumId = albumId, + duration = duration, + year = year, + track = trackNumber, + mimeType = mimeType, + data = data, + dateModified = dateModified, + uri = Uri.withAppendedPath(uri, id.toString()), + albumArtwork = albumArtworks[albumId] ?: Artwork.Unknown, + artistArtwork = artistArtworks[artistId] ?: Artwork.Unknown, + ).also { + cache[id] = it + } + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/UserDataRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/UserDataRepository.kt index ccdab1cc..cc474cac 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/UserDataRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/UserDataRepository.kt @@ -1,14 +1,17 @@ package caios.android.kanade.core.repository +import caios.android.kanade.core.datastore.KanadePreferencesDataStore import caios.android.kanade.core.model.ThemeColorConfig import caios.android.kanade.core.model.ThemeConfig import caios.android.kanade.core.model.UserData import kotlinx.coroutines.flow.Flow +import javax.inject.Inject interface UserDataRepository { val userData: Flow + suspend fun setKanadeId(id: String) suspend fun setThemeConfig(themeConfig: ThemeConfig) suspend fun setThemeColorConfig(themeColorConfig: ThemeColorConfig) suspend fun setUseDynamicColor(useDynamicColor: Boolean) @@ -22,4 +25,72 @@ interface UserDataRepository { suspend fun setUseIgnoreNotMusic(isIgnoreNotMusic: Boolean) suspend fun setAgreedPrivacyPolicy(isAgreedPrivacyPolicy: Boolean) suspend fun setAgreedTermsOfService(isAgreedTermsOfService: Boolean) + suspend fun setEnableYTMusic(isEnableYTMusic: Boolean) +} + +class UserDataRepositoryImpl @Inject constructor( + private val kanadePreferencesDataStore: KanadePreferencesDataStore, +) : UserDataRepository { + + override val userData: Flow = kanadePreferencesDataStore.userData + + override suspend fun setKanadeId(id: String) { + kanadePreferencesDataStore.setKanadeId(id) + } + + override suspend fun setThemeConfig(themeConfig: ThemeConfig) { + kanadePreferencesDataStore.setThemeConfig(themeConfig) + } + + override suspend fun setThemeColorConfig(themeColorConfig: ThemeColorConfig) { + kanadePreferencesDataStore.setThemeColorConfig(themeColorConfig) + } + + override suspend fun setDeveloperMode(isDeveloperMode: Boolean) { + kanadePreferencesDataStore.setDeveloperMode(isDeveloperMode) + } + + override suspend fun setPlusMode(isPlusMode: Boolean) { + kanadePreferencesDataStore.setPlusMode(isPlusMode) + } + + override suspend fun setUseDynamicColor(useDynamicColor: Boolean) { + kanadePreferencesDataStore.setUseDynamicColor(useDynamicColor) + } + + override suspend fun setUseDynamicNormalizer(useDynamicNormalizer: Boolean) { + kanadePreferencesDataStore.setUseDynamicNormalizer(useDynamicNormalizer) + } + + override suspend fun setUseOneStepBack(isOneStepBack: Boolean) { + kanadePreferencesDataStore.setUseOneStepBack(isOneStepBack) + } + + override suspend fun setUseKeepAudioFocus(isKeepAudioFocus: Boolean) { + kanadePreferencesDataStore.setUseKeepAudioFocus(isKeepAudioFocus) + } + + override suspend fun setUseStopWhenTaskkill(isStopWhenTaskkill: Boolean) { + kanadePreferencesDataStore.setUseStopWhenTaskkill(isStopWhenTaskkill) + } + + override suspend fun setUseIgnoreShortMusic(isIgnoreShortMusic: Boolean) { + kanadePreferencesDataStore.setUseIgnoreShortMusic(isIgnoreShortMusic) + } + + override suspend fun setUseIgnoreNotMusic(isIgnoreNotMusic: Boolean) { + kanadePreferencesDataStore.setUseIgnoreNotMusic(isIgnoreNotMusic) + } + + override suspend fun setAgreedPrivacyPolicy(isAgreedPrivacyPolicy: Boolean) { + kanadePreferencesDataStore.setAgreedPrivacyPolicy(isAgreedPrivacyPolicy) + } + + override suspend fun setAgreedTermsOfService(isAgreedTermsOfService: Boolean) { + kanadePreferencesDataStore.setAgreedTermsOfService(isAgreedTermsOfService) + } + + override suspend fun setEnableYTMusic(isEnableYTMusic: Boolean) { + kanadePreferencesDataStore.setEnableYTMusic(isEnableYTMusic) + } } diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/YTMusicRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/YTMusicRepository.kt new file mode 100644 index 00000000..5d2a5f87 --- /dev/null +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/YTMusicRepository.kt @@ -0,0 +1,97 @@ +package caios.android.kanade.core.repository + +import caios.android.kanade.core.common.network.KanadeConfig +import caios.android.kanade.core.datastore.PreferenceYTMusic +import caios.android.kanade.core.model.entity.YTMusicInfo +import caios.android.kanade.core.model.entity.YTMusicOAuthCode +import caios.android.kanade.core.model.entity.YTMusicOAuthRefreshToken +import caios.android.kanade.core.model.entity.YTMusicOAuthToken +import caios.android.kanade.core.repository.util.parse +import io.ktor.client.HttpClient +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.header +import io.ktor.http.Parameters +import javax.inject.Inject + +interface YTMusicRepository { + + suspend fun getOAuthCode(): YTMusicOAuthCode? + suspend fun getOAuthToken(code: YTMusicOAuthCode): YTMusicOAuthToken? + + suspend fun refreshToken(token: YTMusicOAuthToken): YTMusicOAuthRefreshToken? + + fun saveToken(token: YTMusicOAuthToken) + fun removeToken() + fun getTokenFilePath(): String + fun getOAuthToken(): YTMusicOAuthToken? +} + +class YTMusicRepositoryImpl @Inject constructor( + private val client: HttpClient, + private val preferenceYTMusic: PreferenceYTMusic, + private val kanadeConfig: KanadeConfig, +) : YTMusicRepository { + + init { + client.config { + defaultRequest { + header("User-Agent", YTMusicInfo.OAUTH_USER_AGENT) + } + } + } + + override suspend fun getOAuthCode(): YTMusicOAuthCode? { + return client.submitForm( + url = YTMusicInfo.OAUTH_CODE_URL, + formParameters = Parameters.build { + append("client_id", kanadeConfig.ytMusicApiKey) + append("scope", YTMusicInfo.OAUTH_SCOPE) + }, + ).parse() + } + + override suspend fun getOAuthToken(code: YTMusicOAuthCode): YTMusicOAuthToken? { + return client.submitForm( + url = YTMusicInfo.OAUTH_TOKEN_URL, + formParameters = Parameters.build { + append("client_id", kanadeConfig.ytMusicApiKey) + append("client_secret", kanadeConfig.ytMusicApiSecret) + append("code", code.deviceCode) + append("grant_type", "http://oauth.net/grant_type/device/1.0") + }, + ).parse()?.let { + it.copy(expiresAt = (System.currentTimeMillis() / 1000).toInt() + it.expiresIn) + } + } + + override suspend fun refreshToken(token: YTMusicOAuthToken): YTMusicOAuthRefreshToken? { + return client.submitForm( + url = YTMusicInfo.OAUTH_TOKEN_URL, + formParameters = Parameters.build { + append("client_id", kanadeConfig.ytMusicApiKey) + append("client_secret", kanadeConfig.ytMusicApiSecret) + append("refresh_token", token.refreshToken) + append("grant_type", "refresh_token") + }, + ).parse()?.let { + it.copy(expiresAt = (System.currentTimeMillis() / 1000).toInt() + it.expiresIn) + } + } + + override fun saveToken(token: YTMusicOAuthToken) { + preferenceYTMusic.saveToken(token) + } + + override fun removeToken() { + preferenceYTMusic.removeToken() + } + + override fun getTokenFilePath(): String { + return preferenceYTMusic.getTokenFilePath() + } + + override fun getOAuthToken(): YTMusicOAuthToken? { + return preferenceYTMusic.getToken() + } +} diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/di/RepositoryModule.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/di/RepositoryModule.kt index 025df4de..d052497b 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/di/RepositoryModule.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/di/RepositoryModule.kt @@ -1,28 +1,30 @@ package caios.android.kanade.core.repository.di import caios.android.kanade.core.repository.AlbumRepository +import caios.android.kanade.core.repository.AlbumRepositoryImpl import caios.android.kanade.core.repository.ArtistRepository +import caios.android.kanade.core.repository.ArtistRepositoryImpl import caios.android.kanade.core.repository.ArtworkRepository -import caios.android.kanade.core.repository.DefaultAlbumRepository -import caios.android.kanade.core.repository.DefaultArtistRepository -import caios.android.kanade.core.repository.DefaultArtworkRepository -import caios.android.kanade.core.repository.DefaultExternalPlaylistRepository -import caios.android.kanade.core.repository.DefaultLastFmRepository -import caios.android.kanade.core.repository.DefaultMusicRepository -import caios.android.kanade.core.repository.DefaultPlayHistoryRepository -import caios.android.kanade.core.repository.DefaultPlaylistRepository -import caios.android.kanade.core.repository.DefaultSongRepository -import caios.android.kanade.core.repository.DefaultUserDataRepository +import caios.android.kanade.core.repository.ArtworkRepositoryImpl import caios.android.kanade.core.repository.ExternalPlaylistRepository +import caios.android.kanade.core.repository.ExternalPlaylistRepositoryImpl import caios.android.kanade.core.repository.KugouLyricsRepository import caios.android.kanade.core.repository.LastFmRepository +import caios.android.kanade.core.repository.LastFmRepositoryImpl import caios.android.kanade.core.repository.LyricsRepository import caios.android.kanade.core.repository.MusicRepository +import caios.android.kanade.core.repository.MusicRepositoryImpl import caios.android.kanade.core.repository.MusixmatchLyricsRepository import caios.android.kanade.core.repository.PlayHistoryRepository +import caios.android.kanade.core.repository.PlayHistoryRepositoryImpl import caios.android.kanade.core.repository.PlaylistRepository +import caios.android.kanade.core.repository.PlaylistRepositoryImpl import caios.android.kanade.core.repository.SongRepository +import caios.android.kanade.core.repository.SongRepositoryImpl import caios.android.kanade.core.repository.UserDataRepository +import caios.android.kanade.core.repository.UserDataRepositoryImpl +import caios.android.kanade.core.repository.YTMusicRepository +import caios.android.kanade.core.repository.YTMusicRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -45,49 +47,49 @@ interface RepositoryModule { @Singleton @Binds fun bindUserDataRepository( - userDataRepository: DefaultUserDataRepository, + userDataRepository: UserDataRepositoryImpl, ): UserDataRepository @Singleton @Binds fun bindMusicRepository( - musicRepository: DefaultMusicRepository, + musicRepository: MusicRepositoryImpl, ): MusicRepository @Singleton @Binds fun bindSongRepository( - songRepository: DefaultSongRepository, + songRepository: SongRepositoryImpl, ): SongRepository @Singleton @Binds fun bindArtistRepository( - artistRepository: DefaultArtistRepository, + artistRepository: ArtistRepositoryImpl, ): ArtistRepository @Singleton @Binds fun bindAlbumRepository( - albumRepository: DefaultAlbumRepository, + albumRepository: AlbumRepositoryImpl, ): AlbumRepository @Singleton @Binds fun bindPlaylistRepository( - playlistRepository: DefaultPlaylistRepository, + playlistRepository: PlaylistRepositoryImpl, ): PlaylistRepository @Singleton @Binds fun bindExternalPlaylistRepository( - externalPlaylistRepository: DefaultExternalPlaylistRepository, + externalPlaylistRepository: ExternalPlaylistRepositoryImpl, ): ExternalPlaylistRepository @Singleton @Binds fun bindArtworkRepository( - artworkRepository: DefaultArtworkRepository, + artworkRepository: ArtworkRepositoryImpl, ): ArtworkRepository @LyricsKugou @@ -107,12 +109,18 @@ interface RepositoryModule { @Singleton @Binds fun bindPlayHistoryRepository( - playHistoryRepository: DefaultPlayHistoryRepository, + playHistoryRepository: PlayHistoryRepositoryImpl, ): PlayHistoryRepository @Singleton @Binds fun bindLastFmRepository( - lastFmRepository: DefaultLastFmRepository, + lastFmRepository: LastFmRepositoryImpl, ): LastFmRepository + + @Singleton + @Binds + fun bindYTMusicRepository( + yTMusicRepository: YTMusicRepositoryImpl, + ): YTMusicRepository } diff --git a/core/repository/src/main/java/caios/android/kanade/core/repository/fake/FakeUserDataRepository.kt b/core/repository/src/main/java/caios/android/kanade/core/repository/fake/FakeUserDataRepository.kt index b540755d..bc7299a1 100644 --- a/core/repository/src/main/java/caios/android/kanade/core/repository/fake/FakeUserDataRepository.kt +++ b/core/repository/src/main/java/caios/android/kanade/core/repository/fake/FakeUserDataRepository.kt @@ -14,6 +14,10 @@ class FakeUserDataRepository @Inject constructor( override val userData: Flow = kanadePreferencesDataStore.userData + override suspend fun setKanadeId(id: String) { + kanadePreferencesDataStore.setKanadeId(id) + } + override suspend fun setThemeConfig(themeConfig: ThemeConfig) { kanadePreferencesDataStore.setThemeConfig(themeConfig) } @@ -65,4 +69,8 @@ class FakeUserDataRepository @Inject constructor( override suspend fun setAgreedTermsOfService(isAgreedTermsOfService: Boolean) { kanadePreferencesDataStore.setAgreedTermsOfService(isAgreedTermsOfService) } + + override suspend fun setEnableYTMusic(isEnableYTMusic: Boolean) { + kanadePreferencesDataStore.setEnableYTMusic(isEnableYTMusic) + } } diff --git a/core/ui/src/main/java/caios/android/kanade/core/ui/AsyncLoadContents.kt b/core/ui/src/main/java/caios/android/kanade/core/ui/AsyncLoadContents.kt index 06eeecf4..3e5f9f34 100644 --- a/core/ui/src/main/java/caios/android/kanade/core/ui/AsyncLoadContents.kt +++ b/core/ui/src/main/java/caios/android/kanade/core/ui/AsyncLoadContents.kt @@ -7,7 +7,8 @@ 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.fillMaxSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -44,12 +45,14 @@ fun AsyncLoadContents( } is ScreenState.Loading -> { LoadingView( - modifier = otherModifier.background(Color.Black.copy(alpha = 0.2f)), + modifier = otherModifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.2f)), ) } is ScreenState.Error -> { ErrorView( - modifier = otherModifier, + modifier = otherModifier.fillMaxWidth(), errorState = state, retryAction = retryAction, ) @@ -58,32 +61,66 @@ fun AsyncLoadContents( } } +@Composable +fun AsyncLoadContentsWithoutAnimation( + screenState: ScreenState, + modifier: Modifier = Modifier, + otherModifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surface, + cornerShape: RoundedCornerShape = RoundedCornerShape(0.dp), + retryAction: () -> Unit = {}, + content: @Composable (T) -> Unit, +) { + Box( + modifier = modifier + .clip(cornerShape) + .background(containerColor), + ) { + when (screenState) { + is ScreenState.Idle -> { + content.invoke(screenState.data) + } + is ScreenState.Loading -> { + LoadingView( + modifier = otherModifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.2f)), + ) + } + is ScreenState.Error -> { + ErrorView( + modifier = otherModifier.fillMaxWidth(), + errorState = screenState, + retryAction = retryAction, + ) + } + } + } +} + @Composable fun AsyncNoLoadContents( screenState: ScreenState, modifier: Modifier = Modifier, + otherModifier: Modifier = Modifier, containerColor: Color = MaterialTheme.colorScheme.surface, cornerShape: RoundedCornerShape = RoundedCornerShape(0.dp), retryAction: () -> Unit = {}, content: @Composable (T?) -> Unit, ) { - AnimatedContent( + Box( modifier = modifier .clip(cornerShape) .background(containerColor), - targetState = screenState, - transitionSpec = { fadeIn().togetherWith(fadeOut()) }, - contentKey = { it.javaClass }, - label = "AsyncNoLoadContents", - ) { state -> - when (state) { + ) { + when (screenState) { is ScreenState.Idle, is ScreenState.Loading -> { - content.invoke((state as? ScreenState.Idle)?.data) + content.invoke((screenState as? ScreenState.Idle)?.data) } is ScreenState.Error -> { ErrorView( - modifier = Modifier.fillMaxSize(), - errorState = state, + modifier = otherModifier.fillMaxWidth(), + errorState = screenState, retryAction = retryAction, ) } diff --git a/core/ui/src/main/java/caios/android/kanade/core/ui/dialog/BottomSheetWrapper.kt b/core/ui/src/main/java/caios/android/kanade/core/ui/dialog/BottomSheetWrapper.kt index 371e6832..dc4136f3 100644 --- a/core/ui/src/main/java/caios/android/kanade/core/ui/dialog/BottomSheetWrapper.kt +++ b/core/ui/src/main/java/caios/android/kanade/core/ui/dialog/BottomSheetWrapper.kt @@ -3,6 +3,7 @@ package caios.android.kanade.core.ui.dialog import android.app.Activity import android.graphics.Rect import android.view.ViewGroup +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.WindowInsets @@ -75,6 +76,13 @@ private fun BottomSheetWrapper( ), onDismissRequest = { isOpen = false }, ) { + BackHandler { + scope.launch { + state.hide() + isOpen = false + } + } + content { scope.launch { state.hide() diff --git a/feature/download/src/main/java/caios/android/kanade/feature/download/format/DownloadFormatViewModel.kt b/feature/download/src/main/java/caios/android/kanade/feature/download/format/DownloadFormatViewModel.kt index c9adea74..a2655f3f 100644 --- a/feature/download/src/main/java/caios/android/kanade/feature/download/format/DownloadFormatViewModel.kt +++ b/feature/download/src/main/java/caios/android/kanade/feature/download/format/DownloadFormatViewModel.kt @@ -9,7 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import caios.android.kanade.core.common.network.Dispatcher import caios.android.kanade.core.common.network.KanadeDispatcher -import caios.android.kanade.core.datastore.DownloadPathPreference +import caios.android.kanade.core.datastore.PreferenceDownloadPath import caios.android.kanade.core.model.ScreenState import caios.android.kanade.core.model.download.VideoInfo import caios.android.kanade.core.repository.MusicRepository @@ -36,7 +36,7 @@ import kotlin.coroutines.suspendCoroutine class DownloadFormatViewModel @Inject constructor( private val userDataRepository: UserDataRepository, private val musicRepository: MusicRepository, - private val downloadPathPreference: DownloadPathPreference, + private val preferenceDownloadPath: PreferenceDownloadPath, @Dispatcher(KanadeDispatcher.IO) private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { @@ -68,7 +68,7 @@ class DownloadFormatViewModel @Inject constructor( fun fetch(context: Context, videoInfo: VideoInfo) { viewModelScope.launch { val userData = userDataRepository.userData.first() - val uri = withContext(ioDispatcher) { downloadPathPreference.getUri() } + val uri = withContext(ioDispatcher) { preferenceDownloadPath.getUri() } val uniFile = UniFile.fromUri(context, uri) _screenState.value = ScreenState.Idle( @@ -118,7 +118,7 @@ class DownloadFormatViewModel @Inject constructor( } fun updateSaveUri(context: Context, savePath: Uri) { - downloadPathPreference.saveUri(savePath) + preferenceDownloadPath.saveUri(savePath) val state = screenState.value val uniFile = UniFile.fromUri(context, savePath) diff --git a/feature/equalizer/src/main/java/caios/android/kanade/feature/equalizer/EqualizerViewModel.kt b/feature/equalizer/src/main/java/caios/android/kanade/feature/equalizer/EqualizerViewModel.kt index 1180b069..23597057 100644 --- a/feature/equalizer/src/main/java/caios/android/kanade/feature/equalizer/EqualizerViewModel.kt +++ b/feature/equalizer/src/main/java/caios/android/kanade/feature/equalizer/EqualizerViewModel.kt @@ -3,7 +3,7 @@ package caios.android.kanade.feature.equalizer import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import caios.android.kanade.core.datastore.EqualizerPreference +import caios.android.kanade.core.datastore.PreferenceEqualizer import caios.android.kanade.core.model.ScreenState import caios.android.kanade.core.model.music.Equalizer import dagger.hilt.android.lifecycle.HiltViewModel @@ -16,10 +16,10 @@ import javax.inject.Inject @Stable @HiltViewModel class EqualizerViewModel @Inject constructor( - private val equalizerPreference: EqualizerPreference, + private val preferenceEqualizer: PreferenceEqualizer, ) : ViewModel() { - val screenState = equalizerPreference.data.map { + val screenState = preferenceEqualizer.data.map { ScreenState.Idle(EqualizerUiState(it)) }.stateIn( scope = viewModelScope, @@ -29,19 +29,19 @@ class EqualizerViewModel @Inject constructor( fun updatePreset(preset: Equalizer.Preset) { viewModelScope.launch { - equalizerPreference.setPreset(preset) + preferenceEqualizer.setPreset(preset) } } fun updateBand(band: Equalizer.Band, value: Float) { viewModelScope.launch { - equalizerPreference.setHz(band.hz, value) + preferenceEqualizer.setHz(band.hz, value) } } fun updateBassBoost(value: Float) { viewModelScope.launch { - equalizerPreference.setBassBoost(value) + preferenceEqualizer.setBassBoost(value) } } } diff --git a/feature/information/src/main/java/caios/android/kanade/feature/information/about/AboutViewModel.kt b/feature/information/src/main/java/caios/android/kanade/feature/information/about/AboutViewModel.kt index 48e42e95..b28aec0d 100644 --- a/feature/information/src/main/java/caios/android/kanade/feature/information/about/AboutViewModel.kt +++ b/feature/information/src/main/java/caios/android/kanade/feature/information/about/AboutViewModel.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import caios.android.kanade.core.common.network.KanadeConfig -import caios.android.kanade.core.datastore.VersionPreference +import caios.android.kanade.core.datastore.PreferenceVersion import caios.android.kanade.core.model.ScreenState import caios.android.kanade.core.model.UserData import caios.android.kanade.core.model.Version @@ -20,7 +20,7 @@ import javax.inject.Inject class AboutViewModel @Inject constructor( kanadeConfig: KanadeConfig, userDataRepository: UserDataRepository, - versionPreference: VersionPreference, + preferenceVersion: PreferenceVersion, ) : ViewModel() { val screenState = userDataRepository.userData.map { @@ -28,7 +28,7 @@ class AboutViewModel @Inject constructor( AboutUiState( userData = it, config = kanadeConfig, - versions = versionPreference.get(), + versions = preferenceVersion.get(), ), ) }.stateIn( diff --git a/feature/information/src/main/java/caios/android/kanade/feature/information/song/SongInformationDialog.kt b/feature/information/src/main/java/caios/android/kanade/feature/information/song/SongInformationDialog.kt index c39892d7..f5c353f1 100644 --- a/feature/information/src/main/java/caios/android/kanade/feature/information/song/SongInformationDialog.kt +++ b/feature/information/src/main/java/caios/android/kanade/feature/information/song/SongInformationDialog.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import caios.android.kanade.core.design.R import caios.android.kanade.core.model.music.Song import caios.android.kanade.core.model.music.Volume -import caios.android.kanade.core.ui.AsyncLoadContents +import caios.android.kanade.core.ui.AsyncLoadContentsWithoutAnimation @Composable internal fun SongInformationRoute( @@ -37,7 +37,7 @@ internal fun SongInformationRoute( viewModel.fetch(songId) } - AsyncLoadContents( + AsyncLoadContentsWithoutAnimation( modifier = modifier, screenState = screenState, cornerShape = RoundedCornerShape(16.dp), diff --git a/feature/lyrics/src/main/java/caios/android/kanade/feature/lyrics/download/LyricsDownloadDialog.kt b/feature/lyrics/src/main/java/caios/android/kanade/feature/lyrics/download/LyricsDownloadDialog.kt index 090b30e0..b0d8a6b3 100644 --- a/feature/lyrics/src/main/java/caios/android/kanade/feature/lyrics/download/LyricsDownloadDialog.kt +++ b/feature/lyrics/src/main/java/caios/android/kanade/feature/lyrics/download/LyricsDownloadDialog.kt @@ -35,7 +35,7 @@ import caios.android.kanade.core.common.network.util.ToastUtil import caios.android.kanade.core.design.R import caios.android.kanade.core.design.component.KanadeBackground import caios.android.kanade.core.model.music.Song -import caios.android.kanade.core.ui.AsyncLoadContents +import caios.android.kanade.core.ui.AsyncLoadContentsWithoutAnimation @Composable internal fun LyricsDownloadRoute( @@ -51,7 +51,7 @@ internal fun LyricsDownloadRoute( viewModel.fetch(songId) } - AsyncLoadContents( + AsyncLoadContentsWithoutAnimation( modifier = modifier, screenState = screenState, retryAction = { terminate.invoke() }, diff --git a/feature/lyrics/src/main/java/caios/android/kanade/feature/lyrics/download/LyricsDownloadViewModel.kt b/feature/lyrics/src/main/java/caios/android/kanade/feature/lyrics/download/LyricsDownloadViewModel.kt index 55f8329f..80c316ed 100644 --- a/feature/lyrics/src/main/java/caios/android/kanade/feature/lyrics/download/LyricsDownloadViewModel.kt +++ b/feature/lyrics/src/main/java/caios/android/kanade/feature/lyrics/download/LyricsDownloadViewModel.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import caios.android.kanade.core.common.network.KanadeConfig -import caios.android.kanade.core.datastore.TokenPreference +import caios.android.kanade.core.datastore.PreferenceToken import caios.android.kanade.core.design.R import caios.android.kanade.core.model.ScreenState import caios.android.kanade.core.model.music.Lyrics @@ -24,7 +24,7 @@ import javax.inject.Inject class LyricsDownloadViewModel @Inject constructor( private val musicRepository: MusicRepository, private val kanadeConfig: KanadeConfig, - private val tokenPreference: TokenPreference, + private val preferenceToken: PreferenceToken, @LyricsKugou private val kugouLyrics: LyricsRepository, @LyricsMusixmatch private val musixmatchLyrics: LyricsRepository, ) : ViewModel() { @@ -39,7 +39,7 @@ class LyricsDownloadViewModel @Inject constructor( val song = musicRepository.getSong(songId) val lyrics = song?.let { kugouLyrics.get(it) } - val token = tokenPreference.get(TokenPreference.KEY_MUSIXMATCH) ?: if (kanadeConfig.isDebug) kanadeConfig.musixmatchApiKey else null + val token = preferenceToken.get(PreferenceToken.KEY_MUSIXMATCH) ?: if (kanadeConfig.isDebug) kanadeConfig.musixmatchApiKey else null _screenState.value = if (song != null) { ScreenState.Idle( @@ -64,7 +64,7 @@ class LyricsDownloadViewModel @Inject constructor( val lyrics = kotlin.runCatching { if (isUseMusixmatch && token != null) { - tokenPreference.set(TokenPreference.KEY_MUSIXMATCH, token) + preferenceToken.set(PreferenceToken.KEY_MUSIXMATCH, token) musixmatchLyrics.lyrics(song) } else { kugouLyrics.lyrics(song) diff --git a/feature/playlist/src/main/java/caios/android/kanade/feature/playlist/detail/PlaylistDetailScreen.kt b/feature/playlist/src/main/java/caios/android/kanade/feature/playlist/detail/PlaylistDetailScreen.kt index 89e07744..b6e48e01 100644 --- a/feature/playlist/src/main/java/caios/android/kanade/feature/playlist/detail/PlaylistDetailScreen.kt +++ b/feature/playlist/src/main/java/caios/android/kanade/feature/playlist/detail/PlaylistDetailScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.DismissDirection +import androidx.compose.material3.DismissValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -150,6 +151,8 @@ private fun PlaylistDetailScreen( ) { val dismissState = rememberDismissState( confirmValueChange = { + if (it == DismissValue.Default) return@rememberDismissState false + onDeleteItem.invoke(playlist, getItemIndex(item)) data = data.apply { remove(item) } true @@ -157,7 +160,9 @@ private fun PlaylistDetailScreen( ) SwipeToDismiss( - modifier = Modifier.animateItemPlacement(), + modifier = Modifier + .fillMaxWidth() + .animateItemPlacement(), state = dismissState, background = { }, dismissContent = { diff --git a/feature/playlist/src/main/java/caios/android/kanade/feature/playlist/external/ImportPlaylistDialog.kt b/feature/playlist/src/main/java/caios/android/kanade/feature/playlist/external/ImportPlaylistDialog.kt index ce634e5f..0fe5d568 100644 --- a/feature/playlist/src/main/java/caios/android/kanade/feature/playlist/external/ImportPlaylistDialog.kt +++ b/feature/playlist/src/main/java/caios/android/kanade/feature/playlist/external/ImportPlaylistDialog.kt @@ -26,7 +26,7 @@ import caios.android.kanade.core.common.network.util.ToastUtil import caios.android.kanade.core.design.R import caios.android.kanade.core.model.music.ExternalPlaylist import caios.android.kanade.core.model.music.Playlist -import caios.android.kanade.core.ui.AsyncLoadContents +import caios.android.kanade.core.ui.AsyncLoadContentsWithoutAnimation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -38,7 +38,7 @@ internal fun ImportPlaylistRoute( ) { val screenState by viewModel.screenState.collectAsStateWithLifecycle() - AsyncLoadContents( + AsyncLoadContentsWithoutAnimation( modifier = modifier, screenState = screenState, cornerShape = RoundedCornerShape(16.dp), diff --git a/feature/queue/src/main/java/caios/android/kanade/feature/queue/items/QueueListSection.kt b/feature/queue/src/main/java/caios/android/kanade/feature/queue/items/QueueListSection.kt index 6cd03ed7..52691161 100644 --- a/feature/queue/src/main/java/caios/android/kanade/feature/queue/items/QueueListSection.kt +++ b/feature/queue/src/main/java/caios/android/kanade/feature/queue/items/QueueListSection.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.DismissDirection +import androidx.compose.material3.DismissValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SwipeToDismiss @@ -74,7 +75,10 @@ internal fun QueueListSection( key = "${item.index}", ) { isDragging -> val dismissState = rememberDismissState( - confirmValueChange = { onDeleteItem(item) }, + confirmValueChange = { + if (it == DismissValue.Default) return@rememberDismissState false + onDeleteItem(item) + }, ) val elevation = animateDpAsState( targetValue = if (isDragging) 16.dp else 0.dp, diff --git a/feature/search/src/main/java/caios/android/kanade/feature/search/top/SearchViewModel.kt b/feature/search/src/main/java/caios/android/kanade/feature/search/top/SearchViewModel.kt index cce0ae7c..92b52cc0 100644 --- a/feature/search/src/main/java/caios/android/kanade/feature/search/top/SearchViewModel.kt +++ b/feature/search/src/main/java/caios/android/kanade/feature/search/top/SearchViewModel.kt @@ -7,20 +7,26 @@ import caios.android.kanade.core.common.network.Dispatcher import caios.android.kanade.core.common.network.KanadeDispatcher import caios.android.kanade.core.design.R import caios.android.kanade.core.model.ScreenState +import caios.android.kanade.core.model.entity.YTMusicSearch import caios.android.kanade.core.model.music.Album import caios.android.kanade.core.model.music.Artist import caios.android.kanade.core.model.music.Playlist import caios.android.kanade.core.model.music.Song import caios.android.kanade.core.model.player.PlayerEvent import caios.android.kanade.core.music.MusicController +import caios.android.kanade.core.music.YTMusic import caios.android.kanade.core.repository.MusicRepository +import caios.android.kanade.core.repository.UserDataRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -29,6 +35,8 @@ import javax.inject.Inject class SearchViewModel @Inject constructor( private val musicController: MusicController, private val musicRepository: MusicRepository, + private val userDataRepository: UserDataRepository, + private val ytMusic: YTMusic, @Dispatcher(KanadeDispatcher.IO) private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { @@ -36,6 +44,16 @@ class SearchViewModel @Inject constructor( val screenState = _screenState.asStateFlow() + init { + viewModelScope.launch { + userDataRepository.userData.collectLatest { + (screenState.value as? ScreenState.Idle)?.also { state -> + _screenState.value = ScreenState.Idle(state.data.copy(isEnableYTMusic = it.isEnableYTMusic)) + } + } + } + } + fun onNewPlay(songs: List, index: Int) { musicController.playerEvent( PlayerEvent.NewPlay( @@ -62,16 +80,19 @@ class SearchViewModel @Inject constructor( val artists = musicRepository.sortedArtists(config) val albums = musicRepository.sortedAlbums(config) val playlists = musicRepository.sortedPlaylists(config) + val userData = userDataRepository.userData.firstOrNull() val searchSongsJob = searchSongs(keywords, songs) val searchArtistsJob = searchArtists(keywords, artists) val searchAlbumsJob = searchAlbums(keywords, albums) val searchPlaylistsJob = searchPlaylists(keywords, playlists) + val searchYTMusicJob = if (userData?.isEnableYTMusic == true) searchYTMusic(keywords) else null val (resultSongs, resultSongsRangeMap) = searchSongsJob.await() val (resultArtists, resultArtistsRangeMap) = searchArtistsJob.await() val (resultAlbums, resultAlbumsRangeMap) = searchAlbumsJob.await() val (resultPlaylists, resultPlaylistsRangeMap) = searchPlaylistsJob.await() + val resultYTMusic = searchYTMusicJob?.await() ?: emptyList() delay(100) @@ -81,10 +102,12 @@ class SearchViewModel @Inject constructor( resultArtists = resultArtists, resultAlbums = resultAlbums, resultPlaylists = resultPlaylists, + resultYTMusic = resultYTMusic, resultSongsRangeMap = resultSongsRangeMap, resultArtistsRangeMap = resultArtistsRangeMap, resultAlbumsRangeMap = resultAlbumsRangeMap, resultPlaylistsRangeMap = resultPlaylistsRangeMap, + isEnableYTMusic = userData?.isEnableYTMusic ?: false, ) } @@ -167,15 +190,26 @@ class SearchViewModel @Inject constructor( return@async (resultPlaylists to resultRangeMap) } + + private fun searchYTMusic(keywords: List) = viewModelScope.async { + if (keywords.all { it.isEmpty() } || keywords.isEmpty()) return@async emptyList() + + return@async ytMusic.search(keywords.first()).fold( + onSuccess = { it }, + onFailure = { emptyList() }, + ) + } } @Stable data class SearchUiState( + val isEnableYTMusic: Boolean = false, val keywords: List = emptyList(), val resultSongs: List = emptyList(), val resultArtists: List = emptyList(), val resultAlbums: List = emptyList(), val resultPlaylists: List = emptyList(), + val resultYTMusic: List = emptyList(), val resultSongsRangeMap: Map = emptyMap(), val resultArtistsRangeMap: Map = emptyMap(), val resultAlbumsRangeMap: Map = emptyMap(), diff --git a/feature/search/src/main/java/caios/android/kanade/feature/search/top/items/SearchResultSection.kt b/feature/search/src/main/java/caios/android/kanade/feature/search/top/items/SearchResultSection.kt index 35919643..3d5931a1 100644 --- a/feature/search/src/main/java/caios/android/kanade/feature/search/top/items/SearchResultSection.kt +++ b/feature/search/src/main/java/caios/android/kanade/feature/search/top/items/SearchResultSection.kt @@ -6,9 +6,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import caios.android.kanade.core.design.R +import caios.android.kanade.core.design.theme.center import caios.android.kanade.core.model.music.Album import caios.android.kanade.core.model.music.Artist import caios.android.kanade.core.model.music.Playlist @@ -32,6 +37,22 @@ internal fun SearchResultSection( modifier = modifier, contentPadding = PaddingValues(bottom = 8.dp), ) { + if (uiState.resultYTMusic.isNotEmpty()) { + itemsIndexed( + items = uiState.resultYTMusic, + key = { _, ytmusicSearch -> ytmusicSearch.hashCode() }, + ) { index, ytmusicSearch -> + Text( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(), + text = ytmusicSearch.title ?: "unknown", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + if (uiState.resultSongs.isNotEmpty()) { item { SearchHeaderItem( @@ -131,5 +152,25 @@ internal fun SearchResultSection( ) } } + + if ( + uiState.resultSongs.isEmpty() && + uiState.resultAlbums.isEmpty() && + uiState.resultArtists.isEmpty() && + uiState.resultPlaylists.isEmpty() && + uiState.isEnableYTMusic && + uiState.keywords.all { it.isBlank() } + ) { + item { + Text( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(), + text = stringResource(R.string.search_empty_text_ytmusic), + style = MaterialTheme.typography.bodyMedium.center(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } diff --git a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopNavigation.kt b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopNavigation.kt index 5b930905..a76fd996 100644 --- a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopNavigation.kt +++ b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopNavigation.kt @@ -14,6 +14,7 @@ fun NavController.navigateToSettingTop() { } fun NavGraphBuilder.settingTopScreen( + navigateToYTMusicLogin: () -> Unit, navigateToEqualizer: () -> Unit, navigateToSettingTheme: () -> Unit, navigateToOpenSourceLicense: () -> Unit, @@ -29,6 +30,7 @@ fun NavGraphBuilder.settingTopScreen( ) { SettingTopRoute( modifier = Modifier.fillMaxSize(), + navigateToYTMusicLogin = navigateToYTMusicLogin, navigateToEqualizer = navigateToEqualizer, navigateToSettingTheme = navigateToSettingTheme, navigateToOpenSourceLicense = navigateToOpenSourceLicense, diff --git a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopScreen.kt b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopScreen.kt index 76e4adcc..962da5ac 100644 --- a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopScreen.kt +++ b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopScreen.kt @@ -24,11 +24,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import caios.android.kanade.core.common.network.KanadeConfig +import caios.android.kanade.core.common.network.util.ToastUtil import caios.android.kanade.core.design.R import caios.android.kanade.core.model.UserData import caios.android.kanade.core.ui.AsyncLoadContents @@ -37,9 +39,11 @@ import caios.android.kanade.feature.setting.top.items.SettingTopLibrarySection import caios.android.kanade.feature.setting.top.items.SettingTopOthersSection import caios.android.kanade.feature.setting.top.items.SettingTopPlayingSection import caios.android.kanade.feature.setting.top.items.SettingTopThemeSection +import caios.android.kanade.feature.setting.top.items.SettingTopYTMusicSection @Composable internal fun SettingTopRoute( + navigateToYTMusicLogin: () -> Unit, navigateToEqualizer: () -> Unit, navigateToSettingTheme: () -> Unit, navigateToOpenSourceLicense: () -> Unit, @@ -48,6 +52,7 @@ internal fun SettingTopRoute( modifier: Modifier = Modifier, viewModel: SettingTopViewModel = hiltViewModel(), ) { + val context = LocalContext.current val screenState by viewModel.screenState.collectAsStateWithLifecycle() AsyncLoadContents( @@ -70,11 +75,27 @@ internal fun SettingTopRoute( onClickOpenSourceLicense = navigateToOpenSourceLicense, onClickDeveloperMode = { isEnable -> if (isEnable) { - navigateToSettingDeveloper() + navigateToSettingDeveloper.invoke() } else { viewModel.setDeveloperMode(false) } }, + onClickEnableYTMusic = { isEnable -> + if (isEnable) { + if (uiState.isYTMusicInitialized) { + if (uiState.userData.isDeveloperMode) { + viewModel.setEnableYTMusic(true) + } else { + ToastUtil.show(context, R.string.error_developing_feature) + } + } else { + navigateToYTMusicLogin.invoke() + } + } else { + viewModel.setEnableYTMusic(false) + } + }, + onClickRemoveYTMusicToken = viewModel::removeYTMusicToken, onTerminate = terminate, ) } @@ -86,6 +107,8 @@ private fun SettingTopScreen( userData: UserData, config: KanadeConfig, onClickTheme: () -> Unit, + onClickEnableYTMusic: (Boolean) -> Unit, + onClickRemoveYTMusicToken: () -> Unit, onClickEqualizer: () -> Unit, onClickDynamicNormalizer: (Boolean) -> Unit, onClickOneStepBack: (Boolean) -> Unit, @@ -140,6 +163,13 @@ private fun SettingTopScreen( onClickAppTheme = onClickTheme, ) + SettingTopYTMusicSection( + modifier = Modifier.fillMaxWidth(), + userData = userData, + onClickEnableYTMusic = onClickEnableYTMusic, + onClickRemoveYTMusicToken = onClickRemoveYTMusicToken, + ) + SettingTopPlayingSection( modifier = Modifier.fillMaxWidth(), userData = userData, diff --git a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopViewModel.kt b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopViewModel.kt index 20b2a907..a06f3098 100644 --- a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopViewModel.kt +++ b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/SettingTopViewModel.kt @@ -9,6 +9,7 @@ import caios.android.kanade.core.common.network.KanadeConfig import caios.android.kanade.core.common.network.KanadeDispatcher import caios.android.kanade.core.model.ScreenState import caios.android.kanade.core.model.UserData +import caios.android.kanade.core.music.YTMusic import caios.android.kanade.core.repository.MusicRepository import caios.android.kanade.core.repository.UserDataRepository import com.yausername.youtubedl_android.YoutubeDL @@ -27,6 +28,7 @@ class SettingTopViewModel @Inject constructor( private val musicRepository: MusicRepository, private val userDataRepository: UserDataRepository, private val kanadeConfig: KanadeConfig, + private val ytMusic: YTMusic, @Dispatcher(KanadeDispatcher.IO) private val dispatcher: CoroutineDispatcher, ) : ViewModel() { @@ -37,6 +39,7 @@ class SettingTopViewModel @Inject constructor( SettingTopUiState( userData = it, config = kanadeConfig, + isYTMusicInitialized = ytMusic.isInitialized(), ), ) }.stateIn( @@ -64,6 +67,12 @@ class SettingTopViewModel @Inject constructor( } } + fun setEnableYTMusic(isEnable: Boolean) { + viewModelScope.launch { + userDataRepository.setEnableYTMusic(isEnable) + } + } + fun setUseDynamicNormalizer(useDynamicNormalizer: Boolean) { viewModelScope.launch { userDataRepository.setUseDynamicNormalizer(useDynamicNormalizer) @@ -100,6 +109,13 @@ class SettingTopViewModel @Inject constructor( } } + fun removeYTMusicToken() { + viewModelScope.launch { + ytMusic.removeToken() + setEnableYTMusic(false) + } + } + suspend fun updateYoutubeDL(context: Context): String? = withContext(dispatcher) { with(YoutubeDL.getInstance()) { updateYoutubeDL(context) @@ -112,4 +128,5 @@ class SettingTopViewModel @Inject constructor( data class SettingTopUiState( val userData: UserData, val config: KanadeConfig, + val isYTMusicInitialized: Boolean, ) diff --git a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/items/SettingTopOthersSection.kt b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/items/SettingTopOthersSection.kt index ea99a553..b46bf7ed 100644 --- a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/items/SettingTopOthersSection.kt +++ b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/items/SettingTopOthersSection.kt @@ -19,7 +19,6 @@ import caios.android.kanade.feature.setting.SettingSwitchItem import caios.android.kanade.feature.setting.SettingTextItem import com.yausername.youtubedl_android.YoutubeDL import kotlinx.coroutines.launch -import java.util.UUID @Composable internal fun SettingTopOthersSection( @@ -43,7 +42,7 @@ internal fun SettingTopOthersSection( SettingTextItem( modifier = Modifier.fillMaxWidth(), title = stringResource(R.string.setting_top_others_id), - description = UUID.randomUUID().toString(), + description = userData.kanadeId, onClick = { /* do nothing */ }, ) diff --git a/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/items/SettingTopYTMusicSection.kt b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/items/SettingTopYTMusicSection.kt new file mode 100644 index 00000000..5eb54060 --- /dev/null +++ b/feature/setting/src/main/java/caios/android/kanade/feature/setting/top/items/SettingTopYTMusicSection.kt @@ -0,0 +1,47 @@ +package caios.android.kanade.feature.setting.top.items + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import caios.android.kanade.core.common.network.util.ToastUtil +import caios.android.kanade.core.design.R +import caios.android.kanade.core.model.UserData +import caios.android.kanade.feature.setting.SettingSwitchItem +import caios.android.kanade.feature.setting.SettingTextItem + +@Composable +internal fun SettingTopYTMusicSection( + userData: UserData, + onClickEnableYTMusic: (Boolean) -> Unit, + onClickRemoveYTMusicToken: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Column(modifier) { + SettingTopTitleItem( + modifier = Modifier.fillMaxWidth(), + text = R.string.setting_top_ytmusic, + ) + + SettingSwitchItem( + modifier = Modifier.fillMaxWidth(), + title = R.string.setting_top_library_ytmusic_title, + description = R.string.setting_top_library_ytmusic_description, + value = userData.isEnableYTMusic, + onValueChanged = onClickEnableYTMusic, + ) + + SettingTextItem( + modifier = Modifier.fillMaxWidth(), + title = R.string.setting_top_library_ytmusic_remove_token_title, + description = R.string.setting_top_library_ytmusic_remove_token_description, + onClick = { + ToastUtil.show(context, R.string.setting_ytmusic_login_toast_remove_token) + onClickRemoveYTMusicToken.invoke() + }, + ) + } +} diff --git a/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginDialog.kt b/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginDialog.kt new file mode 100644 index 00000000..f2083771 --- /dev/null +++ b/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginDialog.kt @@ -0,0 +1,161 @@ +package caios.android.kanade.feature.setting.ytmusic + +import android.content.Intent +import androidx.compose.foundation.background +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.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import caios.android.kanade.core.common.network.util.ToastUtil +import caios.android.kanade.core.design.R +import caios.android.kanade.core.design.theme.center +import caios.android.kanade.core.model.entity.YTMusicOAuthCode +import caios.android.kanade.core.ui.AsyncLoadContentsWithoutAnimation +import kotlinx.coroutines.launch + +@Composable +internal fun YTMusicLoginRoute( + terminate: () -> Unit, + modifier: Modifier = Modifier, + viewModel: YTMusicLoginViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + + AsyncLoadContentsWithoutAnimation( + modifier = modifier, + screenState = screenState, + retryAction = { terminate.invoke() }, + ) { uiState -> + YTMusicLoginDialog( + modifier = Modifier.fillMaxWidth(), + isReadyToAuth = uiState.isReadyToAuth, + oauthCode = uiState.oauthCode, + onClickOpenBrowser = { + context.startActivity(Intent(Intent.ACTION_VIEW, it.toUri())) + viewModel.readyToAuth() + }, + onClickDone = { + scope.launch { + if (viewModel.getOAuthToken(uiState.oauthCode)) { + ToastUtil.show(context, R.string.setting_ytmusic_login_toast_success2) + terminate.invoke() + } else { + ToastUtil.show(context, R.string.setting_ytmusic_login_toast_failure) + } + } + }, + onTerminate = terminate, + ) + } +} + +@Composable +private fun YTMusicLoginDialog( + isReadyToAuth: Boolean, + oauthCode: YTMusicOAuthCode, + onClickOpenBrowser: (String) -> Unit, + onClickDone: () -> Unit, + onTerminate: () -> Unit, + modifier: Modifier = Modifier, +) { + val title: String + val description: String + val buttonText: String + + if (!isReadyToAuth) { + title = stringResource(R.string.setting_ytmusic_login_title1) + description = stringResource(R.string.setting_ytmusic_login_description1) + buttonText = stringResource(R.string.setting_ytmusic_login_button1) + } else { + title = stringResource(R.string.setting_ytmusic_login_title2) + description = stringResource(R.string.setting_ytmusic_login_description2) + buttonText = stringResource(R.string.setting_ytmusic_login_button2) + } + + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.surface) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + modifier = Modifier.fillMaxWidth(), + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + text = oauthCode.userCode, + style = MaterialTheme.typography.displaySmall.center(), + color = MaterialTheme.colorScheme.onSurface, + ) + + Row( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(4.dp), + onClick = { onTerminate.invoke() }, + ) { + Text( + text = stringResource(R.string.common_cancel), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + Button( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(4.dp), + onClick = { + if (!isReadyToAuth) { + onClickOpenBrowser.invoke("${oauthCode.verificationUrl}?user_code=${oauthCode.userCode}") + } else { + onClickDone.invoke() + } + }, + ) { + Text( + text = buttonText, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } +} diff --git a/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginNavigation.kt b/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginNavigation.kt new file mode 100644 index 00000000..6da2db27 --- /dev/null +++ b/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginNavigation.kt @@ -0,0 +1,33 @@ +package caios.android.kanade.feature.setting.ytmusic + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.dialog + +const val YTMusicLoginRoute = "YTMusicLogin" + +fun NavController.navigateToYTMusicLogin() { + this.navigate(YTMusicLoginRoute) +} + +fun NavGraphBuilder.ytmusicLoginDialog( + terminate: () -> Unit, +) { + dialog( + route = YTMusicLoginRoute, + ) { + YTMusicLoginRoute( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(16.dp)), + terminate = terminate, + ) + } +} diff --git a/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginViewModel.kt b/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginViewModel.kt new file mode 100644 index 00000000..521efaa1 --- /dev/null +++ b/feature/setting/src/main/java/caios/android/kanade/feature/setting/ytmusic/YTMusicLoginViewModel.kt @@ -0,0 +1,67 @@ +package caios.android.kanade.feature.setting.ytmusic + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import caios.android.kanade.core.design.R +import caios.android.kanade.core.model.ScreenState +import caios.android.kanade.core.model.entity.YTMusicOAuthCode +import caios.android.kanade.core.music.YTMusic +import caios.android.kanade.core.repository.UserDataRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class YTMusicLoginViewModel @Inject constructor( + private val userDataRepository: UserDataRepository, + private val ytMusic: YTMusic, +) : ViewModel() { + + private val _screenState = MutableStateFlow>(ScreenState.Loading) + + val screenState = _screenState.asStateFlow() + + init { + viewModelScope.launch { + _screenState.value = ytMusic.getOAuthCode().fold( + onSuccess = { + ScreenState.Idle( + YTMusicLoginUiState( + isReadyToAuth = false, + oauthCode = it, + ), + ) + }, + onFailure = { + ScreenState.Error( + message = R.string.error_no_data, + retryTitle = R.string.common_close, + ) + }, + ) + } + } + + fun readyToAuth() { + (screenState.value as? ScreenState.Idle)?.also { + _screenState.value = ScreenState.Idle(it.data.copy(isReadyToAuth = true)) + } + } + + suspend fun getOAuthToken(oauthCode: YTMusicOAuthCode): Boolean { + return ytMusic.getOAuthToken(oauthCode).onSuccess { + userDataRepository.setEnableYTMusic(true) + if (!userDataRepository.userData.first().isDeveloperMode) userDataRepository.setEnableYTMusic(false) + }.isSuccess + } +} + +@Stable +data class YTMusicLoginUiState( + val oauthCode: YTMusicOAuthCode, + val isReadyToAuth: Boolean, +) diff --git a/gradle.properties b/gradle.properties index ad1a9cd8..23944453 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,9 +21,6 @@ org.gradle.configureondemand=false # Enable caching between builds. org.gradle.caching=true -# Enable configuration caching between builds. -org.gradle.unsafe.configuration-cache=true - # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8986d737..7f8aabc4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] # Application -versionName = "2.0.1" -versionCode = "133" +versionName = "2.0.2" +versionCode = "134" # SDK minSdk = "26" @@ -69,6 +69,7 @@ jsoup = "1.16.1" detekt = "1.23.1" balloon = "1.5.3" orbital = "0.2.4" +chaquopy = "15.0.0" libraries = "10.9.1" ffmpeg = "5.1" youtubedl= "23b26d55f8" @@ -91,6 +92,7 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "libraries" } +chaquopy = { id = "com.chaquo.python", version.ref = "chaquopy" } module-graph = { id = "dev.iurysouza.modulegraph", version = "0.4.0" } [libraries] diff --git a/settings.gradle.kts b/settings.gradle.kts index d710681c..9f82c0f6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ pluginManagement { mavenCentral() gradlePluginPortal() maven(url = "https://www.jitpack.io") + maven(url = "https://chaquo.com/maven-test") } }