diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20e89529..a3538b5a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/EchoApplication.kt b/app/src/main/java/dev/brahmkshatriya/echo/EchoApplication.kt index 0159df94..335407e7 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/EchoApplication.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/EchoApplication.kt @@ -10,6 +10,7 @@ import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColorsOptions import com.google.android.material.color.ThemeUtils import dagger.hilt.android.HiltAndroidApp +import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.cleanupTempApks import dev.brahmkshatriya.echo.ui.exception.ExceptionFragment.Companion.getDetails import dev.brahmkshatriya.echo.ui.exception.ExceptionFragment.Companion.getTitle import dev.brahmkshatriya.echo.ui.settings.LookFragment.Companion.AMOLED_KEY @@ -39,6 +40,7 @@ class EchoApplication : Application() { //UI applyLocale(settings) applyUiChanges(this, settings) + cleanupTempApks() //Crash Handling Thread.setDefaultUncaughtExceptionHandler { _, exception -> diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt b/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt new file mode 100644 index 00000000..35b3c431 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt @@ -0,0 +1,75 @@ +package dev.brahmkshatriya.echo + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.activity.viewModels +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import dev.brahmkshatriya.echo.ui.extension.ExtensionInstallerBottomSheet +import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel +import kotlinx.coroutines.launch +import java.io.File + +class ExtensionOpenerActivity : Activity() { + override fun onStart() { + super.onStart() + val uri = intent.data + + val file = when (uri?.scheme) { + "content" -> getTempFile(uri) + else -> null + } + + if (file == null) Toast.makeText( + this, getString(R.string.could_not_find_the_file), Toast.LENGTH_SHORT + ).show() + + finish() + val startIntent = Intent(this, MainActivity::class.java) + startIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startIntent.data = file?.let { Uri.fromFile(it) } + startActivity(startIntent) + } + + private fun getTempFile(uri: Uri): File? { + val stream = contentResolver.openInputStream(uri) ?: return null + val bytes = stream.readBytes() + val tempFile = File.createTempFile("temp", ".apk", getTempApkDir()) + tempFile.writeBytes(bytes) + return tempFile + } + + companion object { + const val EXTENSION_INSTALLER = "extensionInstaller" + + fun Context.getTempApkDir() = File(cacheDir, "apks").apply { mkdirs() } + + fun Context.cleanupTempApks() { + getTempApkDir().deleteRecursively() + } + + fun FragmentActivity.openExtensionInstaller(uri: Uri) { + + ExtensionInstallerBottomSheet.newInstance(uri.toString()) + .show(supportFragmentManager, null) + + supportFragmentManager.setFragmentResultListener(EXTENSION_INSTALLER, this) { _, b -> + val file = b.getString("file")?.toUri()?.toFile() + val install = b.getBoolean("install") + val installAsApk = b.getBoolean("installAsApk") + val context = this + if (install && file != null) { + val extensionViewModel by viewModels() + lifecycleScope.launch { + extensionViewModel.install(context, file, installAsApk) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt b/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt index 5b206804..709090e4 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt @@ -18,16 +18,9 @@ import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigationrail.NavigationRailView import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.AndroidEntryPoint -import dev.brahmkshatriya.echo.common.models.Album -import dev.brahmkshatriya.echo.common.models.Artist -import dev.brahmkshatriya.echo.common.models.EchoMediaItem -import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem -import dev.brahmkshatriya.echo.common.models.Playlist -import dev.brahmkshatriya.echo.common.models.Track -import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.openExtensionInstaller import dev.brahmkshatriya.echo.databinding.ActivityMainBinding -import dev.brahmkshatriya.echo.ui.common.openFragment -import dev.brahmkshatriya.echo.ui.item.ItemFragment +import dev.brahmkshatriya.echo.ui.common.openItemFragmentFromUri import dev.brahmkshatriya.echo.ui.settings.LookFragment.Companion.NAVBAR_GRADIENT import dev.brahmkshatriya.echo.utils.animateTranslation import dev.brahmkshatriya.echo.utils.checkAudioPermissions @@ -38,7 +31,6 @@ import dev.brahmkshatriya.echo.utils.listenFuture import dev.brahmkshatriya.echo.utils.observe import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel.Companion.connectPlayerToUI -import dev.brahmkshatriya.echo.viewmodels.SnackBar import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.configureSnackBar import dev.brahmkshatriya.echo.viewmodels.UiViewModel import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.isNightMode @@ -83,9 +75,9 @@ class MainActivity : AppCompatActivity() { ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> uiViewModel.setSystemInsets(this, insets) - val navBarSize = uiViewModel.systemInsets.value.bottom + val navBarSize = uiViewModel.systemInsets.value.bottom val full = playerViewModel.settings.getBoolean(NAVBAR_GRADIENT, true) - navView.createNavDrawable(isRail,navBarSize, !full) + navView.createNavDrawable(isRail, navBarSize, !full) insets } @@ -152,47 +144,10 @@ class MainActivity : AppCompatActivity() { return } val uri = data - if (uri != null) { - fun createSnack(id: Int) { - val snackbar by viewModels() - val message = getString(id) - snackbar.create(SnackBar.Message(message)) - } - - val extensionType = uri.host - when (extensionType) { - "music" -> { - val extensionId = uri.pathSegments.firstOrNull() - if (extensionId == null) { - createSnack(R.string.error_no_client) - return - } - val type = uri.pathSegments.getOrNull(1) - val id = uri.pathSegments.getOrNull(2) - if (id == null) { - createSnack(R.string.error_no_id) - return - } - val name = uri.getQueryParameter("name").orEmpty() - val item: EchoMediaItem? = when (type) { - "user" -> User(id, name).toMediaItem() - "artist" -> Artist(id, name).toMediaItem() - "track" -> Track(id, name).toMediaItem() - "album" -> Album(id, name).toMediaItem() - "playlist" -> Playlist(id, name, false).toMediaItem() - else -> null - } - if (item == null) { - createSnack(R.string.error_invalid_type) - return - } - openFragment(ItemFragment.newInstance(extensionId, item)) - } - - else -> { - createSnack(R.string.invalid_extension_host) - } - } + println("URI: $uri") + when (uri?.scheme) { + "echo" -> openItemFragmentFromUri(uri) + "file" -> openExtensionInstaller(uri) } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt b/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt index 7279b3c4..138878ec 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt @@ -137,8 +137,6 @@ class PlayerService : MediaLibraryService() { } } - //TODO: Radio Item - //TODO: Open .eapk files //TODO: extension updater //TODO: Spotify //TODO: EQ, Pitch, Tempo, Reverb & Sleep Timer(5m, 10m, 15m, 30m, 45m, 1hr, End of track) @@ -158,7 +156,7 @@ class PlayerService : MediaLibraryService() { } override fun onTaskRemoved(rootIntent: Intent?) { - val stopPlayer = settings.getBoolean(CLOSE_PLAYER, false) + val stopPlayer = settings.getBoolean(CLOSE_PLAYER, true) val player = mediaSession?.player ?: return stopSelf() if (stopPlayer || !player.isPlaying) stopSelf() } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt index e7881735..651a687d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt @@ -2,6 +2,9 @@ package dev.brahmkshatriya.echo.di import android.app.Application import android.content.SharedPreferences +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.SimpleCache import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -21,9 +24,11 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) class ExtensionModule { + @OptIn(UnstableApi::class) @Provides @Singleton - fun provideOfflineExtension(context: Application) = OfflineExtension(context) + fun provideOfflineExtension(context: Application, cache: SimpleCache) = + OfflineExtension(context, cache) @Provides @Singleton diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt index 7734cb11..e2669a93 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt @@ -6,9 +6,9 @@ import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.LyricsExtension import dev.brahmkshatriya.echo.common.MusicExtension import dev.brahmkshatriya.echo.common.TrackerExtension +import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.common.clients.LoginClient import dev.brahmkshatriya.echo.common.helpers.ExtensionType -import dev.brahmkshatriya.echo.common.helpers.ImportType import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.common.providers.LyricsClientsProvider import dev.brahmkshatriya.echo.common.providers.MusicClientsProvider @@ -17,16 +17,9 @@ import dev.brahmkshatriya.echo.db.ExtensionDao import dev.brahmkshatriya.echo.db.UserDao import dev.brahmkshatriya.echo.db.models.UserEntity import dev.brahmkshatriya.echo.db.models.UserEntity.Companion.toUser -import dev.brahmkshatriya.echo.extensions.plugger.AndroidPluginLoader -import dev.brahmkshatriya.echo.extensions.plugger.ApkFileManifestParser -import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser -import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource -import dev.brahmkshatriya.echo.extensions.plugger.FilePluginSource -import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo -import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepoImpl -import dev.brahmkshatriya.echo.extensions.plugger.LazyRepoComposer +import dev.brahmkshatriya.echo.extensions.plugger.FileChangeListener import dev.brahmkshatriya.echo.extensions.plugger.PackageChangeListener -import dev.brahmkshatriya.echo.offline.LocalExtensionRepo +import dev.brahmkshatriya.echo.offline.BuiltInExtensionRepo import dev.brahmkshatriya.echo.offline.OfflineExtension import dev.brahmkshatriya.echo.utils.catchWith import kotlinx.coroutines.CoroutineName @@ -39,6 +32,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -46,7 +40,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import java.io.File class ExtensionLoader( context: Context, @@ -63,46 +56,25 @@ class ExtensionLoader( private val extensionFlow: MutableStateFlow, ) { private val scope = MainScope() + CoroutineName("ExtensionLoader") - - private fun Context.getPluginFileDir() = File(filesDir, "extensions").apply { mkdirs() } private val listener = PackageChangeListener(context) - private fun getComposed( - context: Context, - suffix: String, - vararg repo: LazyPluginRepo - ): LazyPluginRepo { - val loader = AndroidPluginLoader(context) - val apkFilePluginRepo = LazyPluginRepoImpl( - FilePluginSource(context.getPluginFileDir(), ".eapk"), - ApkFileManifestParser(context.packageManager, ApkManifestParser(ImportType.Apk)), - loader, - ) - val appPluginRepo = LazyPluginRepoImpl( - ApkPluginSource(listener, context, "dev.brahmkshatriya.echo.$suffix"), - ApkManifestParser(ImportType.App), - loader - ) - return LazyRepoComposer(appPluginRepo, apkFilePluginRepo, *repo) - } - - private val musicExtensionRepo = MusicExtensionRepo( - context, - getComposed(context, "music", LocalExtensionRepo(offlineExtension)) - ) + val fileListener = FileChangeListener(scope) + private val builtIn = BuiltInExtensionRepo(offlineExtension) - private val trackerExtensionRepo = TrackerExtensionRepo( - context, getComposed(context, "tracker") - ) - - private val lyricsExtensionRepo = LyricsExtensionRepo( - context, getComposed(context, "lyrics") - ) + private val musicExtensionRepo = MusicExtensionRepo(context, listener, fileListener, builtIn) + private val trackerExtensionRepo = TrackerExtensionRepo(context, listener, fileListener) + private val lyricsExtensionRepo = LyricsExtensionRepo(context, listener, fileListener) val trackers = trackerListFlow val extensions = extensionListFlow val current = extensionFlow val currentWithUser = MutableStateFlow>(null to null) + val priorityMap = ExtensionType.entries.associateWith { + val key = it.priorityKey() + val list = settings.getString(key, null).orEmpty().split(',') + MutableStateFlow(list) + } + fun initialize() { scope.launch { getAllPlugins(scope) @@ -192,7 +164,7 @@ class ExtensionLoader( val lyrics = MutableStateFlow(null) val music = MutableStateFlow(null) scope.launch { - trackerExtensionRepo.getPlugins(ExtensionType.TRACKER) { list -> + trackerExtensionRepo.getPlugins { list -> val trackerExtensions = list.map { (metadata, client) -> TrackerExtension(metadata, client) } @@ -202,7 +174,7 @@ class ExtensionLoader( } } scope.launch { - lyricsExtensionRepo.getPlugins(ExtensionType.LYRICS) { list -> + lyricsExtensionRepo.getPlugins { list -> val lyricsExtensions = list.map { (metadata, client) -> LyricsExtension(metadata, client) } @@ -215,7 +187,7 @@ class ExtensionLoader( trackers.first { it != null } scope.launch { - musicExtensionRepo.getPlugins(ExtensionType.MUSIC) { list -> + musicExtensionRepo.getPlugins { list -> val extensions = list.map { (metadata, client) -> MusicExtension(metadata, client) } @@ -232,19 +204,25 @@ class ExtensionLoader( music.first { it != null } } - private suspend fun LazyPluginRepo.getPlugins( - type: ExtensionType, collector: FlowCollector>>>> - ) = getAllPlugins().catchWith(throwableFlow).map { list -> - list.mapNotNull { result -> - val (metadata, client) = result.getOrElse { - val error = it.cause ?: it - throwableFlow.emit(ExtensionException(type, error)) - null - } ?: return@mapNotNull null - val metadataEnabled = isExtensionEnabled(type, metadata) - Pair(metadataEnabled, client) + private suspend fun ExtensionRepo.getPlugins( + collector: FlowCollector>>>> + ) { + val pluginFlow = getAllPlugins().catchWith(throwableFlow).map { list -> + list.mapNotNull { result -> + val (metadata, client) = result.getOrElse { + val error = it.cause ?: it + throwableFlow.emit(ExtensionLoadingException(type, error)) + null + } ?: return@mapNotNull null + val metadataEnabled = isExtensionEnabled(type, metadata) + Pair(metadataEnabled, client) + } } - }.collect(collector) + val priorityFlow = priorityMap[type]!! + pluginFlow.combine(priorityFlow) { list, set -> + list.sortedBy { set.indexOf(it.first.id) } + }.collect(collector) + } private suspend fun List>.setExtensions() = coroutineScope { map { @@ -264,6 +242,8 @@ class ExtensionLoader( const val LAST_EXTENSION_KEY = "last_extension" private const val TIMEOUT = 5000L + fun ExtensionType.priorityKey() = "priority_$this" + fun setupMusicExtension( scope: CoroutineScope, settings: SharedPreferences, diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionException.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoadingException.kt similarity index 87% rename from app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionException.kt rename to app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoadingException.kt index 09f6e59d..eec87aec 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionException.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoadingException.kt @@ -2,7 +2,7 @@ package dev.brahmkshatriya.echo.extensions import dev.brahmkshatriya.echo.common.helpers.ExtensionType -class ExtensionException( +class ExtensionLoadingException( val type: ExtensionType, override val cause: Throwable ) : Exception("Failed to load extension of type: $type") \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt new file mode 100644 index 00000000..0726bf09 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt @@ -0,0 +1,99 @@ +package dev.brahmkshatriya.echo.extensions + +import android.content.Context +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.LyricsClient +import dev.brahmkshatriya.echo.common.clients.TrackerClient +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.extensions.plugger.AndroidPluginLoader +import dev.brahmkshatriya.echo.extensions.plugger.ApkFileManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource +import dev.brahmkshatriya.echo.extensions.plugger.FileChangeListener +import dev.brahmkshatriya.echo.extensions.plugger.FilePluginSource +import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo +import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepoImpl +import dev.brahmkshatriya.echo.extensions.plugger.LazyRepoComposer +import dev.brahmkshatriya.echo.extensions.plugger.PackageChangeListener +import dev.brahmkshatriya.echo.extensions.plugger.catchLazy +import dev.brahmkshatriya.echo.utils.getSettings +import tel.jeelpa.plugger.utils.mapState +import java.io.File + +sealed class ExtensionRepo( + private val context: Context, + private val listener: PackageChangeListener, + private val fileChangeListener: FileChangeListener, + private vararg val repo: LazyPluginRepo +) : LazyPluginRepo { + abstract val type: ExtensionType + + private val composed by lazy { + val loader = AndroidPluginLoader(context) + val dir = context.getPluginFileDir(type) + val apkFilePluginRepo = LazyPluginRepoImpl( + FilePluginSource(dir, fileChangeListener.scope, fileChangeListener.getFlow(type)), + ApkFileManifestParser(context.packageManager, ApkManifestParser(ImportType.File)), + loader, + ) + val appPluginRepo = LazyPluginRepoImpl( + ApkPluginSource(listener, context, "$FEATURE${type.feature}"), + ApkManifestParser(ImportType.App), + loader + ) + LazyRepoComposer(appPluginRepo, apkFilePluginRepo, *repo) + } + + private fun injected() = composed.getAllPlugins().mapState { list -> + list.map { + runCatching { + val plugin = it.getOrThrow() + val (metadata, resultLazy) = plugin + metadata to catchLazy { + val instance = resultLazy.value.getOrThrow() + //Injection + instance.setSettings(getSettings(context, type, metadata)) + + instance + } + } + } + } + + override fun getAllPlugins() = injected() + + companion object { + const val FEATURE = "dev.brahmkshatriya.echo." + fun Context.getPluginFileDir(type: ExtensionType) = + File(filesDir, type.feature).apply { mkdirs() } + } +} + +class MusicExtensionRepo( + context: Context, + listener: PackageChangeListener, + fileChangeListener: FileChangeListener, + vararg repo: LazyPluginRepo +) : ExtensionRepo(context, listener, fileChangeListener, *repo) { + override val type = ExtensionType.MUSIC +} + +class TrackerExtensionRepo( + context: Context, + listener: PackageChangeListener, + fileChangeListener: FileChangeListener, + vararg repo: LazyPluginRepo +) : ExtensionRepo(context, listener, fileChangeListener, *repo) { + override val type = ExtensionType.TRACKER +} + +class LyricsExtensionRepo( + context: Context, + listener: PackageChangeListener, + fileChangeListener: FileChangeListener, + vararg repo: LazyPluginRepo +) : ExtensionRepo(context, listener, fileChangeListener, *repo) { + override val type = ExtensionType.LYRICS +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt index 53b34ec8..f7fc8bd3 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt @@ -1,13 +1,7 @@ package dev.brahmkshatriya.echo.extensions -import android.content.Context import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.clients.ExtensionClient -import dev.brahmkshatriya.echo.common.clients.LyricsClient -import dev.brahmkshatriya.echo.common.clients.TrackerClient -import dev.brahmkshatriya.echo.common.helpers.ExtensionType -import dev.brahmkshatriya.echo.common.models.Metadata -import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo import dev.brahmkshatriya.echo.ui.exception.AppException.Companion.toAppException import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow @@ -38,29 +32,4 @@ suspend inline fun Extension<*>.get( inline fun Extension<*>.isClient() = instance.value.getOrNull() is T fun StateFlow>?>.getExtension(id: String?) = - value?.find { it.metadata.id == id } - -class MusicExtensionRepo( - private val context: Context, - private val pluginRepo: LazyPluginRepo -) : LazyPluginRepo { - - override fun getAllPlugins() = pluginRepo.getAllPlugins() - .injectSettings(ExtensionType.MUSIC, context) -} - -class TrackerExtensionRepo( - private val context: Context, - private val pluginRepo: LazyPluginRepo -) : LazyPluginRepo { - override fun getAllPlugins() = pluginRepo.getAllPlugins() - .injectSettings(ExtensionType.TRACKER, context) -} - -class LyricsExtensionRepo( - private val context: Context, - private val pluginRepo: LazyPluginRepo -) : LazyPluginRepo { - override fun getAllPlugins() = pluginRepo.getAllPlugins() - .injectSettings(ExtensionType.LYRICS, context) -} \ No newline at end of file + value?.find { it.metadata.id == id } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt new file mode 100644 index 00000000..34f4085f --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt @@ -0,0 +1,104 @@ +package dev.brahmkshatriya.echo.extensions + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.content.pm.PackageInfo +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType +import dev.brahmkshatriya.echo.extensions.ExtensionRepo.Companion.FEATURE +import dev.brahmkshatriya.echo.extensions.ExtensionRepo.Companion.getPluginFileDir +import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource +import dev.brahmkshatriya.echo.utils.registerActivityResultLauncher +import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +suspend fun installExtension(context: FragmentActivity, file: File, apk: Boolean) = runCatching { + if (apk) { + val contentUri = FileProvider.getUriForFile( + context, context.packageName + ".provider", file + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + putExtra(Intent.EXTRA_RETURN_RESULT, true) + data = contentUri + } + context.waitForResult(installIntent) + } else { + val viewModel by context.viewModels() + val extensionLoader = viewModel.extensionLoader + val fileChangeListener = extensionLoader.fileListener + val packageInfo = context.packageManager.getPackageArchiveInfo( + file.path, ApkPluginSource.PACKAGE_FLAGS + ) + val type = getType(packageInfo!!) + val metadata = ApkManifestParser(ImportType.File) + .parseManifest(packageInfo.applicationInfo!!) + val flow = fileChangeListener.getFlow(type) + val dir = context.getPluginFileDir(type) + val newFile = File(dir, "${metadata.id}.apk") + flow.emit(newFile) + dir.setWritable(true) + newFile.setWritable(true) + if (newFile.exists()) newFile.delete() + file.copyTo(newFile, true) + dir.setReadOnly() + flow.emit(null) + true + } +} + +suspend fun uninstallExtension(context: FragmentActivity, extension: Extension<*>) = runCatching { + when (extension.metadata.importType) { + ImportType.BuiltIn -> throw UnsupportedOperationException() + ImportType.File -> { + val file = File(extension.metadata.path) + val viewModel by context.viewModels() + val extensionLoader = viewModel.extensionLoader + val flow = extensionLoader.fileListener.getFlow(extension.type) + flow.emit(file) + file.parentFile!!.setWritable(true) + file.setWritable(true) + val delete = file.delete() + flow.emit(null) + delete + } + + ImportType.App -> { + val packageInfo = context.packageManager.getPackageArchiveInfo( + extension.metadata.path, ApkPluginSource.PACKAGE_FLAGS + )!! + val packageName = packageInfo.packageName + val intent = Intent(Intent.ACTION_DELETE).apply { + data = "package:$packageName".toUri() + putExtra(Intent.EXTRA_RETURN_RESULT, true) + } + context.waitForResult(intent) + } + } +} + +private suspend fun FragmentActivity.waitForResult(intent: Intent) = suspendCoroutine { cont -> + val contract = ActivityResultContracts.StartActivityForResult() + val activityResultLauncher = registerActivityResultLauncher(contract) { + cont.resume(it.resultCode == RESULT_OK) + } + activityResultLauncher.launch(intent) +} + + +fun getType(appInfo: PackageInfo) = appInfo.reqFeatures?.find { featureInfo -> + ExtensionType.entries.any { featureInfo.name == "$FEATURE${it.feature}" } +}?.let { featureInfo -> + ExtensionType.entries.first { it.feature == featureInfo.name.removePrefix(FEATURE) } +} ?: error("Extension type not found for ${appInfo.packageName}") \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/AndroidPluginLoader.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/AndroidPluginLoader.kt index 89ccb1a0..27437dbc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/AndroidPluginLoader.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/AndroidPluginLoader.kt @@ -10,7 +10,6 @@ class AndroidPluginLoader( ) : PluginLoader { constructor(context: Context): this(GetClassLoaderWithPathUseCase(context.classLoader)) - // for loading classes.dex from dex, jar or apk @Suppress("UNCHECKED_CAST") override fun loadPlugin(pluginMetadata: Metadata): TPlugin { return getClassLoader.getWithPath(pluginMetadata.path) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/ApkManifestParser.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/ApkManifestParser.kt index 125342b5..47a4ebe1 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/ApkManifestParser.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/ApkManifestParser.kt @@ -16,7 +16,7 @@ class ApkManifestParser( path = data.sourceDir, className = get("class"), importType = importType, - id = get("id"), + id = get("id") + importType.name, name = get("name"), version = get("version"), description = get("description"), diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FileChangeListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FileChangeListener.kt new file mode 100644 index 00000000..583ed1a2 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FileChangeListener.kt @@ -0,0 +1,13 @@ +package dev.brahmkshatriya.echo.extensions.plugger + +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import java.io.File + +class FileChangeListener( + val scope: CoroutineScope, +) { + val map = mutableMapOf>() + fun getFlow(type: ExtensionType) = map.getOrPut(type) { MutableSharedFlow() } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FilePluginSource.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FilePluginSource.kt index 2f2aaab4..b63efeb2 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FilePluginSource.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FilePluginSource.kt @@ -1,27 +1,32 @@ package dev.brahmkshatriya.echo.extensions.plugger -import android.os.Build -import android.os.FileObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import tel.jeelpa.plugger.PluginSource import java.io.File class FilePluginSource( private val folder: File, - private val extension: String, + scope: CoroutineScope, + fileIgnoreFlow: MutableSharedFlow ) : PluginSource { - private fun loadAllPlugins() = folder.listFiles()!!.filter { it.path.endsWith(extension) } + + private var ignoreFile : File? = null + private fun loadAllPlugins() = run { + folder.setReadOnly() + folder.listFiles()!!.filter { it != ignoreFile }.onEach { it.setWritable(false) } + } private val pluginStateFlow = MutableStateFlow(loadAllPlugins()) init { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val fsEventsListener = object : FileObserver(folder) { - override fun onEvent(event: Int, path: String?) { - pluginStateFlow.value = loadAllPlugins() - } + scope.launch { + fileIgnoreFlow.collect { + ignoreFile = it + pluginStateFlow.value = loadAllPlugins() } - fsEventsListener.startWatching() } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt index 5298df19..87a016a7 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt @@ -12,4 +12,4 @@ class LazyRepoComposer( .reduce { a, b -> combineStates(a, b) { x, y -> x + y } } } -fun lazily(value: T) = lazy { runCatching { value } } \ No newline at end of file +fun catchLazy(function: () -> T) = lazy { runCatching { function() } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt index 9d6c7850..34cbf24f 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt @@ -18,7 +18,7 @@ data class LazyPluginRepoImpl( }.mapState { metadata -> metadata.map { resultMetadata -> resultMetadata.mapCatching { - Pair(it, lazy { runCatching { pluginLoader.loadPlugin(it) } }) + it to catchLazy { pluginLoader.loadPlugin(it) } } } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/offline/LocalExtensionRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt similarity index 81% rename from app/src/main/java/dev/brahmkshatriya/echo/offline/LocalExtensionRepo.kt rename to app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt index 1a16d4c9..3098880f 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/offline/LocalExtensionRepo.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt @@ -3,10 +3,10 @@ package dev.brahmkshatriya.echo.offline import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo -import dev.brahmkshatriya.echo.extensions.plugger.lazily +import dev.brahmkshatriya.echo.extensions.plugger.catchLazy import kotlinx.coroutines.flow.MutableStateFlow -class LocalExtensionRepo( +class BuiltInExtensionRepo( private val extension: OfflineExtension ) : LazyPluginRepo { @@ -18,5 +18,5 @@ class LocalExtensionRepo( ) private fun getLazy(metadata: Metadata, extension: ExtensionClient) = - Result.success(Pair(metadata, lazily(extension))) + Result.success(Pair(metadata, catchLazy { extension })) } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt b/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt index 6b0e19cd..5afa2025 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt @@ -1,6 +1,9 @@ package dev.brahmkshatriya.echo.offline import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.SimpleCache import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.clients.AlbumClient import dev.brahmkshatriya.echo.common.clients.ArtistClient @@ -37,7 +40,7 @@ import dev.brahmkshatriya.echo.common.settings.SettingMultipleChoice import dev.brahmkshatriya.echo.common.settings.SettingSlider import dev.brahmkshatriya.echo.common.settings.SettingSwitch import dev.brahmkshatriya.echo.common.settings.Settings -import dev.brahmkshatriya.echo.extensions.getSettings +import dev.brahmkshatriya.echo.utils.getSettings import dev.brahmkshatriya.echo.offline.MediaStoreUtils.addSongToPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.createPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.deletePlaylist @@ -45,14 +48,19 @@ import dev.brahmkshatriya.echo.offline.MediaStoreUtils.editPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.moveSongInPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.removeSongFromPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.searchBy +import dev.brahmkshatriya.echo.playback.MediaItemUtils.toIdAndIsVideo import dev.brahmkshatriya.echo.utils.getFromCache import dev.brahmkshatriya.echo.utils.saveToCache import dev.brahmkshatriya.echo.utils.toData import dev.brahmkshatriya.echo.utils.toJson -class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, TrackClient, - AlbumClient, ArtistClient, PlaylistClient, RadioClient, SearchClient, LibraryClient, - TrackLikeClient, PlaylistEditorListenerClient, SettingsChangeListenerClient { +@OptIn(UnstableApi::class) +class OfflineExtension( + val context: Context, + val cache: SimpleCache +) : ExtensionClient, HomeFeedClient, TrackClient, AlbumClient, ArtistClient, PlaylistClient, + RadioClient, SearchClient, LibraryClient, TrackLikeClient, PlaylistEditorListenerClient, + SettingsChangeListenerClient { companion object { val metadata = Metadata( @@ -107,8 +115,15 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, var library = MediaStoreUtils.getAllSongs(context, settings) private fun refreshLibrary() { library = MediaStoreUtils.getAllSongs(context, settings) + cachedTracks = getCachedTracks() } + @OptIn(UnstableApi::class) + private fun getCachedTracks() = cache.keys.mapNotNull { key -> + val (id, _) = key.toIdAndIsVideo() ?: return@mapNotNull null + context.getFromCache>(id, "track") + }.reversed() + override fun setSettings(settings: Settings) {} private fun find(artist: Artist) = @@ -132,7 +147,7 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, override fun getHomeFeed(tab: Tab?): PagedData { if (refreshLibrary) refreshLibrary() fun List.sorted() = sortedBy { it.title.lowercase() } - .map { it.toShelf() }.toPaged() + .map { it.toShelf(true) }.toPaged() return when (tab?.id) { "Songs" -> library.songList.map { it.toMediaItem() }.sorted() "Albums" -> library.albumList.map { it.toAlbum().toMediaItem() }.sorted() @@ -237,10 +252,11 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, } override suspend fun loadPlaylist(playlist: Playlist) = - find(playlist)!!.toPlaylist() + if (playlist.id == "cached") playlist else find(playlist)!!.toPlaylist() override fun loadTracks(playlist: Playlist): PagedData = PagedData.Single { - find(playlist)!!.songList.map { it } + if (playlist.id == "cached") cachedTracks.map { it.second } + else find(playlist)!!.songList.map { it } } override fun getShelves(playlist: Playlist) = PagedData.Single { @@ -378,6 +394,7 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, "Playlists", "Folders" ).map { Tab(it, it) } + private var cachedTracks = listOf>() override fun getLibraryFeed(tab: Tab?): PagedData { if (refreshLibrary) refreshLibrary() return when (tab?.id) { @@ -385,8 +402,16 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, .toShelf(context, null).items!! else -> { - library.playlistList.map { it.toPlaylist().toMediaItem().toShelf() } - .toPaged() + val cached = if (cachedTracks.isNotEmpty()) Playlist( + id = "cached", + title = context.getString(R.string.cached_songs), + isEditable = false, + cover = cachedTracks.first().second.cover, + description = context.getString(R.string.cache_playlist_warning), + tracks = cachedTracks.size + ).toMediaItem().toShelf() else null + val playlists = library.playlistList.map { it.toPlaylist().toMediaItem().toShelf() } + (listOfNotNull(cached) + playlists).toPaged() } } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt index ea37ccb6..716902be 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt @@ -1,9 +1,26 @@ package dev.brahmkshatriya.echo.playback import androidx.media3.common.MediaItem +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track data class Current( val index: Int, val mediaItem: MediaItem, - val isLoaded: Boolean -) + val isLoaded: Boolean, + val isPlaying: Boolean, +) { + val context = lazy { mediaItem.context } + val track = lazy { mediaItem.track } + fun isPlaying(id: String): Boolean { + val same = mediaItem.mediaId == id + || context.value?.id == id + || track.value.album?.id == id + || track.value.artists.any { it.id == id } + return isPlaying && same + } + + companion object { + fun Current?.isPlaying(id: String): Boolean = this?.isPlaying(id) ?: false + } +} diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt index 20686ccf..b950e85c 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt @@ -1,7 +1,6 @@ package dev.brahmkshatriya.echo.playback import android.content.SharedPreferences -import android.net.Uri import android.os.Bundle import androidx.core.net.toUri import androidx.core.os.bundleOf @@ -79,9 +78,8 @@ object MediaItemUtils { item.build() } - fun Uri.toIdAndIsVideo() = runCatching { - val string = toString() - if (string.startsWith('{')) string.toData>() + fun String.toIdAndIsVideo() = runCatching { + if (startsWith('{')) toData>() else null }.getOrNull() diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/PlayerEventListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/PlayerEventListener.kt index f724bf1a..b840d793 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/PlayerEventListener.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/PlayerEventListener.kt @@ -32,7 +32,7 @@ class PlayerEventListener( private fun updateCurrent() { handler.removeCallbacks(runnable) - if (player.isPlaying) ResumptionUtils.saveCurrentPos(context, player.currentPosition) + ResumptionUtils.saveCurrentPos(context, player.currentPosition) handler.postDelayed(runnable, 1000) } @@ -52,14 +52,14 @@ class PlayerEventListener( private fun updateCurrentFlow() { currentFlow.value = player.currentMediaItem?.let { - Current(player.currentMediaItemIndex, it, it.isLoaded) + val isPlaying = player.isPlaying && player.playbackState == Player.STATE_READY + Current(player.currentMediaItemIndex, it, it.isLoaded, isPlaying) } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { updateCurrentFlow() updateCustomLayout() - updateCurrent() } override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { @@ -76,4 +76,14 @@ class PlayerEventListener( updateCustomLayout() } + override fun onPlaybackStateChanged(playbackState: Int) { + updateCurrentFlow() + updateCustomLayout() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + updateCurrentFlow() + updateCurrent() + } + } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/CustomCacheDataSource.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/CustomCacheDataSource.kt new file mode 100644 index 00000000..4efa1407 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/CustomCacheDataSource.kt @@ -0,0 +1,50 @@ +package dev.brahmkshatriya.echo.playback.source + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.SimpleCache +import dev.brahmkshatriya.echo.playback.source.MediaResolver.Companion.LOCAL + +@OptIn(UnstableApi::class) +class CustomCacheDataSource( + cache: SimpleCache, + private val upstream: DataSource.Factory +) : BaseDataSource(true) { + + class Factory( + private val cache: SimpleCache, + private val upstream: DataSource.Factory + ) : DataSource.Factory { + override fun createDataSource() = CustomCacheDataSource(cache, upstream) + } + + private val cacheFactory = CacheDataSource + .Factory().setCache(cache) + .setUpstreamDataSourceFactory(upstream) + + var source: DataSource? = null + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + return source?.read(buffer, offset, length) ?: throw Exception("Source not opened") + } + + override fun open(dataSpec: DataSpec): Long { + val source = if (dataSpec.uri == LOCAL) upstream.createDataSource() + else cacheFactory.createDataSource() + this.source = source + return source.open(dataSpec) + } + + override fun getUri(): Uri? { + return source?.uri + } + + override fun close() { + source?.close() + source = null + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/DelayedSource.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/DelayedSource.kt index af3e3fe6..7f8cce2a 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/DelayedSource.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/DelayedSource.kt @@ -3,14 +3,12 @@ package dev.brahmkshatriya.echo.playback.source import android.content.Context import android.content.SharedPreferences import androidx.annotation.OptIn -import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.source.CompositeMediaSource -import androidx.media3.exoplayer.source.FilteringMediaSource import androidx.media3.exoplayer.source.MediaPeriod import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource @@ -68,13 +66,10 @@ class DelayedSource( mediaItem = new actualSource = when (new.isAudioAndVideoMerged()) { true -> mediaFactory.create(new, true) - null -> FilteringMediaSource(mediaFactory.create(new, false), C.TRACK_TYPE_AUDIO) + null -> mediaFactory.create(new, false) false -> MergingMediaSource( - FilteringMediaSource( - mediaFactory.create(new, true), - setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_TEXT) - ), - FilteringMediaSource(mediaFactory.create(new, false), C.TRACK_TYPE_AUDIO) + mediaFactory.create(new, true), + mediaFactory.create(new, false) ) } runCatching { prepareChildSource(null, actualSource) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaFactory.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaFactory.kt index d04917ce..6c99f51b 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaFactory.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaFactory.kt @@ -7,7 +7,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.ResolvingDataSource -import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.SimpleCache import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.drm.DrmSessionManagerProvider @@ -50,9 +49,7 @@ class MediaFactory( private val mediaResolver = MediaResolver(context, extListFlow) private val dataSource = ResolvingDataSource.Factory( - CacheDataSource - .Factory().setCache(cache) - .setUpstreamDataSourceFactory(MediaDataSource.Factory(context)), + CustomCacheDataSource.Factory(cache, MediaDataSource.Factory(context)), mediaResolver ) private val default = lazily { DefaultMediaSourceFactory(dataSource) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaResolver.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaResolver.kt index ca370efc..048521fe 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaResolver.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaResolver.kt @@ -16,13 +16,16 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.MusicExtension import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.offline.OfflineExtension import dev.brahmkshatriya.echo.playback.MediaItemUtils.audioIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId import dev.brahmkshatriya.echo.playback.MediaItemUtils.toIdAndIsVideo import dev.brahmkshatriya.echo.playback.MediaItemUtils.track import dev.brahmkshatriya.echo.playback.MediaItemUtils.video import dev.brahmkshatriya.echo.playback.source.DelayedSource.Companion.getMediaItemById import dev.brahmkshatriya.echo.playback.source.DelayedSource.Companion.getTrackClient import dev.brahmkshatriya.echo.playback.source.MediaDataSource.Companion.copy +import dev.brahmkshatriya.echo.utils.saveToCache import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -36,7 +39,7 @@ class MediaResolver( @UnstableApi override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { - val (id, isVideo) = dataSpec.uri.toIdAndIsVideo() ?: return dataSpec + val (id, isVideo) = dataSpec.uri.toString().toIdAndIsVideo() ?: return dataSpec val (_, mediaItem) = runBlocking(Dispatchers.Main) { player.getMediaItemById(id) @@ -45,8 +48,16 @@ class MediaResolver( val streamable = if (isVideo) mediaItem.video!! else runBlocking(Dispatchers.IO) { runCatching { loadAudio(mediaItem) } }.getOrThrow() + val uri = if (mediaItem.clientId == OfflineExtension.metadata.id) LOCAL + else { + if(!isVideo) { + val track = mediaItem.track + context.saveToCache(track.id, mediaItem.clientId to track, "track") + } + dataSpec.uri + } return dataSpec.copy( - uri = streamable.hashCode().toString().toUri(), + uri = uri, customData = streamable ) } @@ -66,6 +77,8 @@ class MediaResolver( companion object { + val LOCAL = "local".toUri() + @OptIn(UnstableApi::class) fun getPlayer( context: Context, cache: SimpleCache, video: Streamable.Media.WithVideo.Only diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/GridViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/GridViewHolder.kt index 82ee7324..391d0f3d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/GridViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/GridViewHolder.kt @@ -1,6 +1,7 @@ package dev.brahmkshatriya.echo.ui.adapter import android.annotation.SuppressLint +import android.graphics.drawable.Animatable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible @@ -10,10 +11,14 @@ import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.ItemShelfMediaGridBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTitleBinding +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.bind import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.icon import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.placeHolder +import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.toolTipOnClick +import dev.brahmkshatriya.echo.utils.animateVisibility import dev.brahmkshatriya.echo.utils.loadInto +import dev.brahmkshatriya.echo.utils.observe class GridViewHolder( val listener: ShelfAdapter.Listener, @@ -22,7 +27,7 @@ class GridViewHolder( ) : ShelfListItemViewHolder(binding.root) { @SuppressLint("SetTextI18n") - override fun bind(item:Any) { + override fun bind(item: Any) { val media = when (item) { is EchoMediaItem -> { binding.iconContainer.isVisible = true @@ -57,6 +62,12 @@ class GridViewHolder( if (isNumbered) listener.onLongClick(clientId, null, tracks, pos, it) else listener.onLongClick(clientId, media, it) } + binding.isPlaying.toolTipOnClick() + observe(listener.current) { + val playing = it.isPlaying(media.id) + binding.isPlaying.animateVisibility(playing) + if (playing) (binding.isPlaying.icon as Animatable).start() + } media } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/MediaItemViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/MediaItemViewHolder.kt index a22499a9..dbde3bae 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/MediaItemViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/MediaItemViewHolder.kt @@ -1,5 +1,6 @@ package dev.brahmkshatriya.echo.ui.adapter +import android.graphics.drawable.Animatable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -13,8 +14,11 @@ import dev.brahmkshatriya.echo.databinding.NewItemMediaListsBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaProfileBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTitleBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTrackBinding +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.utils.animateVisibility import dev.brahmkshatriya.echo.utils.loadInto import dev.brahmkshatriya.echo.utils.loadWith +import dev.brahmkshatriya.echo.utils.observe sealed class MediaItemViewHolder( val listener: ShelfAdapter.Listener, @@ -49,7 +53,10 @@ sealed class MediaItemViewHolder( override fun bind(item: EchoMediaItem) { item as EchoMediaItem.Lists titleBinding.bind(item) - binding.cover.bind(item) + val isPlaying = binding.cover.bind(item) + observe(listener.current) { + isPlaying(it.isPlaying(item.id)) + } } companion object { @@ -79,7 +86,10 @@ sealed class MediaItemViewHolder( override fun bind(item: EchoMediaItem) { titleBinding.bind(item) - binding.cover.bind(item) + val isPlaying = binding.cover.bind(item) + observe(listener.current) { + isPlaying(it.isPlaying(item.id)) + } } companion object { @@ -154,17 +164,27 @@ sealed class MediaItemViewHolder( subtitle.text = item.subtitle } - fun ItemTrackCoverBinding.bind(item: EchoMediaItem) { + fun View.toolTipOnClick() { + setOnClickListener { performLongClick() } + } + + fun ItemTrackCoverBinding.bind(item: EchoMediaItem): (Boolean) -> Unit { item.cover.loadInto(trackImageView, item.placeHolder()) this.iconContainer.isVisible = item !is EchoMediaItem.TrackItem this.icon.setImageResource(item.icon()) + isPlaying.toolTipOnClick() + return { playing: Boolean -> + isPlaying.animateVisibility(playing) + if (playing) (isPlaying.icon as Animatable).start() + } } - fun ItemProfileCoverBinding.bind(item: EchoMediaItem) { + fun ItemProfileCoverBinding.bind(item: EchoMediaItem): (Boolean) -> Unit { item.cover.loadInto(profileImageView, item.placeHolder()) + return { } } - fun ItemListsCoverBinding.bind(item: EchoMediaItem.Lists) { + fun ItemListsCoverBinding.bind(item: EchoMediaItem.Lists): (Boolean) -> Unit { playlist.isVisible = item is EchoMediaItem.Lists.PlaylistItem val cover = item.cover cover.loadWith(listImageView) { @@ -172,6 +192,11 @@ sealed class MediaItemViewHolder( cover.loadInto(listImageView2) } albumImage(item.size, listImageContainer1, listImageContainer2) + isPlaying.toolTipOnClick() + return { playing: Boolean -> + isPlaying.animateVisibility(playing) + if (playing) (isPlaying.icon as Animatable).start() + } } private fun albumImage(size: Int?, view1: View, view2: View) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfAdapter.kt index c4e99a70..438c55cc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfAdapter.kt @@ -20,6 +20,7 @@ import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Category import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Lists import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Media +import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.MediaLists import dev.brahmkshatriya.echo.ui.editplaylist.SearchForPlaylistClickListener import dev.brahmkshatriya.echo.ui.editplaylist.SearchForPlaylistFragment import dev.brahmkshatriya.echo.ui.item.TrackAdapter @@ -157,7 +158,7 @@ class ShelfAdapter( val item = getItem(position) ?: return 0 return when (item) { is Shelf.Lists<*> -> 0 - is Shelf.Item -> 1 + is Shelf.Item -> if (item.media is EchoMediaItem.Lists && item.loadTracks) 3 else 1 is Shelf.Category -> 2 } } @@ -168,6 +169,7 @@ class ShelfAdapter( 0 -> Lists.create(parent, stateViewModel, sharedPool, extension.id, listener) 1 -> Media.create(parent, extension.id, listener) 2 -> Category.create(parent) + 3 -> MediaLists.create(parent, extension.id, listener, fragment) else -> throw IllegalArgumentException("Unknown viewType: $viewType") } holder.lifecycleRegistry = LifecycleRegistry(holder) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfClickListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfClickListener.kt index ddcbcf5a..90abfc2d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfClickListener.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfClickListener.kt @@ -17,6 +17,7 @@ import dev.brahmkshatriya.echo.common.models.Shelf.Item import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.extensions.isClient +import dev.brahmkshatriya.echo.playback.Current import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.ui.container.ContainerFragment import dev.brahmkshatriya.echo.ui.container.ContainerViewModel @@ -30,6 +31,7 @@ import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel import dev.brahmkshatriya.echo.viewmodels.SnackBar import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map open class ShelfClickListener( @@ -131,6 +133,9 @@ open class ShelfClickListener( return true } + override val current: StateFlow + get() = fragment.activityViewModels().value.currentFlow + override fun onClick(clientId: String, shelf: Shelf, transitionView: View) { when (shelf) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt index ab58fdf9..d0abd7ad 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt @@ -1,25 +1,47 @@ package dev.brahmkshatriya.echo.ui.adapter +import android.app.Application import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModel import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.flexbox.JustifyContent.SPACE_BETWEEN +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.helpers.PagedData import dev.brahmkshatriya.echo.common.models.EchoMediaItem import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.ItemShelfCategoryBinding import dev.brahmkshatriya.echo.databinding.ItemShelfListsBinding import dev.brahmkshatriya.echo.databinding.ItemShelfMediaBinding +import dev.brahmkshatriya.echo.databinding.ItemShelfMediaListsBinding +import dev.brahmkshatriya.echo.extensions.getExtension +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying import dev.brahmkshatriya.echo.ui.adapter.GridViewHolder.Companion.ifGrid import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.bind +import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Media.Companion.bind import dev.brahmkshatriya.echo.ui.adapter.ShowButtonViewHolder.Companion.ifShowingButton +import dev.brahmkshatriya.echo.ui.item.TrackAdapter +import dev.brahmkshatriya.echo.ui.paging.toFlow import dev.brahmkshatriya.echo.utils.dpToPx +import dev.brahmkshatriya.echo.utils.observe +import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.noClient +import kotlinx.coroutines.flow.MutableStateFlow import java.lang.ref.WeakReference +import javax.inject.Inject sealed class ShelfViewHolder( itemView: View, @@ -129,7 +151,10 @@ sealed class ShelfViewHolder( ) : ShelfViewHolder(binding.root) { override fun bind(item: Shelf) { val media = (item as? Shelf.Item)?.media ?: return - binding.bind(media) + val isPlaying = binding.bind(media) + observe(listener.current) { + isPlaying(it.isPlaying(media.id)) + } binding.more.setOnClickListener { listener.onLongClick(clientId, media, transitionView) } @@ -151,7 +176,7 @@ sealed class ShelfViewHolder( ) } - fun ItemShelfMediaBinding.bind(item: EchoMediaItem) { + fun ItemShelfMediaBinding.bind(item: EchoMediaItem): (Boolean) -> Unit { title.text = item.title subtitle.text = item.subtitle subtitle.isVisible = item.subtitle.isNullOrBlank().not() @@ -160,7 +185,7 @@ sealed class ShelfViewHolder( listsImageContainer.root.isVisible = item is EchoMediaItem.Lists profileImageContainer.root.isVisible = item is EchoMediaItem.Profile - when (item) { + return when (item) { is EchoMediaItem.TrackItem -> trackImageContainer.bind(item) is EchoMediaItem.Lists -> listsImageContainer.bind(item) is EchoMediaItem.Profile -> profileImageContainer.bind(item) @@ -168,4 +193,85 @@ sealed class ShelfViewHolder( } } } + + class MediaLists( + val binding: ItemShelfMediaListsBinding, + private val clientId: String, + val listener: ShelfAdapter.Listener, + val viewModel: ListViewModel + ) : ShelfViewHolder(binding.root) { + override val transitionView: View + get() = binding.root + + override fun bind(item: Shelf) { + val media = (item as? Shelf.Item)?.media ?: return + if (media !is EchoMediaItem.Lists) return + val isPlaying = binding.listsInfo.bind(media) + observe(listener.current) { + isPlaying(it.isPlaying(media.id)) + } + binding.listsInfo.more.setOnClickListener { + listener.onLongClick(clientId, media, transitionView) + } + binding.listsTracks.title.setText(R.string.songs) + binding.listsTracks.shuffle.isVisible = false + val transition = transitionView.transitionName + media.id + val adapter = TrackAdapter(clientId, transition, listener, media, false) + binding.listsTracks.recyclerView.adapter = adapter + val tracks = viewModel.loadTracks(clientId, media) + observe(tracks.toFlow()) { + adapter.submitData(it) + } + } + + companion object { + fun create( + parent: ViewGroup, + clientId: String, + listener: ShelfAdapter.Listener, + fragment: Fragment + ): ShelfViewHolder { + val viewModel by fragment.activityViewModels() + val layoutInflater = LayoutInflater.from(parent.context) + return MediaLists( + ItemShelfMediaListsBinding.inflate(layoutInflater, parent, false), + clientId, + listener, + viewModel + ) + } + } + + @HiltViewModel + class ListViewModel @Inject constructor( + val app: Application, + val extensionList: MutableStateFlow?>, + ) : ViewModel() { + val map = hashMapOf>() + fun loadTracks(clientId: String, lists: EchoMediaItem.Lists) = map.getOrPut(lists) { + PagedData.Single { + val client = extensionList.getExtension(clientId)?.instance?.value?.getOrNull() + ?: throw Exception(app.noClient().message) + when (lists) { + is EchoMediaItem.Lists.AlbumItem -> { + client as AlbumClient + val album = client.loadAlbum(lists.album) + client.loadTracks(album) + } + + is EchoMediaItem.Lists.PlaylistItem -> { + client as PlaylistClient + val playlist = client.loadPlaylist(lists.playlist) + client.loadTracks(playlist) + } + + is EchoMediaItem.Lists.RadioItem -> { + client as RadioClient + client.loadTracks(lists.radio) + } + }.loadFirst().take(3) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/TrackViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/TrackViewHolder.kt index ea545355..6ced1d5c 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/TrackViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/TrackViewHolder.kt @@ -1,5 +1,6 @@ package dev.brahmkshatriya.echo.ui.adapter +import android.graphics.drawable.Animatable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible @@ -7,8 +8,12 @@ import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.models.EchoMediaItem import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.databinding.ItemTrackBinding +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.toolTipOnClick import dev.brahmkshatriya.echo.ui.item.TrackAdapter +import dev.brahmkshatriya.echo.utils.animateVisibility import dev.brahmkshatriya.echo.utils.loadInto +import dev.brahmkshatriya.echo.utils.observe import dev.brahmkshatriya.echo.utils.toTimeString class TrackViewHolder( @@ -48,6 +53,12 @@ class TrackViewHolder( binding.itemMore.setOnClickListener { listener.onLongClick(clientId, context, list, pos, binding.root) } + binding.isPlaying.toolTipOnClick() + observe(listener.current) { + val playing = it.isPlaying(track.id) + binding.isPlaying.animateVisibility(playing) + if(playing) (binding.isPlaying.icon as Animatable).start() + } } override val transitionView = binding.root diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/common/OpenFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/common/OpenFragment.kt index bd0da82e..7cf22e87 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/common/OpenFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/common/OpenFragment.kt @@ -1,12 +1,23 @@ package dev.brahmkshatriya.echo.ui.common +import android.net.Uri import android.os.Bundle import android.view.View +import androidx.activity.viewModels import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.activityViewModels import androidx.fragment.app.commit import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.ui.item.ItemFragment +import dev.brahmkshatriya.echo.viewmodels.SnackBar import dev.brahmkshatriya.echo.viewmodels.UiViewModel fun Fragment.openFragment(newFragment: Fragment, view: View? = null) { @@ -14,7 +25,7 @@ fun Fragment.openFragment(newFragment: Fragment, view: View? = null) { if (view != null) { addSharedElement(view, view.transitionName) newFragment.run { - if(arguments == null) arguments = Bundle() + if (arguments == null) arguments = Bundle() arguments!!.putString("transitionName", view.transitionName) } } @@ -31,4 +42,47 @@ fun Fragment.openFragment(newFragment: Fragment, view: View? = null) { fun FragmentActivity.openFragment(newFragment: Fragment, view: View? = null) { val oldFragment = supportFragmentManager.findFragmentById(R.id.navHostFragment)!! oldFragment.openFragment(newFragment, view) -} \ No newline at end of file +} + +fun FragmentActivity.openItemFragmentFromUri(uri: Uri) { + fun createSnack(id: Int) { + val snackbar by viewModels() + val message = getString(id) + snackbar.create(SnackBar.Message(message)) + } + + val extensionType = uri.host + when (extensionType) { + "music" -> { + val extensionId = uri.pathSegments.firstOrNull() + if (extensionId == null) { + createSnack(R.string.error_no_client) + return + } + val type = uri.pathSegments.getOrNull(1) + val id = uri.pathSegments.getOrNull(2) + if (id == null) { + createSnack(R.string.error_no_id) + return + } + val name = uri.getQueryParameter("name").orEmpty() + val item: EchoMediaItem? = when (type) { + "user" -> User(id, name).toMediaItem() + "artist" -> Artist(id, name).toMediaItem() + "track" -> Track(id, name).toMediaItem() + "album" -> Album(id, name).toMediaItem() + "playlist" -> Playlist(id, name, false).toMediaItem() + else -> null + } + if (item == null) { + createSnack(R.string.error_invalid_type) + return + } + openFragment(ItemFragment.newInstance(extensionId, item)) + } + + else -> { + createSnack(R.string.invalid_extension_host) + } + } +} diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt index b59a6635..e6824fbb 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt @@ -22,7 +22,7 @@ import dev.brahmkshatriya.echo.databinding.FragmentEditPlaylistBinding import dev.brahmkshatriya.echo.playback.MediaItemUtils import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.extensions.isClient -import dev.brahmkshatriya.echo.ui.adapter.PlaylistAdapter +import dev.brahmkshatriya.echo.ui.player.PlaylistAdapter import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.ui.editplaylist.EditPlaylistViewModel.ListAction.Add import dev.brahmkshatriya.echo.ui.editplaylist.EditPlaylistViewModel.ListAction.Move diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionAdapter.kt index e20cc280..497ad9a2 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionAdapter.kt @@ -11,29 +11,28 @@ import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder import dev.brahmkshatriya.echo.databinding.ItemExtensionBinding -import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.ui.adapter.ShelfEmptyAdapter import dev.brahmkshatriya.echo.utils.loadWith class ExtensionAdapter( val listener: Listener -) : PagingDataAdapter(DiffCallback) { +) : PagingDataAdapter, ExtensionAdapter.ViewHolder>(DiffCallback) { - fun interface Listener { - fun onClick(metadata: Metadata, view: View) + interface Listener { + fun onClick(extension: Extension<*>, view: View) + fun onDragHandleTouched(viewHolder: ViewHolder) } - object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: Metadata, newItem: Metadata - ) = oldItem.id == newItem.id + object DiffCallback : DiffUtil.ItemCallback>() { + override fun areItemsTheSame(oldItem: Extension<*>, newItem: Extension<*>) = + oldItem.id == newItem.id - override fun areContentsTheSame( - oldItem: Metadata, newItem: Metadata - ) = oldItem == newItem + override fun areContentsTheSame(oldItem: Extension<*>, newItem: Extension<*>) = + oldItem == newItem } private val empty = ShelfEmptyAdapter() @@ -42,9 +41,11 @@ ExtensionAdapter( class ViewHolder(val binding: ItemExtensionBinding, val listener: Listener) : RecyclerView.ViewHolder(binding.root) { @SuppressLint("SetTextI18n") - fun bind(metadata: Metadata) { + fun bind(extension: Extension<*>) { + val metadata = extension.metadata binding.root.transitionName = metadata.id - binding.root.setOnClickListener { listener.onClick(metadata, binding.root) } + binding.root.alpha = if (extension.instance.value.isSuccess) 1f else 0.5f + binding.root.setOnClickListener { listener.onClick(extension, binding.root) } binding.extensionName.apply { text = if (metadata.enabled) metadata.name else context.getString(R.string.extension_disabled, metadata.name) @@ -55,6 +56,9 @@ ExtensionAdapter( setImageDrawable(it) } } + binding.extensionDrag.setOnClickListener { + listener.onDragHandleTouched(this) + } } } @@ -68,7 +72,7 @@ ExtensionAdapter( holder.bind(download) } - suspend fun submit(list: List) { + suspend fun submit(list: List>) { empty.loadState = if (list.isEmpty()) LoadState.Loading else LoadState.NotLoading(true) submitData(PagingData.from(list)) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt index e37cfa50..3512027c 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt @@ -9,11 +9,13 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.clients.LoginClient import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder -import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.databinding.FragmentExtensionBinding import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.extensions.isClient @@ -31,6 +33,7 @@ import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyBackPressCallback import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyContentInsets import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyInsets +import kotlinx.coroutines.launch class ExtensionInfoFragment : Fragment() { companion object { @@ -44,8 +47,8 @@ class ExtensionInfoFragment : Fragment() { } } - fun newInstance(metadata: Metadata, extensionType: Int) = - newInstance(metadata.id, metadata.name, ExtensionType.entries[extensionType]) + fun newInstance(extension: Extension<*>) = + newInstance(extension.id, extension.name, extension.type) } private var binding by autoCleared() @@ -83,7 +86,7 @@ class ExtensionInfoFragment : Fragment() { val extension = when (extensionType) { ExtensionType.MUSIC -> viewModel.extensionListFlow.getExtension(clientId) - ExtensionType.TRACKER -> viewModel.trackerListFlow.getExtension(clientId) + ExtensionType.TRACKER -> viewModel.trackerListFlow.getExtension(clientId) ExtensionType.LYRICS -> viewModel.lyricsListFlow.getExtension(clientId) } @@ -95,6 +98,24 @@ class ExtensionInfoFragment : Fragment() { val metadata = extension.metadata + if (metadata.importType != ImportType.BuiltIn) { + binding.toolBar.inflateMenu(R.menu.extensions_menu) + binding.toolBar.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_uninstall -> { + lifecycleScope.launch { + viewModel.uninstall(requireActivity(), extension) { + parentFragmentManager.popBackStack() + } + } + true + } + + else -> false + } + } + } + metadata.iconUrl?.toImageHolder().loadWith(binding.extensionIcon, R.drawable.ic_extension) { binding.extensionIcon.setImageDrawable(it) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt new file mode 100644 index 00000000..121198ec --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt @@ -0,0 +1,121 @@ +package dev.brahmkshatriya.echo.ui.extension + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.EXTENSION_INSTALLER +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder +import dev.brahmkshatriya.echo.databinding.DialogExtensionInstallerBinding +import dev.brahmkshatriya.echo.extensions.getType +import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource +import dev.brahmkshatriya.echo.utils.ApkLinkParser +import dev.brahmkshatriya.echo.utils.autoCleared +import dev.brahmkshatriya.echo.utils.loadWith +import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack + +class ExtensionInstallerBottomSheet : BottomSheetDialogFragment() { + + companion object { + fun newInstance( + file: String, + ) = ExtensionInstallerBottomSheet().apply { + arguments = Bundle().apply { + putString("file", file) + } + } + } + + private var binding by autoCleared() + private val args by lazy { requireArguments() } + private val file by lazy { args.getString("file")!!.toUri().toFile() } + private val supportedLinks by lazy { ApkLinkParser.getSupportedLinks(file) } + private val pair by lazy { + runCatching { + val packageInfo = requireActivity().packageManager + .getPackageArchiveInfo(file.path, ApkPluginSource.PACKAGE_FLAGS)!! + val type = getType(packageInfo) + val metadata = + ApkManifestParser(ImportType.App).parseManifest(packageInfo.applicationInfo!!) + type to metadata + } + } + + override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, state: Bundle?): View { + binding = DialogExtensionInstallerBinding.inflate(inflater, parent, false) + return binding.root + } + + private var install = false + private var installAsApk = true + + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.topAppBar.setNavigationOnClickListener { dismiss() } + val value = pair.getOrNull() + if (value == null) { + createSnack(R.string.invalid_extension) + dismiss() + return + } + val (extensionType, metadata) = value + + binding.extensionTitle.text = metadata.name + metadata.iconUrl?.toImageHolder().loadWith(binding.extensionIcon, R.drawable.ic_extension) { + binding.extensionIcon.setImageDrawable(it) + } + binding.extensionDetails.text = metadata.version + + val byAuthor = getString(R.string.by_author, metadata.author) + val type = when (extensionType) { + ExtensionType.MUSIC -> R.string.music + ExtensionType.TRACKER -> R.string.tracker + ExtensionType.LYRICS -> R.string.lyrics + } + val typeString = getString(R.string.name_extension, getString(type)) + binding.extensionDescription.text = "$typeString\n\n${metadata.description}\n\n$byAuthor" + + val isSupported = supportedLinks.isNotEmpty() + binding.installationTypeTitle.isVisible = isSupported + binding.installationTypeGroup.isVisible = isSupported + binding.installationTypeSummary.isVisible = isSupported + binding.installationTypeLinks.isVisible = isSupported + binding.installationTypeWarning.isVisible = false + + installAsApk = isSupported + if (isSupported) { + binding.installationTypeLinks.text = supportedLinks.joinToString("\n") + binding.installationTypeGroup.addOnButtonCheckedListener { group, _, _ -> + installAsApk = group.checkedButtonId == R.id.appInstall + binding.installationTypeWarning.isVisible = !installAsApk + } + } + + binding.installButton.setOnClickListener { + install = true + dismiss() + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + requireActivity().supportFragmentManager.setFragmentResult( + EXTENSION_INSTALLER, + Bundle().apply { + putString("file", file.toUri().toString()) + putBoolean("install", install) + putBoolean("installAsApk", installAsApk) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ManageExtensionsFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ManageExtensionsFragment.kt index d38aed23..ed9d1f9e 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ManageExtensionsFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ManageExtensionsFragment.kt @@ -7,8 +7,12 @@ import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.tabs.TabLayout import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.helpers.ExtensionType import dev.brahmkshatriya.echo.databinding.FragmentManageExtensionsBinding import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.utils.FastScrollerHelper @@ -20,7 +24,7 @@ import dev.brahmkshatriya.echo.utils.setupTransition import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyBackPressCallback import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyInsetsMain -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.Job class ManageExtensionsFragment : Fragment() { var binding by autoCleared() @@ -50,35 +54,62 @@ class ManageExtensionsFragment : Fragment() { refresh.setOnClickListener { viewModel.refresh() } binding.swipeRefresh.configure { viewModel.refresh() } - val flow = MutableStateFlow( - viewModel.extensionListFlow.value?.map { it.metadata } - ) + var type = ExtensionType.entries[binding.tabLayout.selectedTabPosition] + val callback = object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0 + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPos = viewHolder.bindingAdapterPosition + val toPos = target.bindingAdapterPosition + viewModel.moveExtensionItem(type, toPos, fromPos) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun getMovementFlags( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder + ) = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) + + } + + val touchHelper = ItemTouchHelper(callback) + val extensionAdapter = ExtensionAdapter(object : ExtensionAdapter.Listener { + override fun onClick(extension: Extension<*>, view: View) { + openFragment(ExtensionInfoFragment.newInstance(extension), view) + } + + override fun onDragHandleTouched(viewHolder: ExtensionAdapter.ViewHolder) { + touchHelper.startDrag(viewHolder) + } + }) - fun change(pos: Int) { - when (pos) { - 0 -> flow.value = viewModel.extensionListFlow.value?.map { it.metadata } - 1 -> flow.value = viewModel.trackerListFlow.value?.map { it.metadata } - 2 -> flow.value = viewModel.lyricsListFlow.value?.map { it.metadata } + fun change(pos: Int): Job { + type = ExtensionType.entries[pos] + val flow = viewModel.getExtensionListFlow(type) + return observe(flow) { list -> + extensionAdapter.submit(list ?: emptyList()) } } binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + var job: Job? = null override fun onTabSelected(tab: TabLayout.Tab) { - change(tab.position) + job?.cancel() + job = change(tab.position) } override fun onTabUnselected(tab: TabLayout.Tab) {} override fun onTabReselected(tab: TabLayout.Tab) {} }) - val extensionAdapter = ExtensionAdapter { metadata, view1 -> - openFragment( - ExtensionInfoFragment.newInstance(metadata, binding.tabLayout.selectedTabPosition), - view1 - ) - } binding.recyclerView.adapter = extensionAdapter.withEmptyAdapter() - observe(flow) { extensionAdapter.submit(it ?: emptyList()) } + touchHelper.attachToRecyclerView(binding.recyclerView) + observe(viewModel.refresher) { change(binding.tabLayout.selectedTabPosition) binding.swipeRefresh.isRefreshing = it diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ArtistHeaderAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ArtistHeaderAdapter.kt index 2d883cef..20953be9 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ArtistHeaderAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ArtistHeaderAdapter.kt @@ -71,7 +71,9 @@ class ArtistHeaderAdapter(private val listener: Listener) : binding.root.resources.getQuantityString(R.plurals.number_followers, it, it) } ?: "" - description += artist.description?.let { if (description.isBlank()) it else "\n\n$it" } + description += + artist.description?.let { if (description.isBlank()) it else "\n\n$it" } ?: "" + binding.artistDescriptionContainer.isVisible = if (description.isBlank()) false else { binding.artistDescription.text = description true diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt index 2537bba3..f231aebe 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt @@ -30,6 +30,7 @@ import dev.brahmkshatriya.echo.databinding.ItemDialogButtonBinding import dev.brahmkshatriya.echo.databinding.ItemDialogButtonLoadingBinding import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.offline.OfflineExtension +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Media.Companion.bind import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.ui.editplaylist.AddToPlaylistBottomSheet @@ -81,13 +82,17 @@ class ItemBottomSheet : BottomSheetDialogFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + var isPlaying: (Boolean) -> Unit = {} + observe(playerViewModel.currentFlow) { + isPlaying(it.isPlaying(item.id)) + } binding.itemContainer.run { more.run { setOnClickListener { dismiss() } setIconResource(R.drawable.ic_close) contentDescription = context.getString(R.string.close) } - bind(item) + isPlaying = bind(item) if (!loaded) root.setOnClickListener { openItemFragment(item) dismiss() @@ -103,7 +108,7 @@ class ItemBottomSheet : BottomSheetDialogFragment() { viewModel.initialize() observe(viewModel.itemFlow) { if (it != null) { - binding.itemContainer.bind(it) + isPlaying = binding.itemContainer.bind(it) binding.recyclerView.adapter = ActionAdapter(getActions(it, true)) } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/TrackAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/TrackAdapter.kt index a90273e0..30c91c30 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/TrackAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/TrackAdapter.kt @@ -2,13 +2,18 @@ package dev.brahmkshatriya.echo.ui.item import android.view.View import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import dev.brahmkshatriya.echo.common.models.EchoMediaItem import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.playback.Current import dev.brahmkshatriya.echo.ui.adapter.TrackViewHolder +import kotlinx.coroutines.flow.StateFlow class TrackAdapter( private val clientId: String, @@ -24,6 +29,7 @@ class TrackAdapter( } interface Listener { + val current: StateFlow fun onClick( clientId: String, context: EchoMediaItem?, list: List, pos: Int, view: View ) @@ -37,10 +43,26 @@ class TrackAdapter( submitData(pagingData ?: PagingData.empty()) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - TrackViewHolder.create(parent, listener, clientId, context) + private fun destroyLifeCycle(holder: TrackViewHolder) { + if (holder.lifecycleRegistry.currentState.isAtLeast(Lifecycle.State.STARTED)) + holder.lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + + @CallSuper + override fun onViewRecycled(holder: TrackViewHolder) { + destroyLifeCycle(holder) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackViewHolder { + val holder = TrackViewHolder.create(parent, listener, clientId, context) + holder.lifecycleRegistry = LifecycleRegistry(holder) + return holder + } override fun onBindViewHolder(holder: TrackViewHolder, position: Int) { + destroyLifeCycle(holder) + holder.lifecycleRegistry = LifecycleRegistry(holder) + holder.lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) val binding = holder.binding val track = getItem(position) ?: return binding.root.transitionName = (transition + track.id).hashCode().toString() diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt similarity index 93% rename from app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt rename to app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt index 560f8410..35ddbe91 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt @@ -1,6 +1,7 @@ -package dev.brahmkshatriya.echo.ui.adapter +package dev.brahmkshatriya.echo.ui.player import android.annotation.SuppressLint +import android.graphics.drawable.Animatable import android.view.LayoutInflater import android.view.MotionEvent import android.view.ViewGroup @@ -13,6 +14,7 @@ import dev.brahmkshatriya.echo.databinding.ItemPlaylistItemBinding import dev.brahmkshatriya.echo.databinding.SkeletonItemQueueBinding import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.ui.adapter.LifeCycleListAdapter import dev.brahmkshatriya.echo.utils.loadInto import dev.brahmkshatriya.echo.utils.toTimeString @@ -89,6 +91,8 @@ class PlaylistAdapter( binding.playlistCurrentItem.isVisible = isCurrent binding.playlistProgressBar.isVisible = isCurrent && !item.isLoaded + binding.playlistItemNowPlaying.isVisible = isCurrent && item.isLoaded + (binding.playlistItemNowPlaying.drawable as Animatable).start() } class Loader : RecyclerView.Adapter() { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/QueueFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/QueueFragment.kt index 4e57dda6..7bfe012d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/QueueFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/QueueFragment.kt @@ -14,7 +14,6 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDE import dev.brahmkshatriya.echo.databinding.FragmentPlaylistBinding import dev.brahmkshatriya.echo.playback.MediaItemUtils import dev.brahmkshatriya.echo.playback.listeners.Radio -import dev.brahmkshatriya.echo.ui.adapter.PlaylistAdapter import dev.brahmkshatriya.echo.utils.autoCleared import dev.brahmkshatriya.echo.utils.dpToPx import dev.brahmkshatriya.echo.utils.observe diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AboutFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AboutFragment.kt index 49cfa1f8..ed560f3d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AboutFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AboutFragment.kt @@ -54,12 +54,15 @@ class AboutFragment : BaseSettingsFragment() { "fr" to "Français", "hi" to "हिन्दी", "hng" to "Hinglish", + "hu" to "Magyar", "ja" to "日本語", "nb-rNO" to "Norsk bokmål", "nl" to "Nederlands", "pl" to "Polski", "pt" to "Português", + "ru" to "Русский", "sa" to "संस्कृतम्", + "sr" to "Српски", "tr" to "Türkçe", "zh-rCN" to "中文 (简体)", ) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt index 49cca283..4bec7daf 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt @@ -56,7 +56,7 @@ class AudioFragment : BaseSettingsFragment() { summary = getString(R.string.stop_player_summary) layoutResource = R.layout.preference_switch isIconSpaceReserved = false - setDefaultValue(false) + setDefaultValue(true) addPreference(this) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt index e178f5a3..6379b952 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt @@ -26,7 +26,7 @@ import dev.brahmkshatriya.echo.common.settings.SettingSwitch import dev.brahmkshatriya.echo.common.settings.SettingTextInput import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.extensions.run -import dev.brahmkshatriya.echo.extensions.toSettings +import dev.brahmkshatriya.echo.utils.toSettings import dev.brahmkshatriya.echo.utils.prefs.MaterialListPreference import dev.brahmkshatriya.echo.utils.prefs.MaterialMultipleChoicePreference import dev.brahmkshatriya.echo.utils.prefs.MaterialSliderPreference diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/AnimationUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/AnimationUtils.kt index 19f1286c..bc78ec52 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/utils/AnimationUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/AnimationUtils.kt @@ -66,7 +66,7 @@ fun NavigationBarView.animateTranslation( } } -fun View.animateVisibility(visible: Boolean, animate: Boolean) { +fun View.animateVisibility(visible: Boolean, animate: Boolean = true) { if (animations && animate && isVisible != visible) { isVisible = true startAnimation( diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/ApkLinkParser.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/ApkLinkParser.kt new file mode 100644 index 00000000..539f3caa --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/ApkLinkParser.kt @@ -0,0 +1,320 @@ +package dev.brahmkshatriya.echo.utils + +import org.w3c.dom.Document +import org.w3c.dom.Node +import org.xml.sax.Attributes +import org.xml.sax.helpers.DefaultHandler +import java.io.File +import java.io.InputStream +import java.util.Stack +import java.util.zip.ZipFile +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.SAXParserFactory + +class ApkLinkParser { + companion object { + fun getSupportedLinks(apkFile: File): List { + val zip = ZipFile(apkFile) + val entry = zip.getEntry("AndroidManifest.xml") + val manifest = parse(zip.getInputStream(entry)) ?: return listOf() + return manifest.getElementsByTagName("intent-filter").run { + (0 until length).flatMap { index -> + val intentFilter = item(index) + val schemes = mutableListOf() + val hosts = mutableListOf() + val paths = mutableListOf() + + intentFilter.childNodes.run { + for (i in 0 until length) { + val node = item(i) + fun data(name: String) = node.attributes.getNamedItem(name)?.nodeValue + if (node.nodeName == "data") { + data("android:scheme")?.let { schemes.add(it) } + data("android:host")?.let { hosts.add(it) } + data("android:path")?.let { + paths.add(it) + } + } + } + + schemes.flatMap { scheme -> + hosts.flatMap { host -> + if (paths.isEmpty()) listOf("$scheme://$host") + else paths.map { path -> "$scheme://$host$path" } + } + } + } + } + } + } + + private fun parse(input: InputStream) = runCatching { + val xmlDom = XmlDom() + runCatching { + CompressedXmlParser(xmlDom).parse(input) + }.getOrElse { + NonCompressedXmlParser(xmlDom).parse(input) + } + xmlDom.document + }.getOrNull() + } + + class Attribute { + var name: String? = null + var prefix: String? = null + var namespace: String? = null + var value: String? = null + } + + class XmlDom { + val document: Document = + DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() + private val mStack = Stack() + private fun isEmpty(text: String?) = (text == null) || ("" == text) + fun startDocument() { mStack.push(document) } + fun startElement( + uri: String?, localName: String?, qName: String?, attrs: Array + ) { + val elt = if (isEmpty(uri)) document.createElement(localName) + else document.createElementNS(uri, qName) + + for (attr in attrs) { + if (isEmpty(attr.namespace)) elt.setAttribute(attr.name, attr.value) + else elt.setAttributeNS(attr.namespace, attr.prefix + ':' + attr.name, attr.value) + } + mStack.peek()!!.appendChild(elt) + mStack.push(elt) + } + fun endElement() { mStack.pop() } + fun text(data: String?) { mStack.peek()!!.appendChild(document.createTextNode(data)) } + fun characterData(data: String?) { + mStack.peek()!!.appendChild(document.createCDATASection(data)) + } + } + + //NOT TESTED + class NonCompressedXmlParser(private val mListener: XmlDom) { + fun parse(input: InputStream) { + val factory = SAXParserFactory.newInstance() + val saxParser = factory.newSAXParser() + val handler = object : DefaultHandler() { + override fun startDocument() = mListener.startDocument() + override fun startElement( + uri: String?, localName: String?, qName: String, attributes: Attributes + ) { + val attrs = Array(attributes.length) { i -> + Attribute().apply { + name = attributes.getQName(i) + namespace = uri + value = attributes.getValue(i) + } + } + mListener.startElement(uri, localName, qName, attrs) + } + + override fun characters(ch: CharArray, start: Int, length: Int) { + val data = String(ch, start, length) + mListener.characterData(data) + } + + override fun endElement(uri: String?, localName: String?, qName: String) = + mListener.endElement() + + override fun endDocument() {} + } + + saxParser.parse(input, handler) + } + } + + class CompressedXmlParser(private val mListener: XmlDom) { + + fun parse(input: InputStream) { + mData = ByteArray(input.available()) + input.read(mData) + input.close() + parseCompressedXml() + } + + private fun parseCompressedXml() { + var word0: Int + + while (mParserOffset < mData.size) { + word0 = getLEWord(mParserOffset) + when (word0) { + WORD_START_DOCUMENT -> parseStartDocument() + WORD_STRING_TABLE -> parseStringTable() + WORD_RES_TABLE -> parseResourceTable() + WORD_START_NS -> parseNamespace(true) + WORD_END_NS -> parseNamespace(false) + WORD_START_TAG -> parseStartTag() + WORD_END_TAG -> parseEndTag() + WORD_TEXT -> parseText() + else -> mParserOffset += WORD_SIZE + } + } + } + + private fun parseStartDocument() { + mListener.startDocument() + mParserOffset += (2 * WORD_SIZE) + } + + private fun parseStringTable() { + val chunk = getLEWord(mParserOffset + (1 * WORD_SIZE)) + mStringsCount = getLEWord(mParserOffset + (2 * WORD_SIZE)) + mStylesCount = getLEWord(mParserOffset + (3 * WORD_SIZE)) + val strOffset = mParserOffset + getLEWord(mParserOffset + (5 * WORD_SIZE)) + mStringsTable = arrayOfNulls(mStringsCount) + var offset: Int + for (i in 0 until mStringsCount) { + offset = (strOffset + getLEWord(mParserOffset + ((i + 7) * WORD_SIZE))) + mStringsTable[i] = getStringFromStringTable(offset) + } + mParserOffset += chunk + } + + private fun parseResourceTable() { + val chunk = getLEWord(mParserOffset + (1 * WORD_SIZE)) + mResCount = (chunk / 4) - 2 + mResourcesIds = IntArray(mResCount) + for (i in 0 until mResCount) { + mResourcesIds[i] = getLEWord(mParserOffset + ((i + 2) * WORD_SIZE)) + } + mParserOffset += chunk + } + + private fun parseNamespace(start: Boolean) { + val prefixIdx = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val uriIdx = getLEWord(mParserOffset + (5 * WORD_SIZE)) + val uri = getString(uriIdx) + val prefix = getString(prefixIdx) + if (start) mNamespaces[uri] = prefix + else mNamespaces.remove(uri) + mParserOffset += (6 * WORD_SIZE) + } + + private fun parseStartTag() { + val uriIdx = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val nameIdx = getLEWord(mParserOffset + (5 * WORD_SIZE)) + val attrCount = getLEShort(mParserOffset + (7 * WORD_SIZE)) + val name = getString(nameIdx) + val (uri, qName) = if (uriIdx == -0x1) "" to name else { + val uri = getString(uriIdx) + uri to if (mNamespaces.containsKey(uri)) mNamespaces[uri] + ':' + name + else name + } + mParserOffset += (9 * WORD_SIZE) + val attrs = Array(attrCount) { + parseAttribute().also { mParserOffset += (5 * 4) } + } + mListener.startElement(uri, name, qName, attrs) + } + + private fun parseAttribute(): Attribute { + val attrNSIdx = getLEWord(mParserOffset) + val attrNameIdx = getLEWord(mParserOffset + (1 * WORD_SIZE)) + val attrValueIdx = getLEWord(mParserOffset + (2 * WORD_SIZE)) + val attrType = getLEWord(mParserOffset + (3 * WORD_SIZE)) + val attrData = getLEWord(mParserOffset + (4 * WORD_SIZE)) + + val attr = Attribute() + attr.name = getString(attrNameIdx) + + if (attrNSIdx == -0x1) { + attr.namespace = null + attr.prefix = null + } else { + val uri = getString(attrNSIdx) + if (mNamespaces.containsKey(uri)) { + attr.namespace = uri + attr.prefix = mNamespaces[uri] + } + } + attr.value = if (attrValueIdx == -0x1) getAttributeValue(attrType, attrData) + else getString(attrValueIdx) + + return attr + } + + private fun parseText() { + val strIndex = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val data = getString(strIndex) + mListener.characterData(data) + mParserOffset += (7 * WORD_SIZE) + } + + private fun parseEndTag() { + mListener.endElement() + mParserOffset += (6 * WORD_SIZE) + } + + private fun getString(index: Int): String? { + val res = if ((index >= 0) && (index < mStringsCount)) mStringsTable[index] else null + return res + } + + private fun getStringFromStringTable(offset: Int): String { + val strLength: Int + val chars: ByteArray + if (mData[offset + 1] == mData[offset]) { + strLength = mData[offset].toInt() + chars = ByteArray(strLength) + for (i in 0 until strLength) { + chars[i] = mData[offset + 2 + i] + } + } else { + strLength = + (((mData[offset + 1].toInt() shl 8) and 0xFF00) or (mData[offset].toInt() and 0xFF)) + chars = ByteArray(strLength) + for (i in 0 until strLength) { + chars[i] = mData[offset + 2 + (i * 2)] + } + } + return String(chars) + } + + private fun getLEWord(off: Int) = (((mData[off + 3].toInt() shl 24) and -0x1000000) + or ((mData[off + 2].toInt() shl 16) and 0x00ff0000) + or ((mData[off + 1].toInt() shl 8) and 0x0000ff00) + or ((mData[off + 0].toInt() shl 0) and 0x000000ff)) + + private fun getLEShort(off: Int) = + (((mData[off + 1].toInt() shl 8) and 0xff00) or ((mData[off + 0].toInt() shl 0) and 0x00ff)) + + private fun getAttributeValue(type: Int, data: Int) = when (type) { + TYPE_STRING -> getString(data) + TYPE_ID_REF -> String.format("@id/0x%08X", data) + TYPE_ATTR_REF -> String.format("?id/0x%08X", data) + else -> String.format("%08X/0x%08X", type, data) + } + + private val mNamespaces: MutableMap = HashMap() + private lateinit var mData: ByteArray + + private lateinit var mStringsTable: Array + private lateinit var mResourcesIds: IntArray + private var mStringsCount = 0 + private var mStylesCount = 0 + private var mResCount = 0 + private var mParserOffset = 0 + + companion object { + const val WORD_START_DOCUMENT: Int = 0x00080003 + + const val WORD_STRING_TABLE: Int = 0x001C0001 + const val WORD_RES_TABLE: Int = 0x00080180 + + const val WORD_START_NS: Int = 0x00100100 + const val WORD_END_NS: Int = 0x00100101 + const val WORD_START_TAG: Int = 0x00100102 + const val WORD_END_TAG: Int = 0x00100103 + const val WORD_TEXT: Int = 0x00100104 + const val WORD_SIZE: Int = 4 + + private const val TYPE_ID_REF = 0x01000008 + private const val TYPE_ATTR_REF = 0x02000008 + private const val TYPE_STRING = 0x03000008 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/CheckPermissions.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/CheckPermissions.kt deleted file mode 100644 index 583ca215..00000000 --- a/app/src/main/java/dev/brahmkshatriya/echo/utils/CheckPermissions.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.brahmkshatriya.echo.utils - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.Settings -import android.widget.Toast -import androidx.activity.result.ActivityResultCaller -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import dev.brahmkshatriya.echo.R - - -fun AppCompatActivity.checkAudioPermissions() { - val perm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - Manifest.permission.READ_MEDIA_AUDIO - else - Manifest.permission.READ_EXTERNAL_STORAGE - checkPermissions( - this, - perm, - R.string.permission_required, - R.string.music_permission_required_summary, - { finish() } - )?.launch(perm) -} - -fun ActivityResultCaller.checkPermissions( - context: Context, - perm: String, - title: Int, - message: Int, - onCancel: () -> Unit, - onGranted: () -> Unit = {}, - onRequest: () -> Unit = { - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - context.startActivity(this) - } - } -): ActivityResultLauncher? = with(context) { - val permStatus = ContextCompat.checkSelfPermission(this, perm) - val contract = ActivityResultContracts.RequestPermission() - return if (permStatus != PackageManager.PERMISSION_GRANTED) registerForActivityResult(contract) { - if (!it) AlertDialog.Builder(this) - .setTitle(getString(title)) - .setMessage(getString(message)) - .setPositiveButton(getString(R.string.ok)) { _, _ -> onRequest() } - .setNegativeButton(getString(R.string.cancel)) { _, _ -> - Toast.makeText( - this, getString(R.string.permission_denied), Toast.LENGTH_SHORT - ).show() - onCancel() - } - .show() - else onGranted() - } else null -} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/FlowUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/FlowUtils.kt index af74bcee..92fcfdf4 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/utils/FlowUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/FlowUtils.kt @@ -11,14 +11,12 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -fun Fragment.observe(flow: Flow, callback: suspend (T) -> Unit) { +fun Fragment.observe(flow: Flow, callback: suspend (T) -> Unit) = viewLifecycleOwner.observe(flow, callback) -} -fun LifecycleOwner.observe(flow: Flow, block: suspend (T) -> Unit) { - lifecycleScope.launch { - flow.flowWithLifecycle(lifecycle).collectLatest(block) - } + +fun LifecycleOwner.observe(flow: Flow, block: suspend (T) -> Unit) = lifecycleScope.launch { + flow.flowWithLifecycle(lifecycle).collectLatest(block) } fun LifecycleOwner.collect(flow: Flow, block: suspend (T) -> Unit) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/Permissions.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/Permissions.kt new file mode 100644 index 00000000..225648f0 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/Permissions.kt @@ -0,0 +1,88 @@ +package dev.brahmkshatriya.echo.utils + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import dev.brahmkshatriya.echo.R +import java.util.UUID + + +fun ComponentActivity.checkAudioPermissions() { + val perm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + Manifest.permission.READ_MEDIA_AUDIO + else + Manifest.permission.READ_EXTERNAL_STORAGE + checkPermissions( + this, + perm, + R.string.permission_required, + R.string.music_permission_required_summary, + { finish() } + )?.launch(perm) +} + +fun ComponentActivity.checkPermissions( + context: Context, + perm: String, + title: Int, + message: Int, + onCancel: () -> Unit, + onGranted: () -> Unit = {}, + onRequest: () -> Unit = { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + context.startActivity(this) + } + } +): ActivityResultLauncher? = with(context) { + val permStatus = ContextCompat.checkSelfPermission(this, perm) + val contract = ActivityResultContracts.RequestPermission() + return if (permStatus != PackageManager.PERMISSION_GRANTED) + registerActivityResultLauncher(contract) { + if (!it) AlertDialog.Builder(this) + .setTitle(getString(title)) + .setMessage(getString(message)) + .setPositiveButton(getString(R.string.ok)) { _, _ -> onRequest() } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> + Toast.makeText( + this, getString(R.string.permission_denied), Toast.LENGTH_SHORT + ).show() + onCancel() + } + .show() + else onGranted() + } else null +} + +fun ComponentActivity.registerActivityResultLauncher( + contract: ActivityResultContract, + block: (O) -> Unit +): ActivityResultLauncher { + val key = UUID.randomUUID().toString() + var launcher: ActivityResultLauncher? = null + val callback = ActivityResultCallback { + block.invoke(it) + launcher?.unregister() + } + + lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver { + override fun onDestroy(owner: androidx.lifecycle.LifecycleOwner) { + launcher?.unregister() + } + }) + + launcher = activityResultRegistry.register(key, contract, callback) + return launcher +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/SettingsUtils.kt similarity index 61% rename from app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt rename to app/src/main/java/dev/brahmkshatriya/echo/utils/SettingsUtils.kt index 52157396..73a7b8cc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/SettingsUtils.kt @@ -1,36 +1,11 @@ -package dev.brahmkshatriya.echo.extensions +package dev.brahmkshatriya.echo.utils import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit -import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.common.helpers.ExtensionType import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.common.settings.Settings -import kotlinx.coroutines.flow.StateFlow -import tel.jeelpa.plugger.utils.mapState - -inline fun StateFlow>>>>>.injectSettings( - type: ExtensionType, - context: Context -) = mapState { list -> - list.map { - runCatching { - val plugin = it.getOrThrow() - val metadata = plugin.first - Pair( - metadata, - lazy { - runCatching { - val instance = plugin.second.value.getOrThrow() - instance.setSettings(getSettings(context, type, metadata)) - instance - } - } - ) - } - } -} fun getSettings(context: Context, type: ExtensionType, metadata: Metadata): Settings { val name = "$type-${metadata.id}" diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt index d4af23ef..4db715da 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt @@ -1,7 +1,10 @@ package dev.brahmkshatriya.echo.viewmodels +import android.app.Application import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,9 +20,13 @@ import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.common.settings.Settings import dev.brahmkshatriya.echo.db.models.ExtensionEntity import dev.brahmkshatriya.echo.db.models.UserEntity +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader.Companion.priorityKey import dev.brahmkshatriya.echo.extensions.ExtensionLoader.Companion.setupMusicExtension import dev.brahmkshatriya.echo.extensions.get import dev.brahmkshatriya.echo.extensions.getExtension +import dev.brahmkshatriya.echo.extensions.installExtension +import dev.brahmkshatriya.echo.extensions.uninstallExtension import dev.brahmkshatriya.echo.ui.common.ClientLoadingAdapter import dev.brahmkshatriya.echo.ui.common.ClientNotSupportedAdapter import dev.brahmkshatriya.echo.ui.extension.ClientSelectionViewModel @@ -29,11 +36,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import tel.jeelpa.plugger.utils.mapState +import java.io.File import javax.inject.Inject @HiltViewModel class ExtensionViewModel @Inject constructor( throwableFlow: MutableSharedFlow, + val app: Application, + val extensionLoader: ExtensionLoader, + val messageFlow: MutableSharedFlow, val extensionListFlow: MutableStateFlow?>, val trackerListFlow: MutableStateFlow?>, val lyricsListFlow: MutableStateFlow?>, @@ -81,6 +92,42 @@ class ExtensionViewModel @Inject constructor( } } + suspend fun install(context: FragmentActivity, file: File, installAsApk: Boolean) { + val result = installExtension(context, file, installAsApk).getOrElse { + throwableFlow.emit(it) + false + } + if (result) messageFlow.emit(SnackBar.Message(app.getString(R.string.extension_installed_successfully))) + } + + suspend fun uninstall( + context: FragmentActivity, extension: Extension<*>, function: (Boolean) -> Unit + ) { + val result = uninstallExtension(context, extension).getOrElse { + throwableFlow.emit(it) + false + } + if (result) messageFlow.emit(SnackBar.Message(app.getString(R.string.extension_uninstalled_successfully))) + function(result) + } + + fun moveExtensionItem(type: ExtensionType, toPos: Int, fromPos: Int) { + settings.edit { + val flow = extensionLoader.priorityMap[type]!! + val list = flow.value.toMutableList() + list.add(toPos, list.removeAt(fromPos)) + flow.value = list + putString(type.priorityKey(), list.joinToString(",")) + } + } + + fun getExtensionListFlow(type: ExtensionType) = when (type) { + ExtensionType.MUSIC -> extensionListFlow + ExtensionType.TRACKER -> trackerListFlow + ExtensionType.LYRICS -> lyricsListFlow + } + + companion object { fun Context.noClient() = SnackBar.Message( getString(R.string.error_no_client) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt index bc01ab40..9fb03260 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt @@ -190,7 +190,10 @@ class PlayerViewModel @Inject constructor( val mediaItems = tracks.map { track -> MediaItemUtils.build(settings, track, clientId, context) } - val index = if (end) it.mediaItemCount else 1 + val index = if (end) it.mediaItemCount else { + val curr = currentFlow.value?.index ?: 0 + curr + 1 + } it.addMediaItems(index, mediaItems) it.prepare() } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/SnackBar.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/SnackBar.kt index 1c499cad..38d40516 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/SnackBar.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/SnackBar.kt @@ -7,6 +7,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.view.setMargins import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.activityViewModels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -19,18 +20,15 @@ import dev.brahmkshatriya.echo.ui.exception.openException import dev.brahmkshatriya.echo.ui.exception.openLoginException import dev.brahmkshatriya.echo.utils.observe import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SnackBar @Inject constructor( - mutableThrowableFlow: MutableSharedFlow, + val throwableFlow: MutableSharedFlow, val mutableMessageFlow: MutableSharedFlow ) : ViewModel() { - val throwableFlow = mutableThrowableFlow.asSharedFlow() - data class Message( val message: String, val action: Action? = null @@ -113,5 +111,14 @@ class SnackBar @Inject constructor( fun Fragment.createSnack(message: Int) { createSnack(getString(message)) } + + fun FragmentActivity.createSnack(message: Message) { + val viewModel by viewModels() + viewModel.create(message) + } + + fun FragmentActivity.createSnack(message: String) { + createSnack(Message(message)) + } } } \ No newline at end of file diff --git a/app/src/main/res/drawable/anim_now_playing.xml b/app/src/main/res/drawable/anim_now_playing.xml new file mode 100644 index 00000000..395aa8f8 --- /dev/null +++ b/app/src/main/res/drawable/anim_now_playing.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml index 61231e8e..ba8815fd 100644 --- a/app/src/main/res/drawable/ic_delete.xml +++ b/app/src/main/res/drawable/ic_delete.xml @@ -1,6 +1,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +