diff --git a/README.md b/README.md index fb285631..93468dda 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,7 @@ A Spotify songs downloader powered by [spotDL](https://github.com/spotDL/spotify ## ⚠️ Warning -The Spotify mods downloader has been deleted by request of the xManager team. This is because having the mod downloader in Spowlo meant an avoid of their ads/earning methods. xManager has to pay servers and they pay those just for making the users have free Spotify, I hope that you all understand. - -please, instead use the [xManager app](https://github.com/xManager-App/xManager). Maybe somme day I create an app for them who knows haha +Spowlo uses YT Music and YouTube to download the songs. This is because Spotify DRM bypassing can lead to an account ban and legal issues. If YT Music isn't available in your country, don't worry, you can still use YouTube as audio provider or use a VPN. We are working on making a regional bypass so don't matter your region. Thank you for understanding. ## 🔮 Features @@ -51,6 +49,11 @@ For most devices, it is recommended to install the **ARM64-v8a** version of the - Download the latest stable version from [GitHub releases](https://github.com/BobbyESP/Spowlo/releases/latest) +## Translation + +We are using Hosted Weblate for the translations of the app. if you want to contribute follow [this link](https://hosted.weblate.org/engage/spowlo/) 🖇️ + + ## 📖Credits Thanks to [xnetcat](https://github.com/xnetcat) for it's help with some spotDL related things! diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 95fe5fb9..f92df50f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,8 +43,8 @@ sealed class Version( val currentVersion: Version = Version.Stable( versionMajor = 1, - versionMinor = 2, - versionPatch = 1, + versionMinor = 3, + versionPatch = 0, ) val keystorePropertiesFile = rootProject.file("keystore.properties") @@ -71,7 +71,7 @@ android { applicationId = "com.bobbyesp.spowlo" minSdk = 26 targetSdk = 33 - versionCode = 10201 + versionCode = 10300 versionName = currentVersion.toVersionName().run { if (!splitApks) "$this-(F-Droid)" @@ -110,18 +110,42 @@ android { proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + packagingOptions { + resources.excludes.add("META-INF/*.kotlin_module") + } if (keystorePropertiesFile.exists()) signingConfig = signingConfigs.getByName("debug") + //add client id and secret to build config + buildConfigField("String", "CLIENT_ID", "\"${project.properties["CLIENT_ID"]}\"") + buildConfigField( + "String", + "CLIENT_SECRET", + "\"${project.properties["CLIENT_SECRET"]}\"" + ) + matchingFallbacks.add(0, "debug") + matchingFallbacks.add(1, "release") } debug { if (keystorePropertiesFile.exists()) signingConfig = signingConfigs.getByName("debug") + packagingOptions { + resources.excludes.add("META-INF/*.kotlin_module") + } + buildConfigField("String", "CLIENT_ID", "\"${project.properties["CLIENT_ID"]}\"") + buildConfigField( + "String", + "CLIENT_SECRET", + "\"${project.properties["CLIENT_SECRET"]}\"" + ) + matchingFallbacks.add(0, "debug") + matchingFallbacks.add(1, "release") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + kotlinOptions { jvmTarget = "1.8" } @@ -183,10 +207,11 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.accompanist.navigation.animation) implementation(libs.accompanist.webview) - implementation(libs.accompanist.pager.layouts) - implementation(libs.accompanist.pager.indicators) implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.material) + implementation(libs.accompanist.pager.indicators) + implementation(libs.paging.compose) + implementation(libs.paging.runtime) implementation(libs.coil.kt.compose) @@ -214,13 +239,14 @@ dependencies { implementation(libs.markdown) //Exoplayer - implementation(libs.exoplayer.core) - implementation(libs.exoplayer.ui) - implementation(libs.exoplayer.dash) - implementation(libs.exoplayer.smoothstreaming) - implementation(libs.exoplayer.extension.mediasession) +// implementation(libs.exoplayer.core) +// implementation(libs.exoplayer.ui) +// implementation(libs.exoplayer.dash) +// implementation(libs.exoplayer.smoothstreaming) +// implementation(libs.exoplayer.extension.mediasession) implementation(libs.customtabs) + // implementation(libs.shimmer) debugImplementation(libs.crash.handler) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3737b598..95ba6492 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -92,6 +92,10 @@ android:value="true" /> + + + \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/App.kt b/app/src/main/java/com/bobbyesp/spowlo/App.kt index 5ad52d2a..f35eec68 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/App.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/App.kt @@ -4,21 +4,27 @@ import android.annotation.SuppressLint import android.app.Application import android.content.ClipData import android.content.ClipboardManager +import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.ServiceConnection import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.net.ConnectivityManager import android.os.Build import android.os.Environment +import android.os.IBinder import android.os.Looper import androidx.core.content.getSystemService import com.bobbyesp.ffmpeg.FFmpeg import com.bobbyesp.library.SpotDL import com.bobbyesp.spowlo.utils.AUDIO_DIRECTORY import com.bobbyesp.spowlo.utils.DownloaderUtil +import com.bobbyesp.spowlo.utils.EXTRA_DIRECTORY import com.bobbyesp.spowlo.utils.FilesUtil import com.bobbyesp.spowlo.utils.FilesUtil.createEmptyFile import com.bobbyesp.spowlo.utils.FilesUtil.getCookiesFile +import com.bobbyesp.spowlo.utils.NotificationsUtil import com.bobbyesp.spowlo.utils.PreferencesUtil import com.bobbyesp.spowlo.utils.PreferencesUtil.getString import com.bobbyesp.spowlo.utils.ToastUtil @@ -69,12 +75,17 @@ class App : Application() { getString(R.string.app_name) ).absolutePath ) + extraDownloadDir = EXTRA_DIRECTORY.getString( + "" + ) + if (Build.VERSION.SDK_INT >= 26) NotificationsUtil.createNotificationChannel() } companion object { private const val PRIVATE_DIRECTORY_SUFFIX = ".Spowlo" lateinit var clipboard: ClipboardManager lateinit var audioDownloadDir: String + lateinit var extraDownloadDir: String lateinit var applicationScope: CoroutineScope lateinit var connectivityManager: ConnectivityManager lateinit var packageInfo: PackageInfo @@ -83,36 +94,37 @@ class App : Application() { const val userAgentHeader = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Mobile Safari/537.36 Edg/105.0.1343.53" - /* var isServiceRunning = false - - private val connection = object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - val binder = service as DownloadService.DownloadServiceBinder - isServiceRunning = true - } - - override fun onServiceDisconnected(arg0: ComponentName) { - } - } - - fun startService() { - if (isServiceRunning) return - Intent(context.applicationContext, DownloadService::class.java).also { intent -> - context.applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) - } - } - - fun stopService() { - if (!isServiceRunning) return - try { - isServiceRunning = false - context.applicationContext.run { - unbindService(connection) - } - } catch (e: Exception) { - e.printStackTrace() - } - }*/ + var isServiceRunning = false + + private val connection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as DownloaderKeepUpService.DownloadServiceBinder + isServiceRunning = true + } + + override fun onServiceDisconnected(arg0: ComponentName) { + + } + } + + fun startService() { + if (isServiceRunning) return + Intent(context.applicationContext, DownloaderKeepUpService::class.java).also { intent -> + context.applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + } + + fun stopService() { + if (!isServiceRunning) return + try { + isServiceRunning = false + context.applicationContext.run { + unbindService(connection) + } + } catch (e: Exception) { + e.printStackTrace() + } + } fun getPrivateDownloadDirectory(): String = diff --git a/app/src/main/java/com/bobbyesp/spowlo/Downloader.kt b/app/src/main/java/com/bobbyesp/spowlo/Downloader.kt index 0f825145..f4ba6592 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/Downloader.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/Downloader.kt @@ -1,21 +1,30 @@ package com.bobbyesp.spowlo +import android.app.PendingIntent import android.util.Log import androidx.annotation.CheckResult +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.text.AnnotatedString import com.bobbyesp.library.SpotDL import com.bobbyesp.library.dto.Song import com.bobbyesp.spowlo.App.Companion.applicationScope import com.bobbyesp.spowlo.App.Companion.context +import com.bobbyesp.spowlo.App.Companion.startService +import com.bobbyesp.spowlo.App.Companion.stopService +import com.bobbyesp.spowlo.ui.common.containsEllipsis import com.bobbyesp.spowlo.utils.DownloaderUtil import com.bobbyesp.spowlo.utils.FilesUtil +import com.bobbyesp.spowlo.utils.NotificationsUtil import com.bobbyesp.spowlo.utils.ToastUtil import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.UUID object Downloader { @@ -30,6 +39,9 @@ object Downloader { object Idle : State() } + fun makeKey(url: String, additionalString: String = UUID.randomUUID().toString()): String = + "${additionalString}_$url" + data class ErrorState( val errorReport: String = "", val errorMessageResId: Int = R.string.unknown_error, @@ -38,6 +50,73 @@ object Downloader { errorMessageResId != R.string.unknown_error || errorReport.isNotEmpty() } + data class DownloadTask( + val url: String, + val consoleOutput: String, + val state: State, + val currentLine: String, + val taskName: String, + ) { + fun toKey() = makeKey(url, url.reversed()) + sealed class State { + data class Error(val errorReport: String) : State() + object Completed : State() + object Canceled : State() + data class Running(val progress: Float) : State() + } + + override fun hashCode(): Int { + return (this.url + this.url.reversed()).hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DownloadTask + + if (url != other.url) return false + if (consoleOutput != other.consoleOutput) return false + if (state != other.state) return false + if (currentLine != other.currentLine) return false + + return true + } + + fun onCopyLog(clipboardManager: androidx.compose.ui.platform.ClipboardManager) { + clipboardManager.setText(AnnotatedString(consoleOutput)) + ToastUtil.makeToastSuspend(context.getString(R.string.log_copied)) + } + + fun onCopyUrl(clipboardManager: androidx.compose.ui.platform.ClipboardManager) { + clipboardManager.setText(AnnotatedString(url)) + ToastUtil.makeToastSuspend(context.getString(R.string.link_copied)) + } + + + fun onRestart() { + applicationScope.launch(Dispatchers.IO) { + executeParallelDownloadWithUrl(url, name = taskName) + } + } + + + fun onCopyError(clipboardManager: androidx.compose.ui.platform.ClipboardManager) { + clipboardManager.setText(AnnotatedString(currentLine)) + ToastUtil.makeToast(R.string.error_copied) + } + + fun onCancel() { + toKey().run { + SpotDL.getInstance().destroyProcessById(this, true) + onProcessCanceled(this) + } + } + + } + + //---------------------------- + data class DownloadTaskItem( val info: Song = Song(), val spotifyUrl: String = "", @@ -46,16 +125,14 @@ object Downloader { val duration: Double = 0.0, val isExplicit: Boolean = false, val hasLyrics: Boolean = false, - // val fileSizeApprox: Long = 0, val progress: Float = 0f, val progressText: String = "", val thumbnailUrl: String = "", val taskId: String = "", val output: String = "", - // val playlistIndex: Int = 0, ) - private fun Song.toTask(playlistIndex: Int = 0, preferencesHash: Int): DownloadTaskItem = + private fun Song.toTask(preferencesHash: Int): DownloadTaskItem = DownloadTaskItem( info = this, spotifyUrl = this.url, @@ -64,12 +141,10 @@ object Downloader { duration = this.duration, isExplicit = this.explicit, hasLyrics = this.lyrics.isNullOrEmpty(), - // fileSizeApprox = this.fileSizeApprox, progress = 0f, progressText = "", thumbnailUrl = this.cover_url, - taskId = this.song_id + preferencesHash + playlistIndex, - // playlistIndex = playlistIndex, + taskId = this.song_id + preferencesHash, ) private var currentJob: Job? = null @@ -91,10 +166,146 @@ object Downloader { private val mutableProcessCount = MutableStateFlow(0) private val processCount = mutableProcessCount.asStateFlow() + private val mutableQuickDownloadCount = MutableStateFlow(0) + + + //------------------------------------- + + val mutableTaskList = mutableStateMapOf() + + init { + applicationScope.launch { + downloaderState.combine(processCount) { state, cnt -> + if (cnt > 0) true + else when (state) { + is State.Idle -> false + else -> true + } + }.combine(mutableQuickDownloadCount) { isRunning, cnt -> + if (!isRunning) cnt > 0 else true + }.collect { + if (it) startService() + else stopService() + } + + } + } + + fun onTaskStarted(url: String, name: String) = + DownloadTask( + url = url, + consoleOutput = "", + state = DownloadTask.State.Running(0f), + currentLine = "", + taskName = name + ).run { + mutableTaskList.put(this.toKey(), this) + + val key = makeKey(url, url.reversed()) + NotificationsUtil.notifyProgress( + name + " - " + context.getString(R.string.parallel_download), + notificationId = key.toNotificationId(), + progress = (state as DownloadTask.State.Running).progress.toInt(), + text = currentLine + ) + } + + fun updateTaskOutput(url: String, line: String, progress: Float, isPlaylist: Boolean = false) { + val key = makeKey(url, url.reversed()) + val oldValue = mutableTaskList[key] ?: return + val newValue = oldValue.run { + if (currentLine == line || line.containsEllipsis() || consoleOutput.contains(line)) return + when(isPlaylist) { + true -> { + copy( + consoleOutput = consoleOutput + line + "\n", + currentLine = line, + state = DownloadTask.State.Running( + if (line.contains("Total")) { + getProgress(line) + } else { + (state as DownloadTask.State.Running).progress + } + ) + ) + + } + false -> { + copy( + consoleOutput = consoleOutput + line + "\n", + currentLine = line, + state = DownloadTask.State.Running(progress) + ) + } + } + } + mutableTaskList[key] = newValue + } + + private fun getProgress(line: String): Float{ + val PERCENT: Float + //Get the two numbers before an % in the line + val regex = Regex("(\\d+)%") + val matchResult = regex.find(line) + //Log the result + ///if (BuildConfig.DEBUG) Log.d(TAG, "Progress: ${matchResult?.groupValues?.get(1)?.toFloat() ?: 0f}") + PERCENT = matchResult?.groupValues?.get(1)?.toFloat() ?: 0f + //divide percent by 100 to get a value between 0 and 1 + return PERCENT / 100f + } + + fun onTaskEnded( + url: String, + response: String? = null, + notificationTitle : String? = null + ) { + val key = makeKey(url, url.reversed()) + NotificationsUtil.finishNotification( + notificationId = key.toNotificationId(), + title = notificationTitle, + text = context.getString(R.string.status_completed), + ) + mutableTaskList.run { + val oldValue = get(key) ?: return + val newValue = oldValue.copy(state = DownloadTask.State.Completed).run { + response?.let { copy(consoleOutput = response) } ?: this + } + this[key] = newValue + } + FilesUtil.scanDownloadDirectoryToMediaLibrary(App.audioDownloadDir) + } + + fun onTaskError(errorReport: String, url: String) = + mutableTaskList.run { + val key = makeKey(url, url.reversed()) + NotificationsUtil.makeErrorReportNotification( + notificationId = key.toNotificationId(), + error = errorReport + ) + val oldValue = mutableTaskList[key] ?: return + mutableTaskList[key] = oldValue.copy( + state = DownloadTask.State.Error( + errorReport + ), + currentLine = errorReport, + consoleOutput = oldValue.consoleOutput + "\n" + errorReport + ) + } + fun onProcessEnded() = mutableProcessCount.update { it - 1 } + fun onProcessCanceled(taskId: String) = + mutableTaskList.run { + get(taskId)?.let { + this.put( + taskId, + it.copy(state = DownloadTask.State.Canceled) + ) + } + } + fun isDownloaderAvailable(): Boolean { if (downloaderState.value !is State.Idle) { ToastUtil.makeToastSuspend(context.getString(R.string.task_running)) @@ -104,7 +315,7 @@ object Downloader { } @CheckResult - private suspend fun downloadSong( + private fun downloadSong( songInfo: Song, preferences: DownloaderUtil.DownloadPreferences = DownloaderUtil.DownloadPreferences() ): Result> { @@ -112,12 +323,10 @@ object Downloader { val isDownloadingPlaylist = downloaderState.value is State.DownloadingPlaylist mutableTaskState.update { songInfo.toTask(preferencesHash = preferences.hashCode()) } - + val notificationId = preferences.hashCode() + songInfo.song_id.getNumbers() if (!isDownloadingPlaylist) updateState(State.DownloadingSong) return DownloaderUtil.downloadSong( songInfo = songInfo, - playlistUrl = "", - playlistItem = 0, downloadPreferences = preferences, taskId = songInfo.song_id + preferences.hashCode() ) { progress, _, line -> @@ -125,19 +334,21 @@ object Downloader { mutableTaskState.update { it.copy(progress = progress, progressText = line) } - /*NotificationUtil.notifyProgress( + + NotificationsUtil.notifyProgress( notificationId = notificationId, progress = progress.toInt(), text = line, - title = videoInfo.title - )*/ + title = songInfo.name + ) }.onFailure { + Log.d("Downloader", "$it") if (it is SpotDL.CanceledException) return@onFailure Log.d("Downloader", "The download has been canceled (app thread)") manageDownloadError( it, false, - //notificationId = notificationId, + notificationId = notificationId, isTaskAborted = !isDownloadingPlaylist ) }.onSuccess { @@ -145,9 +356,9 @@ object Downloader { val text = context.getString(if (it.isEmpty()) R.string.status_completed else R.string.download_finish_notification) FilesUtil.createIntentForOpeningFile(it.firstOrNull()).run { - /* NotificationUtil.finishNotification( + NotificationsUtil.finishNotification( notificationId, - title = videoInfo.title, + title = songInfo.name, text = text, intent = if (this != null) PendingIntent.getActivity( context, @@ -155,36 +366,51 @@ object Downloader { this, PendingIntent.FLAG_IMMUTABLE ) else null - )*/ + ) } } } fun getInfoAndDownload( url: String, - downloadPreferences: DownloaderUtil.DownloadPreferences = DownloaderUtil.DownloadPreferences() + downloadPreferences: DownloaderUtil.DownloadPreferences = DownloaderUtil.DownloadPreferences(), + skipInfoFetch: Boolean = false ) { currentJob = applicationScope.launch(Dispatchers.IO) { updateState(State.FetchingInfo) - DownloaderUtil.fetchSongInfoFromUrl( - url = url, - preferences = downloadPreferences - ) - .onFailure { + if (skipInfoFetch) { + downloadResultTemp = downloadSong( + songInfo = Song(url = url), + preferences = downloadPreferences + ).onFailure { manageDownloadError( it, isFetchingInfo = true, isTaskAborted = true ) } - .onSuccess { info -> - for (song in info) { - downloadResultTemp = downloadSong( - songInfo = song, - preferences = downloadPreferences + return@launch + } else { + DownloaderUtil.fetchSongInfoFromUrl( + url = url + ) + .onFailure { + manageDownloadError( + it, + isFetchingInfo = true, + isTaskAborted = true ) + return@launch } - } + .onSuccess { info -> + for (song in info) { + downloadResultTemp = downloadSong( + songInfo = song, + preferences = downloadPreferences + ) + } + } + } } } @@ -195,10 +421,8 @@ object Downloader { currentJob = applicationScope.launch(Dispatchers.IO) { updateState(State.FetchingInfo) DownloaderUtil.fetchSongInfoFromUrl( - url = url, - preferences = downloadPreferences - ) - .onFailure { + url = url + ).onFailure { manageDownloadError( it, isFetchingInfo = true, @@ -207,13 +431,17 @@ object Downloader { } .onSuccess { info -> DownloaderUtil.updateSongsState(info) - mutableTaskState.update { DownloaderUtil.songsState.value[0].toTask(preferencesHash = downloadPreferences.hashCode()) } + mutableTaskState.update { + DownloaderUtil.songsState.value[0].toTask( + preferencesHash = downloadPreferences.hashCode() + ) + } finishProcessing() } } } - fun updateState(state: State) = mutableDownloaderState.update { state } + private fun updateState(state: State) = mutableDownloaderState.update { state } fun clearErrorState() { mutableErrorState.update { ErrorState() } @@ -248,7 +476,7 @@ object Downloader { /** * @param isTaskAborted Determines if the download task is aborted due to the given `Exception` */ - fun manageDownloadError( + private fun manageDownloadError( th: Throwable, isFetchingInfo: Boolean, isTaskAborted: Boolean = true, @@ -265,11 +493,11 @@ object Downloader { errorReport = th.message.toString() ) } - notificationId?.let {/* - NotificationUtil.finishNotification( + notificationId?.let { + NotificationsUtil.finishNotification( notificationId = notificationId, text = context.getString(R.string.download_error_msg), - )*/ + ) } if (isTaskAborted) { updateState(State.Idle) @@ -284,9 +512,29 @@ object Downloader { updateState(State.Idle) clearProgressState(isFinished = false) taskState.value.taskId.run { - SpotDL.getInstance().destroyProcessById(this) - //NotificationUtil.cancelNotification(this.toNotificationId()) + SpotDL.getInstance().destroyProcessById(this, true) + NotificationsUtil.cancelNotification(this.toNotificationId()) } } + fun executeParallelDownloadWithUrl(url: String, name: String) = + applicationScope.launch(Dispatchers.IO) { + DownloaderUtil.executeParallelDownload( + url, name + ) + } + + fun onProcessStarted() = mutableProcessCount.update { it + 1 } + fun String.toNotificationId(): Int = this.hashCode() + + //get just the numbers from a string and return an int + fun String.getNumbers(): Int { + val sb = StringBuilder() + for (c in this) { + if (c.isDigit()) { + sb.append(c) + } + } + return sb.toString().toInt() + } } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/DownloaderKeepUpService.kt b/app/src/main/java/com/bobbyesp/spowlo/DownloaderKeepUpService.kt new file mode 100644 index 00000000..8d33e98b --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/DownloaderKeepUpService.kt @@ -0,0 +1,43 @@ +package com.bobbyesp.spowlo + +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.util.Log +import com.bobbyesp.spowlo.utils.NotificationsUtil +import com.bobbyesp.spowlo.utils.NotificationsUtil.SERVICE_NOTIFICATION_ID + +private val TAG = DownloaderKeepUpService::class.java.simpleName +class DownloaderKeepUpService: Service() { + override fun onBind(intent: Intent): IBinder { + val pendingIntent: PendingIntent = + Intent(this, MainActivity::class.java).let { notificationIntent -> + PendingIntent.getActivity( + this, 0, notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ) + } + val notification = NotificationsUtil.makeServiceNotification(pendingIntent) + startForeground(SERVICE_NOTIFICATION_ID, notification) + return DownloadServiceBinder() + } + + + override fun onUnbind(intent: Intent?): Boolean { + Log.d(TAG, "onUnbind: ") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + stopForeground(true) + } + stopSelf() + return super.onUnbind(intent) + } + + inner class DownloadServiceBinder : Binder() { + fun getService(): DownloaderKeepUpService = this@DownloaderKeepUpService + } +} diff --git a/app/src/main/java/com/bobbyesp/spowlo/MainActivity.kt b/app/src/main/java/com/bobbyesp/spowlo/MainActivity.kt index f0b5f37a..65c02aa1 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/MainActivity.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/MainActivity.kt @@ -8,33 +8,13 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.Home -import androidx.compose.material.icons.rounded.LibraryMusic -import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.FileDownloadDone import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -45,6 +25,7 @@ import com.bobbyesp.spowlo.ui.common.Route import com.bobbyesp.spowlo.ui.common.SettingsProvider import com.bobbyesp.spowlo.ui.pages.InitialEntry import com.bobbyesp.spowlo.ui.pages.downloader.DownloaderViewModel +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.playlists.PlaylistPageViewModel import com.bobbyesp.spowlo.ui.pages.mod_downloader.ModsDownloaderViewModel import com.bobbyesp.spowlo.ui.theme.SpowloTheme import com.bobbyesp.spowlo.utils.PreferencesUtil @@ -58,6 +39,8 @@ import kotlinx.coroutines.runBlocking class MainActivity : AppCompatActivity() { private val downloaderViewModel: DownloaderViewModel by viewModels() private val modsDownloaderViewModel: ModsDownloaderViewModel by viewModels() + private val playlistPageViewModel: PlaylistPageViewModel by viewModels() + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -67,10 +50,9 @@ class MainActivity : AppCompatActivity() { insets } runBlocking { - if (Build.VERSION.SDK_INT < 33) - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags(PreferencesUtil.getLanguageConfiguration()) - ) + if (Build.VERSION.SDK_INT < 33) AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(PreferencesUtil.getLanguageConfiguration()) + ) } context = this.baseContext setContent { @@ -86,6 +68,7 @@ class MainActivity : AppCompatActivity() { InitialEntry( downloaderViewModel = downloaderViewModel, modsDownloaderViewModel = modsDownloaderViewModel, + playlistPageViewModel = playlistPageViewModel, isUrlShared = isUrlSharingTriggered ) } @@ -94,7 +77,8 @@ class MainActivity : AppCompatActivity() { handleShareIntent(intent) } - //This function is very important. It handles the intent of opening the app from a shared link and put it in the url field + //This function is very important. + //It handles the intent of opening the app from a shared link and put it in the url field override fun onNewIntent(intent: Intent) { handleShareIntent(intent) super.onNewIntent(intent) @@ -112,11 +96,9 @@ class MainActivity : AppCompatActivity() { } Intent.ACTION_SEND -> { - intent.getStringExtra(Intent.EXTRA_TEXT) - ?.let { sharedContent -> + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { sharedContent -> intent.removeExtra(Intent.EXTRA_TEXT) - matchUrlFromSharedText(sharedContent) - .let { matchedUrl -> + matchUrlFromSharedText(sharedContent).let { matchedUrl -> if (sharedUrl != matchedUrl) { sharedUrl = matchedUrl downloaderViewModel.updateUrl(sharedUrl, true) @@ -133,18 +115,17 @@ class MainActivity : AppCompatActivity() { fun setLanguage(locale: String) { Log.d(TAG, "setLanguage: $locale") - val localeListCompat = - if (locale.isEmpty()) LocaleListCompat.getEmptyLocaleList() - else LocaleListCompat.forLanguageTags(locale) + val localeListCompat = if (locale.isEmpty()) LocaleListCompat.getEmptyLocaleList() + else LocaleListCompat.forLanguageTags(locale) App.applicationScope.launch(Dispatchers.Main) { AppCompatDelegate.setApplicationLocales(localeListCompat) } } val showInBottomNavigation = mapOf( - Route.HOME to Icons.Rounded.Download, - Route.SEARCHER to Icons.Rounded.Search, - Route.MEDIA_PLAYER to Icons.Rounded.MusicNote + Route.DownloaderNavi to Icons.Rounded.Download, + Route.SearcherNavi to Icons.Rounded.Search, + Route.DownloadTasksNavi to Icons.Rounded.FileDownloadDone, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/NotificationActionReceiver.kt b/app/src/main/java/com/bobbyesp/spowlo/NotificationActionReceiver.kt new file mode 100644 index 00000000..4caa29fb --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/NotificationActionReceiver.kt @@ -0,0 +1,67 @@ +package com.bobbyesp.spowlo + +import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.util.Log +import com.bobbyesp.library.SpotDL +import com.bobbyesp.spowlo.App.Companion.context +import com.bobbyesp.spowlo.utils.NotificationsUtil +import com.bobbyesp.spowlo.utils.ToastUtil + +class NotificationActionReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "CancelReceiver" + private const val PACKAGE_NAME_PREFIX = "com.bobbyesp.spowlo." + + const val ACTION_CANCEL_TASK = 0 + const val ACTION_ERROR_REPORT = 1 + + const val ACTION_KEY = PACKAGE_NAME_PREFIX + "action" + const val TASK_ID_KEY = PACKAGE_NAME_PREFIX + "taskId" + + const val NOTIFICATION_ID_KEY = PACKAGE_NAME_PREFIX + "notificationId" + const val ERROR_REPORT_KEY = PACKAGE_NAME_PREFIX + "error_report" + } + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null) return + val notificationId = intent.getIntExtra(NOTIFICATION_ID_KEY, 0) + val action = intent.getIntExtra(ACTION_KEY, ACTION_CANCEL_TASK) + Log.d(TAG, "onReceive: $action") + when (action) { + ACTION_CANCEL_TASK -> { + val taskId = intent.getStringExtra(TASK_ID_KEY) + cancelTask(taskId, notificationId) + } + + ACTION_ERROR_REPORT -> { + val errorReport = intent.getStringExtra(ERROR_REPORT_KEY) + if (!errorReport.isNullOrEmpty()) + copyErrorReport(errorReport, notificationId) + } + } + } + + private fun cancelTask(taskId: String?, notificationId: Int) { + if (taskId.isNullOrEmpty()) return + NotificationsUtil.cancelNotification(notificationId) + val result = SpotDL.getInstance().destroyProcessById(taskId, true) + NotificationsUtil.cancelNotification(notificationId) + if (result) { + Log.d(TAG, "Task (id:$taskId) was killed.") + Downloader.onProcessCanceled(taskId) + + } + } + + private fun copyErrorReport(error: String, notificationId: Int) { + App.clipboard.setPrimaryClip( + ClipData.newPlainText(null, error) + ) + context.let { ToastUtil.makeToastSuspend(it.getString(R.string.error_copied)) } + NotificationsUtil.cancelNotification(notificationId) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/features/mod_downloader/data/remote/ModsDownloaderAPI.kt b/app/src/main/java/com/bobbyesp/spowlo/features/mod_downloader/data/remote/ModsDownloaderAPI.kt index 629b732c..7e810b3e 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/features/mod_downloader/data/remote/ModsDownloaderAPI.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/features/mod_downloader/data/remote/ModsDownloaderAPI.kt @@ -20,6 +20,7 @@ import okhttp3.Response import okio.IOException import java.io.File import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine object ModsDownloaderAPI { @@ -32,48 +33,38 @@ object ModsDownloaderAPI { private val client = OkHttpClient() const val TAG = "APKsDownloaderAPI" - private val requestAPIResponse = - Request.Builder() - .url(BASE_URL + ENDPOINT) - .build() + private val requestAPIResponse = Request.Builder().url(BASE_URL + ENDPOINT).build() @CheckResult - private suspend fun getAPIResponse(): Result{ - return suspendCoroutine { + suspend fun getAPIResponse(): Result { + return suspendCoroutine { continuation -> client.newCall(requestAPIResponse).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - it.resumeWith(Result.failure(e)) - } - - override fun onResponse(call: Call, response: Response) { - val responseData = response.body.string() - val apiResponse = jsonFormat.decodeFromString(APIResponseDto.serializer(), responseData) - response.body.close() - it.resume(Result.success(apiResponse)) - } - }) + override fun onResponse(call: Call, response: Response) { + val responseData = response.body.string() + val apiResponse = + jsonFormat.decodeFromString(APIResponseDto.serializer(), responseData) + response.body.close() + continuation.resume(Result.success(apiResponse)) + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + }) } } - suspend fun callModsAPI(): Result { - return getAPIResponse() - } - - private fun Context.getSpotifyAPK() = - File(getExternalFilesDir("apk"), "Spotify_Spowlo_Mod.apk") + private fun Context.getSpotifyAPK() = File(getExternalFilesDir("apk"), "Spotify_Spowlo_Mod.apk") suspend fun downloadPackage( - context: Context = App.context, - apiResponseDto: APIResponseDto, - listName: String, - index: Int - ): Flow { - withContext(Dispatchers.IO){ + context: Context = App.context, apiResponseDto: APIResponseDto, listName: String, index: Int + ): Flow { + withContext(Dispatchers.IO) { var selectedList = emptyList() - when(listName){ + when (listName) { "Regular" -> selectedList = apiResponseDto.apps.Regular "Amoled" -> selectedList = apiResponseDto.apps.AMOLED "Regular_Cloned" -> selectedList = apiResponseDto.apps.Regular_Cloned @@ -83,9 +74,7 @@ object ModsDownloaderAPI { val file = context.getSpotifyAPK() - val request = Request.Builder() - .url(selectedList[index].link) - .build() + val request = Request.Builder().url(selectedList[index].link).build() try { val response = client.newCall(request).execute() diff --git a/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/paging/SpotifyApiMediator.kt b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/paging/SpotifyApiMediator.kt new file mode 100644 index 00000000..0ad8766a --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/paging/SpotifyApiMediator.kt @@ -0,0 +1 @@ +package com.bobbyesp.spowlo.features.spotify_api.data.paging diff --git a/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/remote/SpotifyApiRequests.kt b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/remote/SpotifyApiRequests.kt new file mode 100644 index 00000000..ecdd346b --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/remote/SpotifyApiRequests.kt @@ -0,0 +1,195 @@ +package com.bobbyesp.spowlo.features.spotify_api.data.remote + +import android.util.Log +import com.adamratzman.spotify.SpotifyAppApi +import com.adamratzman.spotify.models.Album +import com.adamratzman.spotify.models.Artist +import com.adamratzman.spotify.models.AudioFeatures +import com.adamratzman.spotify.models.Playlist +import com.adamratzman.spotify.models.SpotifyPublicUser +import com.adamratzman.spotify.models.SpotifySearchResult +import com.adamratzman.spotify.models.Token +import com.adamratzman.spotify.models.Track +import com.adamratzman.spotify.spotifyAppApi +import com.adamratzman.spotify.utils.Market +import com.bobbyesp.spowlo.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Job +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SpotifyApiRequests { + + private const val clientId = BuildConfig.CLIENT_ID + private const val clientSecret = BuildConfig.CLIENT_SECRET + private var api: SpotifyAppApi? = null + private var token: Token? = null + + private var currentJob: Job? = null + + + //Pulls the clientId and clientSecret tokens and builds them into an object + + @Provides + @Singleton + suspend fun provideSpotifyApi(): SpotifyAppApi { + if (api == null) { + buildApi() + } + return api!! + } + + suspend fun buildApi() { + Log.d( + "SpotifyApiRequests", + "Building API with client ID: $clientId and client secret: $clientSecret" + ) + token = spotifyAppApi(clientId, clientSecret).build().token + api = spotifyAppApi(clientId, clientSecret, token!!) { + automaticRefresh = true + }.build() + } + + //Performs Spotify database query for queries related to user information. + private suspend fun userSearch(userQuery: String): SpotifyPublicUser? { + return provideSpotifyApi().users.getProfile(userQuery) + } + + @Provides + @Singleton + suspend fun provideUserSearch(query: String): SpotifyPublicUser? { + return userSearch("bobbyesp") + } + + // Performs Spotify database query for queries related to track information. + suspend fun searchAllTypes(searchQuery: String): SpotifySearchResult { + kotlin.runCatching { + provideSpotifyApi().search.searchAllTypes(searchQuery, limit = 50, offset = 1, market = Market.ES) + }.onFailure { + Log.d("SpotifyApiRequests", "Error: ${it.message}") + return SpotifySearchResult() + }.onSuccess { + return it + } + return SpotifySearchResult() + } + + @Provides + @Singleton + suspend fun provideSearchAllTypes(query: String): SpotifySearchResult { + return searchAllTypes(query) + } + + private suspend fun searchTracks(searchQuery: String): List { + kotlin.runCatching { + provideSpotifyApi().search.searchTrack(searchQuery, limit = 50) + }.onFailure { + Log.d("SpotifyApiRequests", "Error: ${it.message}") + return listOf() + }.onSuccess { + return it.items + } + return listOf() + } + + @Provides + @Singleton + suspend fun provideSearchTracks(query: String): List { + return searchTracks(query) + } + + //search by id + suspend fun getPlaylistById(id: String): Playlist? { + kotlin.runCatching { + provideSpotifyApi().playlists.getPlaylist(id) + }.onFailure { + Log.d("SpotifyApiRequests", "Error: ${it.message}") + return null + }.onSuccess { + Log.d("SpotifyApiRequests", "Playlist: $it") + return it + } + return null + } + + @Provides + @Singleton + suspend fun provideGetPlaylistById(id: String): Playlist? { + return getPlaylistById(id) + } + + suspend fun getTrackById(id: String): Track? { + kotlin.runCatching { + provideSpotifyApi().tracks.getTrack(id) + }.onFailure { + Log.d("SpotifyApiRequests", "Error: ${it.message}") + return null + }.onSuccess { + return it + } + return null + } + + @Provides + @Singleton + suspend fun provideGetTrackById(id: String): Track? { + return getTrackById(id) + } + + private suspend fun getArtistById(id: String): Artist? { + kotlin.runCatching { + api!!.artists.getArtist(id) + }.onFailure { + Log.d("SpotifyApiRequests", "Error: ${it.message}") + return null + }.onSuccess { + return it + } + return null + } + + @Provides + @Singleton + suspend fun providesGetArtistById(id: String): Artist? { + return getArtistById(id) + } + + suspend fun getAlbumById(id: String): Album? { + kotlin.runCatching { + provideSpotifyApi().albums.getAlbum(id) + }.onFailure { + Log.d("SpotifyApiRequests", "Error: ${it.message}") + return null + }.onSuccess { + return it + } + return null + } + + @Provides + @Singleton + suspend fun providesGetAlbumById(id: String): Album? { + return getAlbumById(id) + } + + private suspend fun getAudioFeatures(id: String): AudioFeatures? { + kotlin.runCatching { + provideSpotifyApi().tracks.getAudioFeatures(id) + }.onFailure { + Log.d("SpotifyApiRequests", "Error: ${it.message}") + }.onSuccess { + return it + } + return null + } + + @Provides + @Singleton + suspend fun providesGetAudioFeatures(id: String): AudioFeatures? { + return getAudioFeatures(id) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/model/SpotifyData.kt b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/model/SpotifyData.kt new file mode 100644 index 00000000..1e3c88a8 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/model/SpotifyData.kt @@ -0,0 +1,19 @@ +package com.bobbyesp.spowlo.features.spotify_api.model + +import com.adamratzman.spotify.models.ReleaseDate + +data class SpotifyData( + val artworkUrl: String = "", + val name: String = "", + val artists: List = emptyList(), + val releaseDate: ReleaseDate? = null, + val playlistSize : Int? = 0, + val type: SpotifyDataType = SpotifyDataType.TRACK +) + +enum class SpotifyDataType { + TRACK, + ALBUM, + PLAYLIST, + ARTIST +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/common/Ext.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/common/Ext.kt index 908b8e8d..27a5071b 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/common/Ext.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/common/Ext.kt @@ -19,3 +19,7 @@ inline val String.intState @Composable get() = remember { mutableStateOf(this.getInt()) } + +fun String.containsEllipsis(): Boolean { + return this.contains("…") +} diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/common/Route.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/common/Route.kt index 3f12f2b9..064de0a4 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/common/Route.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/common/Route.kt @@ -1,14 +1,20 @@ package com.bobbyesp.spowlo.ui.common object Route { + + const val DOWNLOADER_SETTINGS = "downloader_settings" + const val DOWNLOADER_SHEET = "downloader_sheet" + const val NavGraph = "nav_graph" + const val SearcherNavi = "searcher_navi" + const val DownloaderNavi = "downloader_navi" + + const val HOME = "home" const val DOWNLOADER = "downloader" const val DOWNLOADS_HISTORY = "download_history" const val PLAYLIST = "playlist" const val SETTINGS = "settings" const val FORMAT_SELECTION = "format" - const val TASK_LIST = "task_list" - const val TASK_LOG = "task_log" const val PLAYLIST_METADATA_PAGE = "playlist_metadata_page" const val MODS_DOWNLOADER = "mods_downloader" const val SEARCHER = "searcher" @@ -17,6 +23,11 @@ object Route { const val UPDATER_PAGE = "updater_page" const val MARKDOWN_VIEWER = "markdown_viewer" const val DOCUMENTATION = "documentation" + const val MORE_OPTIONS_HOME = "more_options_home" + const val SONG_INFO_HISTORY = "song_info_history" + const val DOWNLOAD_TASKS = "download_tasks" + const val DownloadTasksNavi = "download_tasks_navi" + const val PLAYLIST_PAGE = "playlist_page" const val APPEARANCE = "appearance" const val APP_THEME = "app_theme" @@ -26,16 +37,13 @@ object Route { const val DOWNLOAD_DIRECTORY = "download_directory" const val CREDITS = "credits" const val LANGUAGES = "languages" - const val DARK_THEME = "dark_theme" const val DOWNLOAD_QUEUE = "queue" + const val FULLSCREEN_LOG = "fullscreen_log" const val DOWNLOAD_FORMAT = "download_format" const val NETWORK_PREFERENCES = "network_preferences" const val COOKIE_PROFILE = "cookie_profile" const val COOKIE_GENERATOR_WEBVIEW = "cookie_webview" - const val TASK_HASHCODE = "task_hashcode" - const val TEMPLATE_ID = "template_id" - //DIALOGS const val AUDIO_QUALITY_DIALOG = "audio_quality_dialog" const val AUDIO_FORMAT_DIALOG = "audio_format_dialog" diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/BottomDrawer.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/BottomDrawer.kt index 5b36a07a..7fd7ca62 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/components/BottomDrawer.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/BottomDrawer.kt @@ -15,8 +15,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetDefaults +import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -33,13 +35,14 @@ import androidx.compose.ui.zIndex @Composable fun BottomDrawer( modifier: Modifier = Modifier, - drawerState: ModalBottomSheetState = androidx.compose.material.rememberModalBottomSheetState( - ModalBottomSheetValue.Hidden + drawerState: ModalBottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + confirmStateChange = { it == ModalBottomSheetValue.Hidden || it == ModalBottomSheetValue.Expanded }, ), sheetContent: @Composable ColumnScope.() -> Unit = {}, content: @Composable () -> Unit = {}, ) { - androidx.compose.material.ModalBottomSheetLayout( + ModalBottomSheetLayout( modifier = modifier, sheetShape = RoundedCornerShape( topStart = 28.0.dp, diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/Buttons.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/Buttons.kt index 7c2bec6c..81a26e56 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/components/Buttons.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/Buttons.kt @@ -71,11 +71,13 @@ fun TextButtonWithIcon( onClick: () -> Unit, icon: ImageVector, text: String, - contentColor: Color = MaterialTheme.colorScheme.primary + contentColor: Color = MaterialTheme.colorScheme.primary, + enabled : Boolean = true ) { TextButton( modifier = modifier, onClick = onClick, + enabled = enabled, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, colors = ButtonDefaults.textButtonColors(contentColor = contentColor) ) diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/SettingItem.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/SettingItem.kt index 899bd10c..f59faef5 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/components/SettingItem.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/SettingItem.kt @@ -14,17 +14,19 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable -fun SettingTitle(text: String) { +fun SettingTitle(text: String, fontWeight: FontWeight = FontWeight.Normal) { Text( modifier = Modifier .padding(top = 32.dp) .padding(horizontal = 20.dp, vertical = 16.dp), text = text, - style = MaterialTheme.typography.displaySmall + style = MaterialTheme.typography.displaySmall, + fontWeight = fontWeight, ) } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/SharedText.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/SharedText.kt index d7ac2dc6..0a7c76b9 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/components/SharedText.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/SharedText.kt @@ -1,12 +1,27 @@ package com.bobbyesp.spowlo.ui.components -import androidx.compose.animation.core.* +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.TargetBasedAnimation +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent @@ -236,7 +251,8 @@ fun AutoResizableText( modifier: Modifier = Modifier, text: String, textStyle: TextStyle = MaterialTheme.typography.bodySmall, - color: Color = textStyle.color + color: Color = textStyle.color, + maxLines: Int = 1, ) { var resizedTextStyle by remember { mutableStateOf(textStyle) @@ -250,6 +266,7 @@ fun AutoResizableText( Text( text = text, color = color, + maxLines = maxLines, modifier = modifier.drawWithContent { if (shouldDraw) { drawContent() @@ -274,6 +291,69 @@ fun AutoResizableText( ) } +//auto resizable text but with all the text parameters +@Composable +fun AutoResizableText( + modifier: Modifier = Modifier, + text: String, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + maxLines: Int = 1, + style: TextStyle = LocalTextStyle.current.plus(TextStyle()), + color: Color = style.color, +) { + var resizedTextStyle by remember { + mutableStateOf(style) + } + var shouldDraw by remember { + mutableStateOf(false) + } + + val defaultFontSize = MaterialTheme.typography.bodySmall.fontSize + + Text( + text = text, + color = color, + maxLines = maxLines, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + + modifier = modifier.drawWithContent { + if (shouldDraw) { + drawContent() + } + }, + softWrap = false, + style = resizedTextStyle, + onTextLayout = { result -> + if (result.didOverflowWidth) { + if (style.fontSize.isUnspecified) { + resizedTextStyle = resizedTextStyle.copy( + fontSize = defaultFontSize + ) + } + resizedTextStyle = resizedTextStyle.copy( + fontSize = resizedTextStyle.fontSize * 0.95 + ) + } else { + shouldDraw = true + } + } + ) +} + private enum class MarqueeLayers { MainText, SecondaryText, EdgesGradient } private data class TextLayoutInfo(val textWidth: Int, val containerWidth: Int) \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/download_tasks/DownloadingTaskItem.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/download_tasks/DownloadingTaskItem.kt new file mode 100644 index 00000000..0e6b4b83 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/download_tasks/DownloadingTaskItem.kt @@ -0,0 +1,266 @@ +package com.bobbyesp.spowlo.ui.components.download_tasks + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.common.LocalDarkTheme +import com.bobbyesp.spowlo.ui.components.AutoResizableText +import com.bobbyesp.spowlo.ui.components.FlatButtonChip +import com.bobbyesp.spowlo.ui.components.MarqueeText +import com.bobbyesp.spowlo.ui.theme.harmonizeWith +import com.bobbyesp.spowlo.ui.theme.harmonizeWithPrimary +import com.kyant.monet.LocalTonalPalettes +import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes +import com.kyant.monet.dynamicColorScheme + +val greenTonalPalettes = Color.Green.toTonalPalettes() + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DownloadingTaskItem( + modifier: Modifier = Modifier, + status: TaskState = TaskState.ERROR, + progress: Float = .85f, + url: String = "https://www.example.com", + header: String = "Faded - Alan Walker", + progressText: String = "[sample] Extracting URL: https://www.example.com\n" + + "[sample] sample: Downloading webpage\n" + + "[sample] sample: Downloading android player API JSON\n" + + "[info] Available automatic captions for sample:" + "[info] Available automatic captions for sample:", + artworkUrl: String = "https://www.example.com", + onCopyLog: () -> Unit = {}, + onCopyError: () -> Unit = {}, + onRestart: () -> Unit = {}, + onShowLog: () -> Unit = {}, + onCopyLink: () -> Unit = {}, + onCancel: () -> Unit = {} +) { + CompositionLocalProvider(LocalTonalPalettes provides greenTonalPalettes) { + val greenScheme = dynamicColorScheme(!LocalDarkTheme.current.isDarkTheme()) + val accentColor = MaterialTheme.colorScheme.run { + when (status) { + TaskState.FINISHED -> greenScheme.primary + TaskState.RUNNING -> primary + TaskState.ERROR -> error.harmonizeWithPrimary() + TaskState.CANCELED -> Color.Gray.harmonizeWithPrimary() + } + } + val containerColor = MaterialTheme.colorScheme.run { + surfaceColorAtElevation(3.dp).harmonizeWith(other = accentColor) + }.copy(alpha = 0.9f) + val contentColor = MaterialTheme.colorScheme.run { + onSurfaceVariant.harmonizeWith(other = accentColor) + } + + val labelText = stringResource( + id = when (status) { + TaskState.FINISHED -> R.string.status_completed + TaskState.RUNNING -> R.string.downloading + TaskState.ERROR -> R.string.error + TaskState.CANCELED -> R.string.task_canceled + } + ) + Surface( + modifier = modifier, + color = containerColor, + shape = CardDefaults.shape, + onClick = { onShowLog() }, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.semantics(mergeDescendants = true) { }, + verticalAlignment = Alignment.CenterVertically + ) { + when (status) { + TaskState.FINISHED -> { + Icon( + modifier = Modifier + .padding(8.dp) + .size(24.dp), + imageVector = Icons.Filled.CheckCircle, + tint = accentColor, + contentDescription = stringResource(id = R.string.status_completed) + ) + } + + TaskState.RUNNING -> { + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "" + ) + if (progress < 0) + CircularProgressIndicator( + modifier = Modifier + .padding(8.dp) + .size(24.dp), + strokeWidth = 5.dp, color = accentColor + ) + else + CircularProgressIndicator( + modifier = Modifier + .padding(8.dp) + .size(24.dp), + strokeWidth = 5.dp, + progress = animatedProgress, + color = accentColor + ) + } + + TaskState.ERROR -> { + Icon( + modifier = Modifier + .padding(8.dp) + .size(24.dp), + imageVector = Icons.Filled.Error, + tint = accentColor, + contentDescription = stringResource(id = R.string.searching_error) + ) + } + TaskState.CANCELED -> { + Icon( + modifier = Modifier + .padding(8.dp) + .size(24.dp), + imageVector = Icons.Filled.Cancel, + tint = accentColor, + contentDescription = stringResource(id = R.string.task_canceled) + ) + } + } + + Column( + Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) { + MarqueeText( + text = header, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + + ) + Text( + text = url, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + color = contentColor, + overflow = TextOverflow.Ellipsis + ) + } + IconButton( + modifier = Modifier + .align(Alignment.Top) + .semantics(mergeDescendants = true) { }, + onClick = { onShowLog() }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + imageVector = Icons.Outlined.Terminal, + contentDescription = stringResource( + id = R.string.open_log + ) + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .padding(top = 4.dp) + .clip(MaterialTheme.shapes.small) + .background(Color.Black.copy(alpha = 0.8f)), + ) { + AutoResizableText( + text = progressText, + modifier = Modifier.padding(8.dp), + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + // color is going to be like this: if the system color is dark, then the text color is white, otherwise it's black + color = Color.White, + maxLines = 1 + ) + } + + Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { + FlatButtonChip( + icon = Icons.Outlined.ContentCopy, + label = stringResource(id = R.string.copy_log) + ) { onCopyLog() } + FlatButtonChip( + icon = Icons.Outlined.ContentCopy, + label = stringResource(id = R.string.copy_link) + ) { onCopyLink() } + if (status == TaskState.ERROR) { + FlatButtonChip( + icon = Icons.Outlined.ErrorOutline, + label = stringResource(id = R.string.copy_error_report), + iconColor = MaterialTheme.colorScheme.error, + ) { onCopyError() } + FlatButtonChip( + icon = Icons.Outlined.RestartAlt, + label = stringResource(id = R.string.restart_task), + iconColor = MaterialTheme.colorScheme.secondary, + ) { onRestart() } + } + if (status == TaskState.RUNNING) + FlatButtonChip( + icon = Icons.Outlined.Cancel, + label = stringResource(id = R.string.cancel), + iconColor = MaterialTheme.colorScheme.secondary, + ) { onCancel() } + } + } + } + } +} + +enum class TaskState { + FINISHED, RUNNING, ERROR, CANCELED +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/history/HistoryMediaComponents.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/history/HistoryMediaComponents.kt index 8580104d..a873cfbf 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/components/history/HistoryMediaComponents.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/history/HistoryMediaComponents.kt @@ -3,7 +3,6 @@ package com.bobbyesp.spowlo.ui.components.history import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -16,18 +15,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -45,10 +39,6 @@ import com.bobbyesp.spowlo.ui.common.AsyncImageImpl import com.bobbyesp.spowlo.ui.common.LocalWindowWidthState import com.bobbyesp.spowlo.ui.components.MarqueeText import com.bobbyesp.spowlo.ui.components.songs.CustomTag -import com.bobbyesp.spowlo.ui.components.songs.ExplicitIcon -import com.bobbyesp.spowlo.ui.components.songs.LyricsIcon -import com.bobbyesp.spowlo.ui.components.songs.MiniMetadataInfoComponent -import com.bobbyesp.spowlo.utils.GeneralTextUtils import com.bobbyesp.spowlo.utils.toFileSizeText @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @@ -146,7 +136,7 @@ fun HistoryMediaItem( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start ) { - if (!isTwoColumns) { + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center @@ -185,7 +175,7 @@ fun HistoryMediaItem( maxLines = 1, ) } - } + } Column( modifier = Modifier diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/settings/SettingsComponents.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/settings/SettingsComponents.kt new file mode 100644 index 00000000..9a844971 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/settings/SettingsComponents.kt @@ -0,0 +1,245 @@ +package com.bobbyesp.spowlo.ui.components.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsItemNew( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + description: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + icon: ImageVector? = null, + addTonalElevation: Boolean = true, + clipCorners: Boolean = false, + highlightIcon: Boolean = false +) { + ListItem( + modifier = Modifier + .apply { if (clipCorners) this.clip(MaterialTheme.shapes.medium) } + .then(modifier), + leadingContent = { + icon?.let { + Icon( + imageVector = icon, + contentDescription = null, + ) + } + }, + trailingContent = trailing, + supportingContent = description, + headlineContent = title, + tonalElevation = if (addTonalElevation) 3.dp else 0.dp, + colors = ListItemDefaults.colors( + leadingIconColor = if (highlightIcon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + ) +} + +@Composable +fun SettingsItemNew( + onClick: () -> Unit, + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + description: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + icon: ImageVector? = null, + addTonalElevation: Boolean = true, + clipCorners: Boolean = false, + highlightIcon: Boolean = false +) { + SettingsItemNew( + modifier = modifier + .clickable( + onClick = onClick, + enabled = enabled + ) + .alpha(if (enabled) 1f else 0.5f), + icon = icon, + description = description, + title = title, + trailing = trailing, + addTonalElevation = addTonalElevation, + clipCorners = clipCorners, + highlightIcon = highlightIcon + ) +} + +@Composable +fun SettingsSwitch( + onCheckedChange: ((Boolean) -> Unit)?, + checked: Boolean, + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + description: (@Composable () -> Unit)? = null, + icon: ImageVector? = null, + thumbContent: (@Composable () -> Unit)? = null, + addTonalElevation: Boolean = true, + clipCorners: Boolean = false, + highlightIcon: Boolean = false +) { + val toggleableModifier = if (onCheckedChange != null) { + Modifier.toggleable( + value = checked, + enabled = enabled, + onValueChange = onCheckedChange + ).apply { if (!enabled) this.alpha(0.5f) } + } else Modifier + + SettingsItemNew( + modifier = modifier + .then(toggleableModifier), + icon = icon, + description = description, + title = title, + trailing = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + thumbContent = thumbContent + ) + }, + addTonalElevation = addTonalElevation, + clipCorners = clipCorners, + highlightIcon = highlightIcon + ) +} + +@Composable +fun SettingsNewSingleChoiceItem( + modifier: Modifier = Modifier, + text: String, + selected: Boolean, + wantsTonalElevation: Boolean = true, + contentPadding: PaddingValues = PaddingValues(0.dp), + onClick: () -> Unit, +) { + ListItem( + headlineContent = { + Text( + text = text, + maxLines = 1, + fontWeight = FontWeight.Bold + ) + }, + trailingContent = { + RadioButton( + selected = selected, + onClick = onClick + ) + }, + modifier = Modifier + .fillMaxWidth() + .clearAndSetSemantics { } + .clickable( + onClick = onClick, + ) + .padding(contentPadding) + .then(modifier), + tonalElevation = if (wantsTonalElevation) 3.dp else 0.dp, + ) +} + +//settings switch with divider between the switch and the rest of the item. On click actions are independent of the switch +@Composable +fun SettingsSwitchWithDivider( + onCheckedChange: ((Boolean) -> Unit)?, + checked: Boolean, + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + description: (@Composable () -> Unit)? = null, + icon: ImageVector? = null, + thumbContent: (@Composable () -> Unit)? = null, + addTonalElevation: Boolean = true, + clipCorners: Boolean = false, + highlightIcon: Boolean = false, + onClick: () -> Unit = {} +) { + val toggleableModifier = if (onCheckedChange != null) { + Modifier.toggleable( + value = checked, + enabled = enabled, + onValueChange = onCheckedChange + ).apply { if (!enabled) this.alpha(0.5f) } + } else Modifier + + SettingsItemNew( + modifier = modifier + .then(toggleableModifier), + icon = icon, + description = description, + title = title, + onClick = { onClick() }, + trailing = { + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Divider( + modifier = Modifier + .height(32.dp) + .padding(horizontal = 8.dp) + .width(1f.dp) + .align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + thumbContent = thumbContent + ) + } + }, + addTonalElevation = addTonalElevation, + clipCorners = clipCorners, + highlightIcon = highlightIcon + ) +} + +@Composable +fun ElevatedSettingsCard( + content: @Composable () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( + 3.dp + ) + ) + ) { + content() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/SongCard.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/SongCard.kt index 0505b67d..9a89adba 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/SongCard.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/SongCard.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -28,7 +27,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -42,13 +40,13 @@ import com.bobbyesp.spowlo.utils.GeneralTextUtils @OptIn(ExperimentalMaterial3Api::class) @Composable fun SongCard( + modifier: Modifier = Modifier, song: Song, onClick: () -> Unit = {}, progress: Float = 0.69f, isPreview: Boolean = false, isExplicit: Boolean = true, isLyrics: Boolean = false, - modifier: Modifier = Modifier, ) { Box(modifier) { ElevatedCard( @@ -102,7 +100,7 @@ fun SongCard( ) Spacer(modifier = Modifier.width(6.dp)) LyricsIcon( - visible = isLyrics + visible = false //isLyrics ) } Spacer(Modifier.height(8.dp)) @@ -146,7 +144,7 @@ fun SongCard( Box(Modifier.fillMaxWidth()) { val progressAnimationValue by animateFloatAsState( targetValue = progress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, label = "" ) if (progress < 0f) LinearProgressIndicator( @@ -176,6 +174,7 @@ fun SongCard( fun ShowSongCard() { Surface { SongCard( + song = Song( "Save Your Tears", listOf("The Weekend"), @@ -215,6 +214,7 @@ fun ShowSongCard() { fun ShowSongCardNight() { Surface { SongCard( + song = Song( "mariposas", listOf("sangiovanni"), diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/metadata_viewer/ExtraInfoCard.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/metadata_viewer/ExtraInfoCard.kt new file mode 100644 index 00000000..11606e1e --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/metadata_viewer/ExtraInfoCard.kt @@ -0,0 +1,110 @@ +package com.bobbyesp.spowlo.ui.components.songs.metadata_viewer + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bobbyesp.spowlo.ui.components.AutoResizableText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExtraInfoCard( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + headlineText: String = "POPULARITY", + bodyText: String = "69" +) { + OutlinedCard( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + modifier = modifier.size(width = 175.dp, height = 100.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ) + ) { + Text( + text = headlineText, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(alignment = Alignment.CenterHorizontally) + .padding(top = 8.dp) + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.align(alignment = Alignment.Center).padding(10.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AutoResizableText( + text = bodyText, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier, + fontWeight = FontWeight.ExtraBold + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WideExtraInfoCard( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + headlineText: String = "POPULARITY", + bodyText: String = "69" +) { + OutlinedCard( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + modifier = modifier + .fillMaxWidth() + .height(100.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ) + ) { + Text( + text = headlineText, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(alignment = Alignment.CenterHorizontally) + .padding(top = 8.dp) + ) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = bodyText, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier, + fontWeight = FontWeight.ExtraBold + ) + } + } +} + +@Preview +@Composable +fun ExtraInfoCardPreview() { + ExtraInfoCard() +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/metadata_viewer/TrackComponent.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/metadata_viewer/TrackComponent.kt new file mode 100644 index 00000000..9316a69d --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/metadata_viewer/TrackComponent.kt @@ -0,0 +1,209 @@ +package com.bobbyesp.spowlo.ui.components.songs.metadata_viewer + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.common.AsyncImageImpl +import com.bobbyesp.spowlo.ui.components.MarqueeText +import com.bobbyesp.spowlo.ui.components.songs.ExplicitIcon +import com.bobbyesp.spowlo.ui.components.songs.LyricsIcon +import com.bobbyesp.spowlo.ui.pages.settings.about.LocalAsset +import com.bobbyesp.spowlo.utils.ChromeCustomTabsUtil + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TrackComponent( + modifier: Modifier = Modifier, + contentModifier: Modifier = Modifier, + songName: String, + artists: String, + spotifyUrl: String, + hasLyrics: Boolean = false, + isExplicit: Boolean = false, + isPlaylist: Boolean = false, + imageUrl : String = "", + onClick: () -> Unit = { ChromeCustomTabsUtil.openUrl(spotifyUrl) } +) { + val clipboardManager = LocalClipboardManager.current + val showDropdown = remember { mutableStateOf(false) } + Column( + modifier + .fillMaxWidth() + .clickable { onClick() }) { + Row( + modifier = contentModifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, //This makes all go to the center + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if(isPlaylist && imageUrl.isNotEmpty()) { + AsyncImageImpl( + modifier = Modifier + .size(40.dp) + .aspectRatio( + 1f, matchHeightConstraintsFirst = true + ) + .clip(MaterialTheme.shapes.extraSmall), + model = imageUrl, + contentDescription = stringResource(id = R.string.track_artwork), + contentScale = ContentScale.Crop, + ) + } + Column( + modifier = Modifier + .padding(6.dp) + .padding(start = if(isPlaylist) 6.dp else 0.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + MarqueeText( + text = songName, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + basicGradientColor = MaterialTheme.colorScheme.surface.copy( + alpha = 0.8f + ), + ) + } + Spacer(Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + MarqueeText( + text = artists, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 10.sp, + basicGradientColor = MaterialTheme.colorScheme.surface.copy( + alpha = 0.8f + ), + ) + Spacer(modifier = Modifier.width(6.dp)) + LyricsIcon(visible = hasLyrics) + Spacer(modifier = Modifier.width(6.dp)) + ExplicitIcon(visible = isExplicit) + } + } + } + Column { + FilledTonalIconButton(onClick = { + showDropdown.value = !showDropdown.value + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "More options button", + modifier = Modifier + .weight(0.1f) + .padding(6.dp) + ) + } + + DropdownMenu( + expanded = showDropdown.value, + onDismissRequest = { showDropdown.value = false }, + properties = PopupProperties( + dismissOnClickOutside = true, + dismissOnBackPress = true, + focusable = true, + ), + ) { + DropdownMenuItem( + onClick = onClick, + text = { + Text(text = stringResource(id = R.string.download)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = "Download icon", + ) + } + ) + Divider() + DropdownMenuItem( + text = { + Text(text = stringResource(id = R.string.open_in_spotify)) + }, onClick = { + ChromeCustomTabsUtil.openUrl(spotifyUrl) + }, + leadingIcon = { + Icon( + imageVector = LocalAsset(id = R.drawable.spotify_logo), + contentDescription = "Spotify logo", + ) + } + ) + + DropdownMenuItem( + onClick = { + clipboardManager.setText(AnnotatedString(spotifyUrl)) + }, + text = { + Text(text = stringResource(id = R.string.copy_link)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = "Copy link icon", + ) + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/search_feat/SearchingSongComponent.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/search_feat/SearchingSongComponent.kt new file mode 100644 index 00000000..154d308f --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/components/songs/search_feat/SearchingSongComponent.kt @@ -0,0 +1,101 @@ +package com.bobbyesp.spowlo.ui.components.songs.search_feat + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bobbyesp.spowlo.App +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.common.AsyncImageImpl +import com.bobbyesp.spowlo.ui.components.MarqueeText +import com.bobbyesp.spowlo.utils.ChromeCustomTabsUtil + +@Composable +fun SearchingSongComponent( + artworkUrl: String, + songName: String, + artists: String, + spotifyUrl: String, + type : String = App.context.getString(R.string.single), + onClick: () -> Unit = { ChromeCustomTabsUtil.openUrl(spotifyUrl)} +) { + Column( + Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, //This makes all go to the center + ) { + AsyncImageImpl( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 6.dp) + .size(45.dp) + .aspectRatio(1f, matchHeightConstraintsFirst = true) + .clip(MaterialTheme.shapes.small), + model = artworkUrl, + contentDescription = "Song cover", + contentScale = ContentScale.Crop, + isPreview = false + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier + .padding(6.dp) + .weight(1f), //Weight is to make the time not go away from the screen + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + MarqueeText( + text = songName, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + basicGradientColor = MaterialTheme.colorScheme.surface.copy( + alpha = 0.8f + ), + ) + } + Spacer(Modifier.height(6.dp)) + MarqueeText( + text = "$artists • $type", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 10.sp, + basicGradientColor = MaterialTheme.colorScheme.surface.copy( + alpha = 0.8f + ), + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/DownloaderSettingsDialog.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/DownloaderSettingsDialog.kt index 3038f8a8..9afe3300 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/DownloaderSettingsDialog.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/DownloaderSettingsDialog.kt @@ -1,8 +1,7 @@ package com.bobbyesp.spowlo.ui.dialogs -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,22 +10,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.Dataset import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.DownloadDone -import androidx.compose.material.icons.outlined.HighQuality -import androidx.compose.material.icons.outlined.Key -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,21 +32,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import com.bobbyesp.spowlo.R -import com.bobbyesp.spowlo.ui.common.Route -import com.bobbyesp.spowlo.ui.common.intState -import com.bobbyesp.spowlo.ui.components.AudioFilterChip import com.bobbyesp.spowlo.ui.components.BottomDrawer -import com.bobbyesp.spowlo.ui.components.ButtonChip import com.bobbyesp.spowlo.ui.components.DismissButton -import com.bobbyesp.spowlo.ui.components.DrawerSheetSubtitle import com.bobbyesp.spowlo.ui.components.FilledButtonWithIcon import com.bobbyesp.spowlo.ui.components.OutlinedButtonWithIcon import com.bobbyesp.spowlo.ui.pages.settings.format.AudioFormatDialog @@ -62,36 +47,31 @@ import com.bobbyesp.spowlo.ui.pages.settings.format.AudioQualityDialog import com.bobbyesp.spowlo.ui.pages.settings.spotify.SpotifyClientIDDialog import com.bobbyesp.spowlo.ui.pages.settings.spotify.SpotifyClientSecretDialog import com.bobbyesp.spowlo.utils.COOKIES -import com.bobbyesp.spowlo.utils.CUSTOM_COMMAND import com.bobbyesp.spowlo.utils.DONT_FILTER_RESULTS import com.bobbyesp.spowlo.utils.GEO_BYPASS import com.bobbyesp.spowlo.utils.ORIGINAL_AUDIO import com.bobbyesp.spowlo.utils.PreferencesUtil -import com.bobbyesp.spowlo.utils.PreferencesUtil.templateStateFlow +import com.bobbyesp.spowlo.utils.SKIP_INFO_FETCH import com.bobbyesp.spowlo.utils.SYNCED_LYRICS -import com.bobbyesp.spowlo.utils.TEMPLATE_ID import com.bobbyesp.spowlo.utils.USE_CACHING import com.bobbyesp.spowlo.utils.USE_SPOTIFY_CREDENTIALS import com.bobbyesp.spowlo.utils.USE_YT_METADATA -import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class, + ExperimentalFoundationApi::class +) @Composable fun DownloaderSettingsDialog( useDialog: Boolean = false, dialogState: Boolean = false, isShareActivity: Boolean = false, drawerState: ModalBottomSheetState, - navController: NavController, confirm: () -> Unit, hide: () -> Unit, onRequestMetadata: () -> Unit, ) { val settings = PreferencesUtil - var customCommand by remember { mutableStateOf(PreferencesUtil.getValue(CUSTOM_COMMAND)) } - var selectedTemplateId by TEMPLATE_ID.intState - var preserveOriginalAudio by remember { mutableStateOf( settings.getValue( @@ -154,49 +134,36 @@ fun DownloaderSettingsDialog( ) } + var skipInfoFetch by remember { mutableStateOf(settings.getValue(SKIP_INFO_FETCH)) } + var showAudioFormatDialog by remember { mutableStateOf(false) } var showAudioQualityDialog by remember { mutableStateOf(false) } var showClientIdDialog by remember { mutableStateOf(false) } var showClientSecretDialog by remember { mutableStateOf(false) } - val templateList by templateStateFlow.collectAsStateWithLifecycle(ArrayList()) val scrollState = rememberLazyListState() val scope = rememberCoroutineScope() - LaunchedEffect(templateList.size, customCommand) { - if (customCommand) { - templateList.indexOfFirst { it.id == selectedTemplateId } - .run { if (!equals(-1)) scrollState.scrollToItem(this) } - } - } - - val updatePreferences = { - scope.launch { - settings.updateValue(CUSTOM_COMMAND, customCommand) - settings.encodeInt(TEMPLATE_ID, selectedTemplateId) - } - } val downloadButtonCallback = { - updatePreferences() hide() confirm() } val requestMetadata = { - updatePreferences() hide() onRequestMetadata() } val sheetContent: @Composable () -> Unit = { - Column { + /*Column { Text( text = stringResource(R.string.settings_before_download_text), - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Modifier.align(Alignment.Start) ) AnimatedVisibility(visible = preserveOriginalAudio) { ElevatedCard( @@ -254,7 +221,7 @@ fun DownloaderSettingsDialog( label = stringResource(id = R.string.audio_quality), icon = Icons.Outlined.HighQuality, enabled = !preserveOriginalAudio, - onClick = { navController.navigate(Route.AUDIO_QUALITY_DIALOG) }, + onClick = { showAudioQualityDialog = true }, ) } DrawerSheetSubtitle(text = stringResource(id = R.string.spotify)) @@ -372,26 +339,36 @@ fun DownloaderSettingsDialog( ) } } + */ } if (!useDialog) { //TODO: Change this UI BottomDrawer(drawerState = drawerState, sheetContent = { - Icon( - modifier = Modifier.align(Alignment.CenterHorizontally), - imageVector = Icons.Outlined.DownloadDone, - contentDescription = null - ) - Text( - text = stringResource(R.string.settings_before_download), - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(vertical = 16.dp), - maxLines = 2, - overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.DownloadDone, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = stringResource(R.string.settings_before_download), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(vertical = 16.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + } sheetContent() + val state = rememberLazyListState() + LaunchedEffect(drawerState.isVisible) { state.scrollToItem(1) } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/DownloaderBottomSheet.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/DownloaderBottomSheet.kt new file mode 100644 index 00000000..0bf51f3e --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/DownloaderBottomSheet.kt @@ -0,0 +1,534 @@ +package com.bobbyesp.spowlo.ui.dialogs.bottomsheets + +import android.Manifest +import android.os.Build +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AudioFile +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.Dataset +import androidx.compose.material.icons.outlined.DownloadDone +import androidx.compose.material.icons.outlined.HighQuality +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.PlaylistAddCheck +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.bobbyesp.spowlo.App +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.components.AudioFilterChip +import com.bobbyesp.spowlo.ui.components.ButtonChip +import com.bobbyesp.spowlo.ui.components.DrawerSheetSubtitle +import com.bobbyesp.spowlo.ui.components.FilledButtonWithIcon +import com.bobbyesp.spowlo.ui.components.OutlinedButtonWithIcon +import com.bobbyesp.spowlo.ui.pages.downloader.DownloaderViewModel +import com.bobbyesp.spowlo.ui.pages.settings.format.AudioFormatDialog +import com.bobbyesp.spowlo.ui.pages.settings.format.AudioQualityDialog +import com.bobbyesp.spowlo.ui.pages.settings.spotify.SpotifyClientIDDialog +import com.bobbyesp.spowlo.ui.pages.settings.spotify.SpotifyClientSecretDialog +import com.bobbyesp.spowlo.utils.COOKIES +import com.bobbyesp.spowlo.utils.DONT_FILTER_RESULTS +import com.bobbyesp.spowlo.utils.GEO_BYPASS +import com.bobbyesp.spowlo.utils.ORIGINAL_AUDIO +import com.bobbyesp.spowlo.utils.PreferencesUtil +import com.bobbyesp.spowlo.utils.SKIP_INFO_FETCH +import com.bobbyesp.spowlo.utils.SYNCED_LYRICS +import com.bobbyesp.spowlo.utils.ToastUtil +import com.bobbyesp.spowlo.utils.USE_CACHING +import com.bobbyesp.spowlo.utils.USE_SPOTIFY_CREDENTIALS +import com.bobbyesp.spowlo.utils.USE_YT_METADATA +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class) +@Composable +fun DownloaderBottomSheet( + onBackPressed: () -> Unit, + downloaderViewModel: DownloaderViewModel, + navController: NavController, + navigateToPlaylist: (String) -> Unit +) { + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(initialPage = 0) + + val pages = + listOf(BottomSheetPages.MAIN, BottomSheetPages.TERTIARY) //, BottomSheetPages.SECONDARY + + val viewState by downloaderViewModel.viewStateFlow.collectAsStateWithLifecycle() + + val roundedTopShape = + RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp) + + val storagePermission = rememberPermissionState( + permission = Manifest.permission.WRITE_EXTERNAL_STORAGE + ) { b: Boolean -> + if (b) { + downloaderViewModel.startDownloadSong() + } else { + ToastUtil.makeToast(R.string.permission_denied) + } + } + + val checkPermissionOrDownload = { + if (Build.VERSION.SDK_INT > 29 || storagePermission.status == PermissionStatus.Granted) downloaderViewModel.startDownloadSong() + else { + storagePermission.launchPermissionRequest() + } + } + + val settings = PreferencesUtil + + var preserveOriginalAudio by remember { + mutableStateOf( + settings.getValue( + ORIGINAL_AUDIO + ) + ) + } + + var useSpotifyCredentials by remember { + mutableStateOf( + settings.getValue( + USE_SPOTIFY_CREDENTIALS + ) + ) + } + + var useYtMetadata by remember { + mutableStateOf( + settings.getValue( + USE_YT_METADATA + ) + ) + } + + var useCookies by remember { + mutableStateOf( + settings.getValue( + COOKIES + ) + ) + } + + var useCaching by remember { + mutableStateOf( + settings.getValue( + USE_CACHING + ) + ) + } + + var dontFilter by remember { + mutableStateOf( + settings.getValue( + DONT_FILTER_RESULTS + ) + ) + } + + var useSyncedLyrics by remember { + mutableStateOf( + settings.getValue(SYNCED_LYRICS) + ) + } + + var useGeoBypass by remember { + mutableStateOf( + settings.getValue( + GEO_BYPASS + ) + ) + } + + var skipInfoFetch by remember { mutableStateOf(settings.getValue(SKIP_INFO_FETCH)) } + + var showAudioFormatDialog by remember { mutableStateOf(false) } + var showAudioQualityDialog by remember { mutableStateOf(false) } + var showClientIdDialog by remember { mutableStateOf(false) } + var showClientSecretDialog by remember { mutableStateOf(false) } + + val downloadButtonCallback = { + navController.popBackStack() + checkPermissionOrDownload() + } + + val requestMetadata = { + navController.popBackStack() + downloaderViewModel.requestMetadata() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .navigationBarsPadding() + .clip(roundedTopShape) + .padding(8.dp) + .animateContentSize( + animationSpec = tween( + durationMillis = 300, easing = FastOutSlowInEasing + ), + ) + + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.DownloadDone, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp, start = 8.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.settings_before_download), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(vertical = 12.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + } + Text( + text = stringResource(R.string.settings_before_download_text), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.Start) + .padding(start = 8.dp) + ) + IndicatorBehindScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + modifier = Modifier.animateContentSize(), + indicator = { tabPositions -> + Box( + Modifier + .padding(vertical = 12.dp) + .tabIndicatorOffset(tabPositions[pagerState.currentPage]) + .fillMaxHeight() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer) + ) + }, + edgePadding = 16.dp, + tabAlignment = Alignment.CenterStart, + ) { + pages.forEachIndexed { index, page -> + Tab( + text = { Text(text = page) }, + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + ) + } + } + HorizontalPager( + pageCount = pages.size, state = pagerState, modifier = Modifier.animateContentSize() + ) { + when (pages[it]) { + BottomSheetPages.MAIN -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp) + ) { + DrawerSheetSubtitle(text = stringResource(id = R.string.general)) + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background( + color = MaterialTheme.colorScheme.surfaceVariant + ), + ) { + AudioFilterChip(label = stringResource(id = R.string.preserve_original_audio), + animated = true, + selected = preserveOriginalAudio, + onClick = { + preserveOriginalAudio = !preserveOriginalAudio + scope.launch { + settings.updateValue(ORIGINAL_AUDIO, preserveOriginalAudio) + } + }) + ButtonChip( + label = stringResource(id = R.string.audio_format), + icon = Icons.Outlined.AudioFile, + onClick = { showAudioFormatDialog = true }, + ) + ButtonChip( + label = stringResource(id = R.string.audio_quality), + icon = Icons.Outlined.HighQuality, + enabled = !preserveOriginalAudio, + onClick = { showAudioQualityDialog = true }, + ) + } + DrawerSheetSubtitle(text = stringResource(id = R.string.spotify)) + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background( + color = MaterialTheme.colorScheme.surfaceVariant + ), + ) { + AudioFilterChip(label = stringResource(id = R.string.use_spotify_credentials), + animated = true, + selected = useSpotifyCredentials, + onClick = { + useSpotifyCredentials = !useSpotifyCredentials + scope.launch { + settings.updateValue( + USE_SPOTIFY_CREDENTIALS, useSpotifyCredentials + ) + } + }) + ButtonChip( + label = stringResource(id = R.string.client_id), + icon = Icons.Outlined.Person, + enabled = useSpotifyCredentials, + onClick = { showClientIdDialog = true }, + ) + ButtonChip( + label = stringResource(id = R.string.client_secret), + icon = Icons.Outlined.Key, + enabled = useSpotifyCredentials, + onClick = { showClientSecretDialog = true }, + ) + } + + } + } + + BottomSheetPages.SECONDARY -> { + + } + + BottomSheetPages.TERTIARY -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp) + ) { + DrawerSheetSubtitle(text = stringResource(id = R.string.general)) + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background( + color = MaterialTheme.colorScheme.surfaceVariant + ), + ) { + AudioFilterChip(label = stringResource(id = R.string.use_cache), + animated = true, + selected = useCaching, + onClick = { + useCaching = !useCaching + scope.launch { + settings.updateValue(USE_CACHING, useCaching) + } + }) + + } + + DrawerSheetSubtitle(text = stringResource(id = R.string.experimental_features)) + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background( + color = MaterialTheme.colorScheme.surfaceVariant + ), + ) { + AudioFilterChip(label = stringResource(id = R.string.synced_lyrics), + animated = true, + selected = useSyncedLyrics, + onClick = { + useSyncedLyrics = !useSyncedLyrics + scope.launch { + settings.updateValue(SYNCED_LYRICS, useSyncedLyrics) + } + }) + AudioFilterChip(label = stringResource(id = R.string.geo_bypass), + selected = useGeoBypass, + animated = true, + onClick = { + useGeoBypass = !useGeoBypass + scope.launch { + settings.updateValue(GEO_BYPASS, useGeoBypass) + } + }) + AudioFilterChip(label = stringResource(id = R.string.dont_filter_results), + selected = dontFilter, + animated = true, + onClick = { + dontFilter = !dontFilter + scope.launch { + settings.updateValue(DONT_FILTER_RESULTS, dontFilter) + } + }) + AudioFilterChip(label = stringResource(id = R.string.use_cookies), + animated = true, + selected = useCookies, + onClick = { + useCookies = !useCookies + scope.launch { + settings.updateValue(COOKIES, useCookies) + } + }) + AudioFilterChip(label = stringResource(id = R.string.use_yt_metadata), + animated = true, + selected = useYtMetadata, + onClick = { + useYtMetadata = !useYtMetadata + scope.launch { + settings.updateValue(USE_YT_METADATA, useYtMetadata) + } + }) + } + } + } + } + } + + val state = rememberLazyListState() + + LaunchedEffect(Unit) { + state.scrollToItem(1) + } + + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + horizontalArrangement = Arrangement.End, + state = state + ) { + item { + OutlinedButtonWithIcon( + modifier = Modifier.padding(horizontal = 12.dp), + onClick = { navController.popBackStack() }, + icon = Icons.Outlined.Cancel, + text = stringResource(R.string.cancel) + ) + } + item { + FilledButtonWithIcon( + modifier = Modifier.padding(end = 12.dp), + onClick = requestMetadata, + icon = Icons.Outlined.Dataset, + text = stringResource(R.string.request_metadata) + ) + } + item { + if (viewState.url.contains("playlist")) { + //https://open.spotify.com/playlist/4aKFWQtn0Tstw68SIMURye?si=c9e7282b0c354d34 + //get playlist id after the playlist/ and before the ? + var playlistId = viewState.url.substringAfter("playlist/").substringBefore("?") + + if (viewState.url == "playlist") + run { + playlistId = "7804lpXmApCGPd2Rdai6k1" + } + FilledButtonWithIcon( + onClick = { navigateToPlaylist(playlistId) }, + icon = Icons.Outlined.PlaylistAddCheck, + text = stringResource(R.string.see_playlist) + ) + + } else { + FilledButtonWithIcon( + onClick = downloadButtonCallback, + icon = Icons.Outlined.DownloadDone, + text = stringResource(R.string.start_download) + ) + } + } + } + } + + if (showAudioFormatDialog) { + AudioFormatDialog( + onDismissRequest = { showAudioFormatDialog = false }, + ) + } + if (showAudioQualityDialog) { + AudioQualityDialog( + onDismissRequest = { showAudioQualityDialog = false }, + ) + } + if (showClientIdDialog) { + SpotifyClientIDDialog { + showClientIdDialog = !showClientIdDialog + } + } + if (showClientSecretDialog) { + SpotifyClientSecretDialog { + showClientSecretDialog = !showClientSecretDialog + } + } +} + +object BottomSheetPages { + val MAIN = getString(R.string.audio) + val SECONDARY = "secondary" + val TERTIARY = getString(R.string.downloader) +} + +//GET STRING FROM APP.CONTEXT GIVEN A r.string ID +fun getString(id: Int): String { + return App.context.getString(id) +} diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/MoreOptionsBottomSheet.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/MoreOptionsBottomSheet.kt new file mode 100644 index 00000000..09eb11e9 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/MoreOptionsBottomSheet.kt @@ -0,0 +1,147 @@ +package com.bobbyesp.spowlo.ui.dialogs.bottomsheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.bobbyesp.spowlo.App +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.common.Route + +@Composable +fun MoreOptionsHomeBottomSheet( + onBackPressed : () -> Unit, + navController: NavController +){ + val uriHandler = LocalUriHandler.current + + val roundedTopShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .navigationBarsPadding() + .clip(roundedTopShape) + + ) { + BottomSheetHandle(modifier = Modifier.align(Alignment.CenterHorizontally)) + + Card( + modifier = Modifier.padding(horizontal = 16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + ) { + ListItem( + leadingContent = { + Icon(imageVector = Icons.Rounded.Settings, contentDescription = null) + }, headlineContent = { + Text(text = stringResource(id = R.string.settings)) + }, modifier = Modifier.clickable(onClick = { + navController.navigate(Route.SETTINGS) + }), colors = ListItemDefaults.colors( + leadingIconColor = MaterialTheme.colorScheme.primary, + containerColor = Color.Transparent, + ) + ) + } + Column( + modifier = Modifier.padding(top = 16.dp).fillMaxWidth() + ){ + Text( + text = stringResource(id = R.string.app_name) + " " + App.packageInfo.versionName, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Text( + text = stringResource(id = R.string.app_description), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + + Row(Modifier.padding(horizontal = 4.dp)) { + IconButton(onClick = { + navController.navigate(Route.ABOUT) + }) { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = { + uriHandler.openUri("https://github.com/BobbyESP/Spowlo") + }) { + Icon( + imageVector = Icons.Rounded.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + +} + +@Composable +fun BottomSheetHandle( + modifier: Modifier = Modifier +) { + Divider( + modifier = modifier + .width(32.dp) + .padding(vertical = 14.dp) + .clip(CircleShape), + thickness = 4.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.4f) + ) +} + +@Composable +fun BottomSheetHeader( + modifier: Modifier = Modifier, + text: String +) { + Text( + text, + fontSize = 22.sp, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = modifier.fillMaxWidth() + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/PagerUtils.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/PagerUtils.kt new file mode 100644 index 00000000..b2953a1e --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/dialogs/bottomsheets/PagerUtils.kt @@ -0,0 +1,242 @@ +package com.bobbyesp.spowlo.ui.dialogs.bottomsheets + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.TabRowDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun IndicatorBehindScrollableTabRow( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + containerColor: Color = TabRowDefaults.containerColor, + contentColor: Color = TabRowDefaults.contentColor, + edgePadding: Dp = ScrollableTabRowPadding, + tabAlignment: Alignment = Alignment.CenterStart, + indicator: @Composable (tabPositions: List) -> Unit, + tabs: @Composable () -> Unit +) { + Surface( + modifier = modifier, + color = containerColor, + contentColor = contentColor + ) { + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + val scrollableTabData = remember(scrollState, coroutineScope) { + ScrollableTabData( + scrollState = scrollState, + coroutineScope = coroutineScope + ) + } + SubcomposeLayout( + Modifier + .fillMaxWidth() + .wrapContentSize(align = tabAlignment) + .horizontalScroll(scrollState) + .selectableGroup() + .clipToBounds() + ) { constraints -> + val padding = edgePadding.roundToPx() + + val tabMeasurables = subcompose(TabSlots.Tabs, tabs) + + val layoutHeight = tabMeasurables.fold(initial = 0) { curr, measurable -> + maxOf(curr, measurable.maxIntrinsicHeight(Constraints.Infinity)) + } + + val tabConstraints = constraints.copy(minHeight = layoutHeight) + val tabPlaceables = tabMeasurables.map { it.measure(tabConstraints) } + + val layoutWidth = tabPlaceables.fold(initial = padding * 2) { curr, measurable -> + curr + measurable.width + (8.dp.roundToPx()) + } + + // Position the children. + layout(layoutWidth, layoutHeight) { + // Place the tabs + val tabPositions = mutableListOf() + var left = padding + + tabPlaceables.forEach { + tabPositions.add(TabPosition(left = left.toDp(), width = it.width.toDp())) + left += it.width + (8.dp.roundToPx()) + } + + // The indicator container is measured to fill the entire space occupied by the tab + // row, and then placed on top of the divider. + subcompose(TabSlots.Indicator) { + indicator(tabPositions) + }.forEach { + it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0) + } + + tabPlaceables.forEachIndexed { idx, it -> + it.placeRelative(tabPositions[idx].left.roundToPx(), 0) + } + + scrollableTabData.onLaidOut( + density = this@SubcomposeLayout, + edgeOffset = padding, + tabPositions = tabPositions, + selectedTab = selectedTabIndex + ) + } + } + } +} + +private enum class TabSlots { + Tabs, + Divider, + Indicator +} + +private class ScrollableTabData( + private val scrollState: ScrollState, + private val coroutineScope: CoroutineScope +) { + private var selectedTab: Int? = null + + fun onLaidOut( + density: Density, + edgeOffset: Int, + tabPositions: List, + selectedTab: Int + ) { + // Animate if the new tab is different from the old tab, or this is called for the first + // time (i.e selectedTab is `null`). + if (this.selectedTab != selectedTab) { + this.selectedTab = selectedTab + tabPositions.getOrNull(selectedTab)?.let { + // Scrolls to the tab with [tabPosition], trying to place it in the center of the + // screen or as close to the center as possible. + val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions) + if (scrollState.value != calculatedOffset) { + coroutineScope.launch { + scrollState.animateScrollTo( + calculatedOffset, + animationSpec = ScrollableTabRowScrollSpec + ) + } + } + } + } + } + + /** + * @return the offset required to horizontally center the tab inside this TabRow. + * If the tab is at the start / end, and there is not enough space to fully centre the tab, this + * will just clamp to the min / max position given the max width. + */ + private fun TabPosition.calculateTabOffset( + density: Density, + edgeOffset: Int, + tabPositions: List + ): Int = with(density) { + val totalTabRowWidth = tabPositions.last().right.roundToPx() + edgeOffset + val visibleWidth = totalTabRowWidth - scrollState.maxValue + val tabOffset = left.roundToPx() + val scrollerCenter = visibleWidth / 2 + val tabWidth = width.roundToPx() + val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2) + // How much space we have to scroll. If the visible width is <= to the total width, then + // we have no space to scroll as everything is always visible. + val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0) + return centeredTabOffset.coerceIn(0, availableSpace) + } +} + +@Immutable +class TabPosition internal constructor(val left: Dp, val width: Dp) { + val right: Dp get() = left + width + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is androidx.compose.material3.TabPosition) return false + + if (left != other.left) return false + if (width != other.width) return false + + return true + } + + override fun hashCode(): Int { + var result = left.hashCode() + result = 31 * result + width.hashCode() + return result + } + + override fun toString(): String { + return "TabPosition(left=$left, right=$right, width=$width)" + } +} + + +private val ScrollableTabRowMinimumTabWidth = 90.dp + +/** + * The default padding from the starting edge before a tab in a [ScrollableTabRow]. + */ +private val ScrollableTabRowPadding = 52.dp + +/** + * [AnimationSpec] used when scrolling to a tab that is not fully visible. + */ +private val ScrollableTabRowScrollSpec: AnimationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing +) + +fun Modifier.tabIndicatorOffset( + currentTabPosition: TabPosition +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "tabIndicatorOffset" + value = currentTabPosition + } +) { + val currentTabWidth by animateDpAsState( + targetValue = currentTabPosition.width, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), label = "" + ) + + val indicatorOffset by animateDpAsState( + targetValue = currentTabPosition.left, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), label = "" + ) + + fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset(x = indicatorOffset) + .width(currentTabWidth) +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/InitialEntry.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/InitialEntry.kt index eb119fa1..0ab68f83 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/InitialEntry.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/InitialEntry.kt @@ -7,19 +7,30 @@ import android.provider.Settings import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -32,31 +43,44 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavType import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.dialog import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import androidx.navigation.navOptions +import androidx.navigation.navigation import com.bobbyesp.library.SpotDL import com.bobbyesp.spowlo.App import com.bobbyesp.spowlo.MainActivity import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.features.mod_downloader.data.remote.ModsDownloaderAPI +import com.bobbyesp.spowlo.features.spotify_api.data.remote.SpotifyApiRequests import com.bobbyesp.spowlo.ui.common.LocalWindowWidthState import com.bobbyesp.spowlo.ui.common.Route +import com.bobbyesp.spowlo.ui.common.Route.MARKDOWN_VIEWER import com.bobbyesp.spowlo.ui.common.animatedComposable +import com.bobbyesp.spowlo.ui.common.animatedComposableVariant +import com.bobbyesp.spowlo.ui.common.arg +import com.bobbyesp.spowlo.ui.common.id import com.bobbyesp.spowlo.ui.common.slideInVerticallyComposable import com.bobbyesp.spowlo.ui.dialogs.UpdaterBottomDrawer +import com.bobbyesp.spowlo.ui.dialogs.bottomsheets.DownloaderBottomSheet +import com.bobbyesp.spowlo.ui.dialogs.bottomsheets.MoreOptionsHomeBottomSheet +import com.bobbyesp.spowlo.ui.pages.download_tasks.DownloadTasksPage +import com.bobbyesp.spowlo.ui.pages.download_tasks.FullscreenConsoleOutput import com.bobbyesp.spowlo.ui.pages.downloader.DownloaderPage import com.bobbyesp.spowlo.ui.pages.downloader.DownloaderViewModel import com.bobbyesp.spowlo.ui.pages.history.DownloadsHistoryPage +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.playlists.PlaylistPage +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.playlists.PlaylistPageViewModel import com.bobbyesp.spowlo.ui.pages.mod_downloader.ModsDownloaderPage import com.bobbyesp.spowlo.ui.pages.mod_downloader.ModsDownloaderViewModel import com.bobbyesp.spowlo.ui.pages.playlist.PlaylistMetadataPage +import com.bobbyesp.spowlo.ui.pages.searcher.SearcherPage import com.bobbyesp.spowlo.ui.pages.settings.SettingsPage import com.bobbyesp.spowlo.ui.pages.settings.about.AboutPage import com.bobbyesp.spowlo.ui.pages.settings.appearance.AppThemePreferencesPage @@ -67,20 +91,22 @@ import com.bobbyesp.spowlo.ui.pages.settings.cookies.CookiesSettingsViewModel import com.bobbyesp.spowlo.ui.pages.settings.cookies.WebViewPage import com.bobbyesp.spowlo.ui.pages.settings.directories.DownloadsDirectoriesPage import com.bobbyesp.spowlo.ui.pages.settings.documentation.DocumentationPage +import com.bobbyesp.spowlo.ui.pages.settings.downloader.DownloaderSettingsPage import com.bobbyesp.spowlo.ui.pages.settings.format.AudioQualityDialog import com.bobbyesp.spowlo.ui.pages.settings.format.SettingsFormatsPage import com.bobbyesp.spowlo.ui.pages.settings.general.GeneralSettingsPage import com.bobbyesp.spowlo.ui.pages.settings.spotify.SpotifySettingsPage import com.bobbyesp.spowlo.ui.pages.settings.updater.UpdaterPage -import com.bobbyesp.spowlo.utils.PreferencesUtil.getBoolean +import com.bobbyesp.spowlo.utils.PreferencesUtil import com.bobbyesp.spowlo.utils.PreferencesUtil.getString import com.bobbyesp.spowlo.utils.SPOTDL -import com.bobbyesp.spowlo.utils.SPOTDL_UPDATE import com.bobbyesp.spowlo.utils.ToastUtil import com.bobbyesp.spowlo.utils.UpdateUtil import com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.rememberAnimatedNavController import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi +import com.google.accompanist.navigation.material.ModalBottomSheetLayout +import com.google.accompanist.navigation.material.bottomSheet import com.google.accompanist.navigation.material.rememberBottomSheetNavigator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -91,21 +117,26 @@ private const val TAG = "InitialEntry" @OptIn( ExperimentalAnimationApi::class, ExperimentalMaterialNavigationApi::class, - ExperimentalMaterial3Api::class + ExperimentalLayoutApi::class, ExperimentalMaterialApi::class ) @Composable fun InitialEntry( downloaderViewModel: DownloaderViewModel, modsDownloaderViewModel: ModsDownloaderViewModel, + playlistPageViewModel: PlaylistPageViewModel, isUrlShared: Boolean ) { + //bottom sheet remember state val bottomSheetNavigator = rememberBottomSheetNavigator() val navController = rememberAnimatedNavController(bottomSheetNavigator) - val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRootRoute = remember(navBackStackEntry) { - navController.backQueue.getOrNull(1)?.destination?.route + mutableStateOf( + navBackStackEntry?.destination?.parent?.route ?: Route.DownloaderNavi + ) } + //navController.currentBackStack.value.getOrNull(1)?.destination?.route val shouldHideBottomNavBar = remember(navBackStackEntry) { navBackStackEntry?.destination?.hierarchy?.any { it.route == Route.SPOTIFY_SETUP } == true } @@ -148,13 +179,16 @@ fun InitialEntry( } } } - val cookiesViewModel: CookiesSettingsViewModel = viewModel() val onBackPressed: () -> Unit = { navController.popBackStack() } if (isUrlShared) { - if (navController.currentDestination?.route != Route.HOME) { - navController.popBackStack(route = Route.HOME, inclusive = false, saveState = true) + if (navController.currentDestination?.route != Route.DOWNLOADER) { + navController.popBackStack( + route = Route.DOWNLOADER, + inclusive = false, + saveState = true + ) } } Box( @@ -162,277 +196,409 @@ fun InitialEntry( .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - /*Scaffold( - bottomBar = { - NavigationBar( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) - .navigationBarsPadding(), + val navRootUrl = "android-app://androidx.navigation/" + ModalBottomSheetLayout( + bottomSheetNavigator, + sheetShape = MaterialTheme.shapes.medium.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp) + ), + scrimColor = MaterialTheme.colorScheme.scrim.copy(0.5f), + sheetBackgroundColor = MaterialTheme.colorScheme.surface, + ) { + Scaffold( + bottomBar = { + AnimatedVisibility( + visible = !shouldHideBottomNavBar, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() ) { - MainActivity.showInBottomNavigation.forEach() { (route, icon) -> - val text = when (route) { - Route.HOME -> App.context.getString(R.string.downloader) - Route.SEARCHER -> App.context.getString(R.string.searcher) - Route.MEDIA_PLAYER -> App.context.getString(R.string.mediaplayer) - else -> "" - } - NavigationBarItem(selected = currentRootRoute == route, - onClick = { - navController.navigate(route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true + NavigationBar( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + .navigationBarsPadding(), + ) { + MainActivity.showInBottomNavigation.forEach { (route, icon) -> + val text = when (route) { + Route.DownloaderNavi -> App.context.getString(R.string.downloader) + Route.SearcherNavi -> App.context.getString(R.string.searcher) + Route.DownloadTasksNavi -> App.context.getString(R.string.tasks) + else -> "" + } + + val selected = currentRootRoute.value == route + + val onClick = remember(selected, navController, route) { + { + if (!selected) { + navController.navigate(route) { + popUpTo(Route.NavGraph) { + saveState = true + } + launchSingleTop = true + restoreState = true + } } + } + } + NavigationBarItem( + selected = currentRootRoute.value == route, + onClick = onClick, + icon = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + }, + label = { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + }) + } + } + } + }, modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + ) { paddingValues -> + AnimatedNavHost( + modifier = Modifier + .fillMaxWidth( + when (LocalWindowWidthState.current) { + WindowWidthSizeClass.Compact -> 1f + WindowWidthSizeClass.Expanded -> 1f + else -> 0.8f + } + ) + .align(Alignment.Center) + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + navController = navController, + startDestination = Route.DownloaderNavi, + route = Route.NavGraph + ) { + navigation(startDestination = Route.DOWNLOADER, route = Route.DownloaderNavi) { + animatedComposable(Route.DOWNLOADER) { + DownloaderPage( + navigateToDownloads = { + navController.navigate(Route.DOWNLOADS_HISTORY) { launchSingleTop = true - restoreState = true } }, - icon = { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface - ) + navigateToSettings = { + navController.navigate(Route.MORE_OPTIONS_HOME) { + launchSingleTop = true + } }, - label = { - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface - ) - }) + navigateToDownloaderSheet = { + navController.navigate(Route.DOWNLOADER_SHEET) { + launchSingleTop = true + } + }, + onSongCardClicked = { + navController.navigate(Route.PLAYLIST_METADATA_PAGE) { + launchSingleTop = true + } + }, + navigateToMods = { + navController.navigate(Route.MODS_DOWNLOADER) { + launchSingleTop = true + } + }, + downloaderViewModel = downloaderViewModel + ) + } + animatedComposable(Route.SETTINGS) { + SettingsPage( + navController = navController + ) + } + animatedComposable(Route.GENERAL_DOWNLOAD_PREFERENCES) { + GeneralSettingsPage( + onBackPressed = onBackPressed + ) + } + animatedComposable(Route.DOWNLOADS_HISTORY) { + DownloadsHistoryPage( + onBackPressed = onBackPressed, + ) + } + animatedComposable(Route.DOWNLOAD_DIRECTORY) { + DownloadsDirectoriesPage { + onBackPressed() + } + } + animatedComposable(Route.APPEARANCE) { + AppearancePage(navController = navController) + } + animatedComposable(Route.APP_THEME) { + AppThemePreferencesPage { + onBackPressed() + } + } + animatedComposable(Route.DOWNLOAD_FORMAT) { + SettingsFormatsPage { + onBackPressed() + } + } + animatedComposable(Route.SPOTIFY_PREFERENCES) { + SpotifySettingsPage { + onBackPressed() + } + } + animatedComposable(Route.DOWNLOADER_SETTINGS) { + DownloaderSettingsPage { + onBackPressed() + } + } + slideInVerticallyComposable(Route.PLAYLIST_METADATA_PAGE) { + PlaylistMetadataPage( + onBackPressed, + //TODO: ADD THE ABILITY TO PASS JUST SONGS AND NOT GET THEM FROM THE MUTABLE STATE + ) + } + animatedComposable(Route.MODS_DOWNLOADER) { + ModsDownloaderPage( + onBackPressed, + modsDownloaderViewModel + ) + } + animatedComposable(Route.COOKIE_PROFILE) { + CookieProfilePage( + cookiesViewModel = cookiesViewModel, + navigateToCookieGeneratorPage = { navController.navigate(Route.COOKIE_GENERATOR_WEBVIEW) }, + ) { onBackPressed() } + } + animatedComposable( + Route.COOKIE_GENERATOR_WEBVIEW + ) { + WebViewPage(cookiesViewModel) { onBackPressed() } + } + animatedComposable(Route.UPDATER_PAGE) { + UpdaterPage( + onBackPressed + ) + } + animatedComposable(Route.DOCUMENTATION) { + DocumentationPage( + onBackPressed, + navController + ) + } + + animatedComposable(Route.ABOUT) { + AboutPage { + onBackPressed() + } + } + + animatedComposable(Route.LANGUAGES) { + LanguagePage { + onBackPressed() + } + } + + + navDeepLink { + // Want to go to "markdown_viewer/{markdownFileName}" + uriPattern = + "android-app://androidx.navigation/markdown_viewer/{markdownFileName}" + } + + animatedComposable( + MARKDOWN_VIEWER arg "markdownFileName", + arguments = listOf( + navArgument( + "markdownFileName" + ) { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val mdFileName = + backStackEntry.arguments?.getString("markdownFileName") ?: "" + Log.d("MainActivity", mdFileName) + MarkdownViewerPage( + markdownFileName = mdFileName, + onBackPressed = onBackPressed + ) + } + + //DIALOGS ------------------------------- + dialog(Route.AUDIO_QUALITY_DIALOG) { + AudioQualityDialog( + onBackPressed + ) + } + + //BOTTOM SHEETS -------------------------- + bottomSheet(Route.MORE_OPTIONS_HOME) { + MoreOptionsHomeBottomSheet( + onBackPressed, + navController + ) + } + + bottomSheet(Route.DOWNLOADER_SHEET) { + /*ModalBottomSheetLayout( + bottomSheetNavigator = BottomSheetNavigator( + rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = true), + ) + ) {*/ + DownloaderBottomSheet( + onBackPressed, + downloaderViewModel, + navController + ) { id -> navController.navigate(Route.PLAYLIST_PAGE + "/" + "playlist" + "/" + id, navOptions = navOptions { + launchSingleTop = true + }) } + // } + } } - }, modifier = Modifier.fillMaxSize().align(Alignment.Center)) { paddingValues ->*/ - - AnimatedNavHost( - modifier = Modifier - .fillMaxWidth( - when (LocalWindowWidthState.current) { - WindowWidthSizeClass.Compact -> 1f - WindowWidthSizeClass.Expanded -> 0.5f - else -> 0.8f - } - ) - .align(Alignment.Center) - .padding(/*bottom = paddingValues.calculateBottomPadding()*/), - navController = navController, - startDestination = Route.HOME - ) { - //TODO: Add all routes - animatedComposable(Route.HOME) { //TODO: Change this route to Route.DOWNLOADER, but by now, keep it as Route.HOME - DownloaderPage( - navigateToDownloads = { navController.navigate(Route.DOWNLOADS_HISTORY) }, - navigateToSettings = { navController.navigate(Route.SETTINGS) }, - navigateToPlaylistPage = { navController.navigate(Route.PLAYLIST) }, - onSongCardClicked = { - navController.navigate(Route.PLAYLIST_METADATA_PAGE) - }, - onNavigateToTaskList = { navController.navigate(Route.TASK_LIST) }, - navigateToMods = { navController.navigate(Route.MODS_DOWNLOADER) }, - navController = navController, - downloaderViewModel = downloaderViewModel - ) - } - animatedComposable(Route.SETTINGS) { - SettingsPage( - navController = navController - ) - } - animatedComposable(Route.GENERAL_DOWNLOAD_PREFERENCES) { - GeneralSettingsPage( - onBackPressed = onBackPressed - ) - } - animatedComposable(Route.DOWNLOADS_HISTORY) { - DownloadsHistoryPage( - onBackPressed = onBackPressed - ) - } - animatedComposable(Route.DOWNLOAD_DIRECTORY) { - DownloadsDirectoriesPage { - onBackPressed() - } - } - animatedComposable(Route.APPEARANCE) { - AppearancePage(navController = navController) - } - animatedComposable(Route.APP_THEME) { - AppThemePreferencesPage { - onBackPressed() - } - } - animatedComposable(Route.DOWNLOAD_FORMAT) { - SettingsFormatsPage { - onBackPressed() - } - } - animatedComposable(Route.SPOTIFY_PREFERENCES) { - SpotifySettingsPage { - onBackPressed() - } - } - slideInVerticallyComposable(Route.PLAYLIST_METADATA_PAGE) { - PlaylistMetadataPage( - onBackPressed, - //TODO: ADD THE ABILITY TO PASS JUST SONGS AND NOT GET THEM FROM THE MUTABLE STATE - ) - } - animatedComposable(Route.MODS_DOWNLOADER) { - ModsDownloaderPage( - onBackPressed, - modsDownloaderViewModel - ) - } - animatedComposable(Route.COOKIE_PROFILE) { - CookieProfilePage( - cookiesViewModel = cookiesViewModel, - navigateToCookieGeneratorPage = { navController.navigate(Route.COOKIE_GENERATOR_WEBVIEW) }, - ) { onBackPressed() } - } - animatedComposable( - Route.COOKIE_GENERATOR_WEBVIEW - ) { - WebViewPage(cookiesViewModel) { onBackPressed() } - } - animatedComposable(Route.UPDATER_PAGE) { - UpdaterPage( - onBackPressed - ) - } - animatedComposable(Route.DOCUMENTATION) { - DocumentationPage( - onBackPressed, - navController - ) - } - animatedComposable(Route.ABOUT) { - AboutPage { - onBackPressed() - } - } + //Can add the downloads history bottom sheet here using `val downloadsHistoryViewModel = hiltViewModel()` + navigation(startDestination = Route.SEARCHER, route = Route.SearcherNavi) { + animatedComposableVariant(Route.SEARCHER) { + SearcherPage( + navController = navController + ) + } - animatedComposable(Route.LANGUAGES) { - LanguagePage { - onBackPressed() - } - } - navDeepLink { - // Want to go to "markdown_viewer/{markdownFileName}" - uriPattern = "android-app://androidx.navigation/markdown_viewer/{markdownFileName}" - } + //create a deeplink to the playlist page passing the id of the playlist + navDeepLink { + // Want to go to "markdown_viewer/{markdownFileName}" + uriPattern = + StringBuilder().append(navRootUrl).append(Route.PLAYLIST_PAGE) + .append("/{type}") + .append("/{id}").toString() + } - animatedComposable( - "markdown_viewer/{markdownFileName}", - arguments = listOf(navArgument("markdownFileName") { type = NavType.StringType }) - ) { backStackEntry -> - val mdFileName = backStackEntry.arguments?.getString("markdownFileName") ?: "" - Log.d("MainActivity", mdFileName) - MarkdownViewerPage( - markdownFileName = mdFileName, - onBackPressed = onBackPressed - ) - } + //We create the arguments for the route + val typeArg = navArgument("type") { + type = NavType.StringType + } + + val idArg = navArgument("id") { + type = NavType.StringType + } + + + //We build the route with the type of the destination and the id of it + val routeWithIdPattern: String = + StringBuilder().append(Route.PLAYLIST_PAGE).append("/{type}") + .append("/{id}").toString() - //DIALOGS - //TODO: ADD DIALOGS - dialog(Route.AUDIO_QUALITY_DIALOG) { - AudioQualityDialog( - onBackPressed - ) + //We create the composable with the route and the arguments + animatedComposableVariant( + routeWithIdPattern, + arguments = listOf(typeArg, idArg) + ) { backStackEntry -> + val id = + backStackEntry.arguments?.getString("id") ?: "SOMETHING WENT WRONG" + val type = backStackEntry.arguments?.getString("type") + ?: "SOMETHING WENT WRONG" + + PlaylistPage( + onBackPressed, + id = id, + type = type, + playlistPageViewModel = playlistPageViewModel, + ) + } + } + + navigation( + startDestination = Route.DOWNLOAD_TASKS, + route = Route.DownloadTasksNavi + ) { + + animatedComposable( + Route.FULLSCREEN_LOG arg "taskHashCode", + arguments = listOf(navArgument("taskHashCode") { + type = NavType.IntType + } + )) { + + FullscreenConsoleOutput( + onBackPressed = onBackPressed, + taskHashCode = it.arguments?.getInt("taskHashCode") ?: -1 + ) + } + + animatedComposable(Route.DOWNLOAD_TASKS) { + DownloadTasksPage( + onNavigateToDetail = { navController.navigate(Route.FULLSCREEN_LOG id it) } + ) + } + } + } } } } -//} + //INIT SPOTIFY API LaunchedEffect(Unit) { - if (!SPOTDL_UPDATE.getBoolean()) return@LaunchedEffect runCatching { - withContext(Dispatchers.IO) { - val res = UpdateUtil.updateSpotDL() - if (res == SpotDL.UpdateStatus.DONE) { - ToastUtil.makeToastSuspend(context.getString(R.string.spotDl_uptodate) + " (${SPOTDL.getString()})") - } - } + SpotifyApiRequests.provideSpotifyApi() }.onFailure { it.printStackTrace() + ToastUtil.makeToastSuspend(context.getString(R.string.spotify_api_error)) } } + LaunchedEffect(Unit) { - launch(Dispatchers.IO) { + if (PreferencesUtil.isNetworkAvailable()) launch(Dispatchers.IO) { runCatching { - //TODO: Add check for updates of spotDL UpdateUtil.checkForUpdate()?.let { latestRelease = it showUpdateDialog = true } - if(showUpdateDialog){ + if (showUpdateDialog) { UpdateUtil.showUpdateDrawer() } }.onFailure { it.printStackTrace() + ToastUtil.makeToastSuspend(context.getString(R.string.update_check_failed)) } } } LaunchedEffect(Unit) { - Log.d(TAG, "InitialEntry: Checking for updates") - ModsDownloaderAPI.callModsAPI().onFailure { - ToastUtil.makeToastSuspend(App.context.getString(R.string.api_call_failed)) - }.onSuccess { - modsDownloaderViewModel.updateApiResponse(it) + Log.d(TAG, "InitialEntry: Checking for mod updates") + if (PreferencesUtil.isNetworkAvailable()) ModsDownloaderAPI.getAPIResponse() + .onSuccess { + Log.d(TAG, "InitialEntry: Mods API call success") + modsDownloaderViewModel.updateApiResponse(it) + }.onFailure { + ToastUtil.makeToastSuspend(App.context.getString(R.string.api_call_failed)) + } + } + + LaunchedEffect(Unit) { + if (SPOTDL.getString().isNotEmpty()) return@LaunchedEffect + kotlin.runCatching { + withContext(Dispatchers.IO) { + val result = UpdateUtil.updateSpotDL() + if (result == SpotDL.UpdateStatus.DONE) { + ToastUtil.makeToastSuspend( + App.context.getString(R.string.spotdl_update_success) + .format(SPOTDL.getString()) + ) + } + } } } if (showUpdateDialog) { - /*UpdateDialogImpl( - onDismissRequest = { - showUpdateDialog = false - updateJob?.cancel() - }, - title = latestRelease.name.toString(), - onConfirmUpdate = { - updateJob = scope.launch(Dispatchers.IO) { - runCatching { - UpdateUtil.downloadApk(latestRelease = latestRelease) - .collect { downloadStatus -> - currentDownloadStatus = downloadStatus - if (downloadStatus is UpdateUtil.DownloadStatus.Finished) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - launcher.launch(Manifest.permission.REQUEST_INSTALL_PACKAGES) - } - } - } - }.onFailure { - it.printStackTrace() - currentDownloadStatus = UpdateUtil.DownloadStatus.NotYet - ToastUtil.makeToastSuspend(context.getString(R.string.app_update_failed)) - return@launch - } - } - }, - releaseNote = latestRelease.body.toString(), - downloadStatus = currentDownloadStatus - )*/ UpdaterBottomDrawer(latestRelease = latestRelease) } } -@OptIn(ExperimentalAnimationApi::class) -private fun buildAnimationForward(scope: AnimatedContentScope): Boolean { - val isRoute = getStartingRoute(scope.initialState.destination) - val tsRoute = getStartingRoute(scope.targetState.destination) - - val isIndex = MainActivity.showInBottomNavigation.keys.indexOfFirst { it == isRoute } - val tsIndex = MainActivity.showInBottomNavigation.keys.indexOfFirst { it == tsRoute } - - return tsIndex == -1 || isRoute == tsRoute || tsIndex > isIndex -} - -private fun getStartingRoute(destination: NavDestination): String { - return destination.hierarchy.toList().let { it[it.lastIndex - 1] }.route.orEmpty() -} - //TODO: Separate the SettingsPage into a different NavGraph (like Seal) \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/ErrorPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/ErrorPage.kt new file mode 100644 index 00000000..4be71eb0 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/ErrorPage.kt @@ -0,0 +1,69 @@ +package com.bobbyesp.spowlo.ui.pages.commonPages + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.bobbyesp.spowlo.R + +@Composable +fun ErrorPage( + onReload: () -> Unit, + exception: String, + modifier: Modifier +) { + val clipboard = LocalClipboardManager.current + + Box(modifier) { + Column( + Modifier + .align(Alignment.Center) + ) { + Icon( + Icons.Rounded.Error, contentDescription = null, modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(56.dp) + .padding(bottom = 12.dp) + ) + Text( + stringResource(id = R.string.searching_error), + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + ) { + OutlinedButton( + onClick = { + clipboard.setText(AnnotatedString("Message: ${exception}\n\n")) + }) { + Text(stringResource(id = R.string.error_copy)) + } + + Spacer(modifier = Modifier.width(8.dp)) + + OutlinedButton( + onClick = { onReload() }) { + Text(stringResource(id = R.string.err_act_reload)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/LoadingPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/LoadingPage.kt new file mode 100644 index 00000000..de1c6b4b --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/LoadingPage.kt @@ -0,0 +1,44 @@ +package com.bobbyesp.spowlo.ui.pages.commonPages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.bobbyesp.spowlo.R + +@Composable +fun LoadingPage() { + //create a loading page + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + modifier = Modifier + .size(72.dp) + .padding(6.dp), + strokeWidth = 4.dp + ) + Text(text = stringResource(id = R.string.page_loading), modifier = Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/NotImplementedPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/NotImplementedPage.kt new file mode 100644 index 00000000..020786fc --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/commonPages/NotImplementedPage.kt @@ -0,0 +1,38 @@ +package com.bobbyesp.spowlo.ui.pages.commonPages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.bobbyesp.spowlo.R + +@Composable +fun NotImplementedPage( +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.not_implemented), modifier = Modifier.align( + Alignment.CenterHorizontally + ), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold + ) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/download_tasks/DownloadTasksPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/download_tasks/DownloadTasksPage.kt new file mode 100644 index 00000000..233558a4 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/download_tasks/DownloadTasksPage.kt @@ -0,0 +1,128 @@ +package com.bobbyesp.spowlo.ui.pages.download_tasks + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bobbyesp.spowlo.Downloader +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.components.HorizontalDivider +import com.bobbyesp.spowlo.ui.components.download_tasks.DownloadingTaskItem +import com.bobbyesp.spowlo.ui.components.download_tasks.TaskState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DownloadTasksPage(onNavigateToDetail: (Int) -> Unit) { + + val scope = rememberCoroutineScope() + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + Scaffold(modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar(title = { + Text( + text = stringResource(R.string.download_tasks), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp) + ) + }, actions = { + }, scrollBehavior = scrollBehavior + ) + }) { paddings -> + val clipboardManager = LocalClipboardManager.current + LazyColumn( + modifier = Modifier.padding(paddings), contentPadding = PaddingValues(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(Downloader.mutableTaskList.values.toList()) { + it.run { + DownloadingTaskItem( + status = state.toStatus(), + progress = if (state is Downloader.DownloadTask.State.Running) state.progress else 0f, + progressText = currentLine, + url = url, + header = it.taskName, + onCopyError = { + onCopyError(clipboardManager) + }, + onCancel = { + onCancel() + }, + onRestart = { + onRestart() + }, onCopyLog = { + onCopyLog(clipboardManager) + }, onShowLog = { + onNavigateToDetail(hashCode()) + }, + onCopyLink = { + onCopyUrl(clipboardManager) + } + ) + } + } + } + if (Downloader.mutableTaskList.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.no_running_downloads), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + HorizontalDivider( + modifier = Modifier.padding( + vertical = 24.dp, + horizontal = 4.dp + ) + ) + Text( + text = stringResource(R.string.no_running_downloads_description), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + } + } +} + +private fun Downloader.DownloadTask.State.toStatus(): TaskState = when (this) { + Downloader.DownloadTask.State.Completed -> TaskState.FINISHED + is Downloader.DownloadTask.State.Error -> TaskState.ERROR + is Downloader.DownloadTask.State.Running -> TaskState.RUNNING + else -> { + TaskState.ERROR + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/download_tasks/FullscreenConsoleOutput.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/download_tasks/FullscreenConsoleOutput.kt new file mode 100644 index 00000000..6d67ed35 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/download_tasks/FullscreenConsoleOutput.kt @@ -0,0 +1,178 @@ +package com.bobbyesp.spowlo.ui.pages.download_tasks + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bobbyesp.spowlo.Downloader +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.components.ButtonChip + +private const val TAG = "TaskLogPage" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FullscreenConsoleOutput( + onBackPressed: () -> Unit, taskHashCode: Int +) { + Log.d(TAG, "TaskLogPage: $taskHashCode") + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val task = Downloader.mutableTaskList.values.find { it.hashCode() == taskHashCode } ?: return + val clipboardManager = LocalClipboardManager.current + + val minFontSize = 8 + val maxFontSize = 32 + + var mutableFontSize by remember { mutableStateOf(14) } + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar(title = { + Text( + text = stringResource(R.string.download_log), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp) + ) + }, navigationIcon = { + IconButton(onClick = { onBackPressed() }) { + Icon(Icons.Outlined.Close, stringResource(R.string.close)) + } + }, actions = { + }, scrollBehavior = scrollBehavior + ) + }, bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.Center + ) { + Divider(modifier = Modifier.fillMaxWidth()) + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + task.run { + ButtonChip( + icon = Icons.Outlined.ContentCopy, + label = stringResource(id = R.string.copy_log) + ) { + onCopyLog(clipboardManager) + } + Row( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background( + color = Color.Transparent, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(id = R.string.font_size), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = mutableFontSize.toString(), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace + ) + ButtonChip( + label = "-", + onClick = { mutableFontSize = (mutableFontSize - 2).coerceIn(minFontSize, maxFontSize) } + + ) + ButtonChip( + label = "+", + onClick = { mutableFontSize = (mutableFontSize + 2).coerceIn(minFontSize, maxFontSize) } + ) + } + if (state is Downloader.DownloadTask.State.Error) + ButtonChip( + icon = Icons.Outlined.ErrorOutline, + label = stringResource(id = R.string.copy_error_report), + iconColor = MaterialTheme.colorScheme.error, + ) { + onCopyError(clipboardManager) + } + if (state is Downloader.DownloadTask.State.Canceled) + ButtonChip( + icon = Icons.Outlined.RestartAlt, + label = stringResource(id = R.string.restart), + ) { + onRestart() + } + } + } + } + }) { paddings -> + val scrollState = rememberScrollState() + LaunchedEffect(key1 = scrollState.maxValue) { + scrollState.animateScrollTo(scrollState.maxValue) + } + Column( + modifier = Modifier + .padding(paddings) + .padding(horizontal = 24.dp) + .verticalScroll(scrollState) + .horizontalScroll(rememberScrollState()) + ) { + SelectionContainer { + Text( + modifier = Modifier.widthIn(max = 800.dp), + text = task.consoleOutput, + fontSize = mutableFontSize.sp, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/DownloaderPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/DownloaderPage.kt index fd7bb438..bf63d34c 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/DownloaderPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/DownloaderPage.kt @@ -2,6 +2,7 @@ package com.bobbyesp.spowlo.ui.pages.downloader import android.Manifest import android.os.Build +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing @@ -30,13 +31,15 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.ContentAlpha import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FormatListBulleted +import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.ContentPaste import androidx.compose.material.icons.outlined.Error import androidx.compose.material.icons.outlined.FileDownload -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Subscriptions import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -45,7 +48,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -74,7 +79,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import com.bobbyesp.spowlo.App import com.bobbyesp.spowlo.Downloader import com.bobbyesp.spowlo.R @@ -85,9 +89,10 @@ import com.bobbyesp.spowlo.ui.components.NavigationBarSpacer import com.bobbyesp.spowlo.ui.components.songs.SongCard import com.bobbyesp.spowlo.ui.dialogs.DownloaderSettingsDialog import com.bobbyesp.spowlo.ui.pages.settings.about.LocalAsset +import com.bobbyesp.spowlo.ui.theme.harmonizeWith import com.bobbyesp.spowlo.utils.CONFIGURE -import com.bobbyesp.spowlo.utils.CUSTOM_COMMAND import com.bobbyesp.spowlo.utils.DEBUG +import com.bobbyesp.spowlo.utils.NOTIFICATION import com.bobbyesp.spowlo.utils.PreferencesUtil import com.bobbyesp.spowlo.utils.PreferencesUtil.getBoolean import com.bobbyesp.spowlo.utils.ToastUtil @@ -98,16 +103,16 @@ import com.google.accompanist.permissions.rememberPermissionState @Composable @OptIn( - ExperimentalPermissionsApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class + ExperimentalPermissionsApi::class, + ExperimentalComposeUiApi::class, + ExperimentalMaterialApi::class ) fun DownloaderPage( navigateToSettings: () -> Unit = {}, navigateToDownloads: () -> Unit = {}, - navigateToPlaylistPage: () -> Unit = {}, + navigateToDownloaderSheet: () -> Unit = {}, onSongCardClicked: () -> Unit = {}, - onNavigateToTaskList: () -> Unit = {}, navigateToMods: () -> Unit = {}, - navController: NavController, downloaderViewModel: DownloaderViewModel = hiltViewModel(), ) { val scope = rememberCoroutineScope() @@ -121,6 +126,30 @@ fun DownloaderPage( } } + val notificationsPermission = rememberPermissionState( + permission = Manifest.permission.ACCESS_NOTIFICATION_POLICY + ) { b: Boolean -> + Log.d("DownloaderPage", "notificationsPermission: $b") + if (b) { + PreferencesUtil.updateValue(NOTIFICATION, true) + } else { + PreferencesUtil.updateValue(NOTIFICATION, false) + ToastUtil.makeToast(R.string.permission_denied) + } + } + + val modernNotificationPermission = rememberPermissionState( + permission = Manifest.permission.POST_NOTIFICATIONS + ) { b: Boolean -> + Log.d("DownloaderPage", "modernNotificationPermission: $b") + if (b) { + PreferencesUtil.updateValue(NOTIFICATION, true) + } else { + PreferencesUtil.updateValue(NOTIFICATION, false) + ToastUtil.makeToast(R.string.permission_denied) + } + } + //STATE FLOWS val viewState by downloaderViewModel.viewStateFlow.collectAsStateWithLifecycle() val downloaderState by Downloader.downloaderState.collectAsStateWithLifecycle() @@ -133,20 +162,30 @@ fun DownloaderPage( val keyboardController = LocalSoftwareKeyboardController.current val checkPermissionOrDownload = { - if (Build.VERSION.SDK_INT > 29 || storagePermission.status == PermissionStatus.Granted) - downloaderViewModel.startDownloadSong() + if (Build.VERSION.SDK_INT > 29 || storagePermission.status == PermissionStatus.Granted) downloaderViewModel.startDownloadSong() else { storagePermission.launchPermissionRequest() } } val downloadCallback = { - if (CONFIGURE.getBoolean()) downloaderViewModel.showDialog( - scope, - useDialog - ) + if (CONFIGURE.getBoolean()) navigateToDownloaderSheet() else checkPermissionOrDownload() keyboardController?.hide() + if(NOTIFICATION.getBoolean()){ + when(Build.VERSION.SDK_INT){ + in 23..31 -> { + if(notificationsPermission.status != PermissionStatus.Granted){ + notificationsPermission.launchPermissionRequest() + } + } + in 32..Int.MAX_VALUE -> { + if(modernNotificationPermission.status != PermissionStatus.Granted){ + modernNotificationPermission.launchPermissionRequest() + } + } + } + } } val songCardClicked = { @@ -174,16 +213,17 @@ fun DownloaderPage( .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - DownloaderPageImplementation( - downloaderState = downloaderState, + DownloaderPageImplementation(downloaderState = downloaderState, taskState = taskState, viewState = viewState, errorState = errorState, downloadCallback = { downloadCallback() }, - navigateToSettings = navigateToSettings, + navigateToSettings = { + navigateToSettings() + keyboardController?.hide() + }, navigateToDownloads = navigateToDownloads, navigateToMods = navigateToMods, - onNavigateToTaskList = onNavigateToTaskList, onSongCardClicked = { songCardClicked() }, showOutput = showConsoleOutput, showSongCard = true, @@ -191,9 +231,7 @@ fun DownloaderPage( pasteCallback = { matchUrlFromClipboard( string = clipboardManager.getText().toString(), - isMatchingMultiLink = CUSTOM_COMMAND.getBoolean() - ) - .let { downloaderViewModel.updateUrl(it) } + ).let { downloaderViewModel.updateUrl(it) } }, cancelCallback = { Downloader.cancelDownload() @@ -201,15 +239,12 @@ fun DownloaderPage( onUrlChanged = { url -> downloaderViewModel.updateUrl(url) }) {} with(viewState) { - DownloaderSettingsDialog( - useDialog = useDialog, + DownloaderSettingsDialog(useDialog = useDialog, dialogState = showDownloadSettingDialog, drawerState = drawerState, - navController = navController, confirm = { checkPermissionOrDownload() }, onRequestMetadata = { downloaderViewModel.requestMetadata() }, - hide = { downloaderViewModel.hideDialog(scope, useDialog) } - ) + hide = { downloaderViewModel.hideDialog(scope, useDialog) }) } } } @@ -230,7 +265,6 @@ fun DownloaderPageImplementation( navigateToSettings: () -> Unit = {}, navigateToDownloads: () -> Unit = {}, navigateToMods: () -> Unit = {}, - onNavigateToTaskList: () -> Unit = {}, pasteCallback: () -> Unit = {}, cancelCallback: () -> Unit = {}, onSongCardClicked: () -> Unit = {}, @@ -239,32 +273,31 @@ fun DownloaderPageImplementation( content: @Composable () -> Unit ) { Scaffold(modifier = Modifier.fillMaxSize(), topBar = { - TopAppBar(title = {}, modifier = Modifier.padding(horizontal = 8.dp), - navigationIcon = { - IconButton(onClick = { navigateToSettings() }) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(id = R.string.settings) - ) - } - }, actions = { - IconButton(onClick = { navigateToMods() }) { - Icon( - imageVector = LocalAsset(id = R.drawable.spotify_logo), - contentDescription = stringResource(id = R.string.mods_downloader) - ) - } + TopAppBar(title = {}, modifier = Modifier.padding(horizontal = 8.dp), navigationIcon = { + IconButton(onClick = { navigateToSettings() }) { + Icon( + imageVector = Icons.Filled.FormatListBulleted, + contentDescription = stringResource(id = R.string.show_more_actions) + ) + } + }, actions = { + IconButton(onClick = { navigateToMods() }) { + Icon( + imageVector = LocalAsset(id = R.drawable.spotify_logo), + contentDescription = stringResource(id = R.string.mods_downloader) + ) + } - IconButton(onClick = { navigateToDownloads() }) { - Icon( - imageVector = Icons.Outlined.Subscriptions, - contentDescription = stringResource(id = R.string.downloads_history) - ) - } - }) + IconButton(onClick = { navigateToDownloads() }) { + Icon( + imageVector = Icons.Filled.LibraryMusic, + contentDescription = stringResource(id = R.string.downloads_history) + ) + } + }) }, floatingActionButton = { FABs( - modifier = with(receiver = Modifier) { if (showDownloadProgress) this else this.imePadding() }, + modifier = with(Modifier) { if (showDownloadProgress) this else this.imePadding() }, downloadCallback = downloadCallback, pasteCallback = pasteCallback, cancelCallback = cancelCallback, @@ -308,12 +341,10 @@ fun DownloaderPageImplementation( ) } AnimatedVisibility( - visible = downloaderState !is Downloader.State.Idle, - modifier = Modifier + visible = downloaderState !is Downloader.State.Idle, modifier = Modifier ) { CircularProgressIndicator( - modifier = Modifier - .size(24.dp), + modifier = Modifier.size(24.dp), strokeWidth = 3.dp, ) } @@ -321,15 +352,12 @@ fun DownloaderPageImplementation( with(taskState) { AnimatedVisibility(visible = showSongCard && showDownloadProgress) { Column(modifier = Modifier.fillMaxWidth()) { - SongCard( - song = info, + SongCard(song = info, progress = progress, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), - isLyrics = info.lyrics?.isNotEmpty() - ?: false, + isLyrics = hasLyrics, isExplicit = info.explicit, - onClick = { onSongCardClicked() } - ) + onClick = { onSongCardClicked() }) Text( text = stringResource(id = R.string.click_card_metadata), modifier = Modifier @@ -353,8 +381,7 @@ fun DownloaderPageImplementation( visible = progressText.isNotEmpty() && showOutput ) { ConsoleOutputComponent( - consoleOutput = progressText, - modifier = Modifier.padding(top = 10.dp) + consoleOutput = progressText, modifier = Modifier.padding(top = 10.dp) ) } @@ -380,45 +407,46 @@ fun FABs( downloadCallback: () -> Unit = {}, pasteCallback: () -> Unit = {}, cancelCallback: () -> Unit = {}, - isDownloading : Boolean = false + isDownloading: Boolean = false ) { Column( modifier = modifier.padding(6.dp), horizontalAlignment = Alignment.End ) { - FloatingActionButton( - onClick = pasteCallback, - content = { - Icon( - Icons.Outlined.ContentPaste, contentDescription = stringResource(R.string.paste) - ) - }, - modifier = Modifier.padding(vertical = 12.dp), + ExtendedFloatingActionButton(onClick = pasteCallback, text = { + Text(stringResource(R.string.paste)) + }, icon = { + Icon( + Icons.Outlined.ContentPaste, contentDescription = stringResource(R.string.paste) + ) + }, modifier = Modifier.padding(vertical = 12.dp) ) - FloatingActionButton( - onClick = downloadCallback, content = { + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedVisibility(visible = isDownloading) { + FloatingActionButton( + onClick = cancelCallback, + content = { + Icon( + Icons.Outlined.Cancel, + contentDescription = stringResource(R.string.cancel_download) + ) + }, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + ExtendedFloatingActionButton(onClick = downloadCallback, text = { + Text(stringResource(R.string.download)) + }, icon = { Icon( Icons.Outlined.FileDownload, contentDescription = stringResource(R.string.download) ) - }, modifier = Modifier.padding(vertical = 12.dp) - ) + }, modifier = Modifier.padding(vertical = 12.dp)) + } - /*AnimatedVisibility(visible = isDownloading) { - ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.cancel)) }, - onClick = cancelCallback, icon = { - Icon( - Icons.Outlined.Cancel, - contentDescription = stringResource(R.string.cancel_download) - ) - }, modifier = Modifier.padding(vertical = 12.dp) - ) - }*/ } - } -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable fun InputUrl( url: String, @@ -444,13 +472,24 @@ fun InputUrl( maxLines = 3, trailingIcon = { if (url.isNotEmpty()) ClearButton { onValueChange("") } -// else PasteUrlButton { onPaste() } - }, keyboardActions = KeyboardActions(onDone = { + }, + keyboardActions = KeyboardActions(onDone = { softwareKeyboardController?.hide() focusManager.moveFocus(FocusDirection.Down) onDone() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + colors = TextFieldDefaults.outlinedTextFieldColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( + 8.dp + ), + unfocusedBorderColor = MaterialTheme.colorScheme.surfaceVariant, + errorContainerColor = MaterialTheme.colorScheme.errorContainer.harmonizeWith( + other = MaterialTheme.colorScheme.surfaceColorAtElevation( + 8.dp + ) + ), + ), ) AnimatedVisibility(visible = showDownloadProgress) { Row( @@ -459,7 +498,8 @@ fun InputUrl( ) { val progressAnimationValue by animateFloatAsState( targetValue = progress / 100f, - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + label = "" ) if (progressAnimationValue < 0) LinearProgressIndicator( modifier = Modifier diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/DownloaderViewModel.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/DownloaderViewModel.kt index cec1725e..83a249cc 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/DownloaderViewModel.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/DownloaderViewModel.kt @@ -8,7 +8,6 @@ import com.bobbyesp.library.dto.Song import com.bobbyesp.spowlo.Downloader import com.bobbyesp.spowlo.Downloader.showErrorMessage import com.bobbyesp.spowlo.R -import com.bobbyesp.spowlo.utils.DownloaderUtil import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -24,7 +23,7 @@ class DownloaderViewModel @Inject constructor() : ViewModel() { private val mutableViewStateFlow = MutableStateFlow(ViewState()) val viewStateFlow = mutableViewStateFlow.asStateFlow() - val songInfoFlow = MutableStateFlow(listOf(Song())) + private val songInfoFlow = MutableStateFlow(listOf(Song())) data class ViewState( val url: String = "", @@ -68,7 +67,8 @@ class DownloaderViewModel @Inject constructor() : ViewModel() { Downloader.getRequestedMetadata(url) } - fun startDownloadSong() { + fun startDownloadSong(skipInfoFetch: Boolean = false) { + val url = viewStateFlow.value.url Downloader.clearErrorState() if (!Downloader.isDownloaderAvailable()) @@ -77,7 +77,8 @@ class DownloaderViewModel @Inject constructor() : ViewModel() { showErrorMessage(R.string.url_empty) return } - Downloader.getInfoAndDownload(url) + //request notification permission + Downloader.getInfoAndDownload(url, skipInfoFetch = skipInfoFetch) } fun goToMetadataViewer(songs: List) { diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/FullscreenConsoleOutput.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/FullscreenConsoleOutput.kt deleted file mode 100644 index d71ca0bf..00000000 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/downloader/FullscreenConsoleOutput.kt +++ /dev/null @@ -1,2 +0,0 @@ -package com.bobbyesp.spowlo.ui.pages.downloader - diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/history/DownloadHistoryBottomDrawer.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/history/DownloadHistoryBottomDrawer.kt index 598e99e6..34ddcd24 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/history/DownloadHistoryBottomDrawer.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/history/DownloadHistoryBottomDrawer.kt @@ -3,6 +3,7 @@ package com.bobbyesp.spowlo.ui.pages.history import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -144,9 +145,9 @@ fun DownloadHistoryBottomDrawerImpl( val clipboardManager = LocalClipboardManager.current val context = LocalContext.current - BottomDrawer(drawerState = drawerState, sheetContent = { + BottomDrawer(modifier = Modifier.animateContentSize(),drawerState = drawerState, sheetContent = { AnimatedVisibility(visible = !showDeleteInfo) { - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().animateContentSize()) { Row( modifier = Modifier .fillMaxWidth(), @@ -232,7 +233,8 @@ fun DownloadHistoryBottomDrawerImpl( Column( modifier = Modifier .fillMaxWidth() - .padding(6.dp), + .padding(6.dp) + .animateContentSize(), horizontalAlignment = Alignment.Start ) { Row( @@ -278,7 +280,7 @@ fun DownloadHistoryBottomDrawerImpl( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(top = 24.dp), + .padding(top = 24.dp).animateContentSize(), ) { OutlinedButtonWithIcon( modifier = Modifier diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/history/DownloadsHistoryPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/history/DownloadsHistoryPage.kt index aa03eb72..790088ae 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/history/DownloadsHistoryPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/history/DownloadsHistoryPage.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,9 +24,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DownloadForOffline import androidx.compose.material.icons.outlined.Checklist import androidx.compose.material.icons.outlined.DeleteSweep -import androidx.compose.material.icons.outlined.DownloadForOffline import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api @@ -58,6 +57,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -69,6 +69,7 @@ import com.bobbyesp.spowlo.ui.components.AudioFilterChip import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.ConfirmButton import com.bobbyesp.spowlo.ui.components.DismissButton +import com.bobbyesp.spowlo.ui.components.HorizontalDivider import com.bobbyesp.spowlo.ui.components.LargeTopAppBar import com.bobbyesp.spowlo.ui.components.MultiChoiceItem import com.bobbyesp.spowlo.ui.components.SpowloDialog @@ -102,10 +103,9 @@ fun DownloadsHistoryPage( Log.d("DownloadsHistoryPage", songsList.toString()) } - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( - rememberTopAppBarState(), - canScroll = { true } - ) + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState(), + canScroll = { true }) val scope = rememberCoroutineScope() val fileSizeMap = remember(songsList.size) { @@ -158,11 +158,9 @@ fun DownloadsHistoryPage( remember(songsList, isSelectEnabled, viewState) { mutableStateListOf() } val selectedFiles = remember(selectedItemIds.size) { - mutableStateOf( - songsList.count { info -> - selectedItemIds.contains(info.id) - } - ) + mutableStateOf(songsList.count { info -> + selectedItemIds.contains(info.id) + }) } val selectedFileSizeSum by remember(selectedItemIds.size) { @@ -179,12 +177,9 @@ fun DownloadsHistoryPage( val checkBoxState by remember(selectedItemIds, visibleItemCount) { derivedStateOf { - if (selectedItemIds.isEmpty()) - ToggleableState.Off - else if (selectedItemIds.size == visibleItemCount.value && selectedItemIds.isNotEmpty()) - ToggleableState.On - else - ToggleableState.Indeterminate + if (selectedItemIds.isEmpty()) ToggleableState.Off + else if (selectedItemIds.size == visibleItemCount.value && selectedItemIds.isNotEmpty()) ToggleableState.On + else ToggleableState.Indeterminate } } @@ -192,95 +187,88 @@ fun DownloadsHistoryPage( isSelectEnabled = false } - Scaffold( - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - LargeTopAppBar( - title = { - Text( - modifier = Modifier, - text = stringResource(R.string.downloads_history) - ) - }, - navigationIcon = { - BackButton { - onBackPressed() - } - }, actions = { - Row(){ - IconToggleButton( - modifier = Modifier, - onCheckedChange = { isSelectEnabled = !isSelectEnabled }, - checked = isSelectEnabled, - enabled = songsList.isNotEmpty() - ) { - Icon( - Icons.Outlined.Checklist, - contentDescription = stringResource(R.string.multiselect_mode) - ) - } - } - }, scrollBehavior = scrollBehavior + Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { + LargeTopAppBar(title = { + Text( + modifier = Modifier, text = stringResource(R.string.downloads_history) ) - }, bottomBar = { - AnimatedVisibility( - isSelectEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - BottomAppBar( - modifier = Modifier + }, navigationIcon = { + BackButton { + onBackPressed() + } + }, actions = { + Row { + IconToggleButton( + modifier = Modifier, + onCheckedChange = { isSelectEnabled = !isSelectEnabled }, + checked = isSelectEnabled, + enabled = songsList.isNotEmpty() ) { - val selectAllText = stringResource(R.string.select_all) - TriStateCheckbox( - modifier = Modifier.semantics { - this.contentDescription = selectAllText - }, - state = checkBoxState, - onClick = { - when (checkBoxState) { - ToggleableState.On -> selectedItemIds.clear() - else -> { - for (item in songsList) { - if (!selectedItemIds.contains(item.id) - && item.filterSort(viewState) - ) { - selectedItemIds.add(item.id) - } + Icon( + Icons.Outlined.Checklist, + contentDescription = stringResource(R.string.multiselect_mode) + ) + } + } + }, scrollBehavior = scrollBehavior + ) + }, bottomBar = { + AnimatedVisibility( + isSelectEnabled, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + BottomAppBar( + modifier = Modifier + ) { + val selectAllText = stringResource(R.string.select_all) + TriStateCheckbox( + modifier = Modifier.semantics { + this.contentDescription = selectAllText + }, + state = checkBoxState, + onClick = { + when (checkBoxState) { + ToggleableState.On -> selectedItemIds.clear() + else -> { + for (item in songsList) { + if (!selectedItemIds.contains(item.id) && item.filterSort( + viewState + ) + ) { + selectedItemIds.add(item.id) } } } - }, - ) - Text( - modifier = Modifier.weight(1f), - text = stringResource(R.string.multiselect_item_count).format( - selectedFiles.value, - ), - style = MaterialTheme.typography.labelLarge + } + }, + ) + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.multiselect_item_count).format( + selectedFiles.value, + ), + style = MaterialTheme.typography.labelLarge + ) + IconButton( + onClick = { showRemoveMultipleItemsDialog = true }, + enabled = selectedItemIds.isNotEmpty() + ) { + Icon( + imageVector = Icons.Outlined.DeleteSweep, + contentDescription = stringResource(id = R.string.remove) ) - IconButton( - onClick = { showRemoveMultipleItemsDialog = true }, - enabled = selectedItemIds.isNotEmpty() - ) { - Icon( - imageVector = Icons.Outlined.DeleteSweep, - contentDescription = stringResource(id = R.string.remove) - ) - } } } } - ) { innerPaddings -> + }) { innerPaddings -> val cellCount = when (LocalWindowWidthState.current) { WindowWidthSizeClass.Expanded -> 2 else -> 1 } val span: (LazyGridItemSpanScope) -> GridItemSpan = { GridItemSpan(cellCount) } LazyVerticalGrid( - modifier = Modifier - .padding(innerPaddings), columns = GridCells.Fixed(cellCount) + modifier = Modifier.padding(innerPaddings), columns = GridCells.Fixed(cellCount) ) { if (filterSet.size > 1) { item { @@ -300,7 +288,10 @@ fun DownloadsHistoryPage( if (songsList.isEmpty()) { item { - Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { EmptyState( modifier = Modifier .fillMaxSize() @@ -312,17 +303,14 @@ fun DownloadsHistoryPage( } for (song in songsList) { - item( - key = song.id, - contentType = { song.songPath.contains(AUDIO_REGEX) }) { + item(key = song.id, contentType = { song.songPath.contains(AUDIO_REGEX) }) { with(song) { AnimatedVisibility( visible = song.filterSort(viewState), exit = shrinkVertically() + fadeOut(), enter = expandVertically() + fadeIn() ) { - HistoryMediaItem( - modifier = Modifier, + HistoryMediaItem(modifier = Modifier, songName = songName, author = songAuthor, artworkUrl = thumbnailUrl, @@ -338,8 +326,12 @@ fun DownloadsHistoryPage( if (selectedItemIds.contains(id)) selectedItemIds.remove(id) else selectedItemIds.add(id) }, - onClick = { FilesUtil.openFile(songPath) } - ) { downloadsHistoryViewModel.showDrawer(scope, song) } + onClick = { FilesUtil.openFile(songPath) }) { + downloadsHistoryViewModel.showDrawer( + scope, + song + ) + } } } } @@ -349,16 +341,15 @@ fun DownloadsHistoryPage( DownloadHistoryBottomDrawer() if (showRemoveMultipleItemsDialog) { var deleteFile by remember { mutableStateOf(false) } - SpowloDialog( - onDismissRequest = { showRemoveMultipleItemsDialog = false }, + SpowloDialog(onDismissRequest = { showRemoveMultipleItemsDialog = false }, icon = { Icon(Icons.Outlined.DeleteSweep, null) }, - title = { Text(stringResource(R.string.delete_info)) }, text = { + title = { Text(stringResource(R.string.delete_info)) }, + text = { Column { Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - , + .padding(horizontal = 24.dp), text = stringResource(R.string.delete_multiple_items_msg).format( selectedFiles.value ) @@ -369,7 +360,8 @@ fun DownloadsHistoryPage( checked = deleteFile ) { deleteFile = !deleteFile } } - }, confirmButton = { + }, + confirmButton = { ConfirmButton { scope.launch { DatabaseUtil.deleteInfoListByIdList(selectedItemIds, deleteFile) @@ -377,12 +369,12 @@ fun DownloadsHistoryPage( showRemoveMultipleItemsDialog = false isSelectEnabled = false } - }, dismissButton = { + }, + dismissButton = { DismissButton { showRemoveMultipleItemsDialog = false } - } - ) + }) } } @@ -396,17 +388,17 @@ fun EmptyState(modifier: Modifier, text: String) { verticalArrangement = Arrangement.Center ) { Icon( - imageVector = Icons.Outlined.DownloadForOffline, + imageVector = Icons.Filled.DownloadForOffline, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(64.dp) + modifier = Modifier.size(72.dp) ) - Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(modifier = Modifier.height(16.dp).padding(horizontal = 32.dp)) Text( text = text, style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.secondary, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold ) } } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/binders/SpotifyInfoBinder.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/binders/SpotifyInfoBinder.kt new file mode 100644 index 00000000..d30e11a2 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/binders/SpotifyInfoBinder.kt @@ -0,0 +1,44 @@ +package com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.adamratzman.spotify.models.SimpleAlbum +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.features.spotify_api.model.SpotifyDataType + +//make a composable that has as parameter a SpotifyData and returns the type of the data as a string with a string resource +@Composable +fun typeOfDataToString(type: SpotifyDataType): String { + return when (type) { + SpotifyDataType.ALBUM -> stringResource(id = R.string.album) + SpotifyDataType.ARTIST -> stringResource(id = R.string.artist) + SpotifyDataType.PLAYLIST -> stringResource(id = R.string.playlist) + SpotifyDataType.TRACK -> stringResource(id = R.string.track) + } +} + +//Assign and return the type of the data from referred to the SpotifyDataType enum +fun typeOfSpotifyDataType(type: String): SpotifyDataType { + return when (type) { + "track" -> SpotifyDataType.TRACK + "album" -> SpotifyDataType.ALBUM + "playlist" -> SpotifyDataType.PLAYLIST + "artist" -> SpotifyDataType.ARTIST + else -> SpotifyDataType.TRACK + } +} + +@Composable +fun dataStringToString(data: String, additional: String): String { + return when (typeOfSpotifyDataType(data)) { + SpotifyDataType.ALBUM -> stringResource(id = R.string.album) + " • " + additional + SpotifyDataType.ARTIST -> stringResource(id = R.string.artist) + " • " + additional + SpotifyDataType.PLAYLIST -> stringResource(id = R.string.playlist) + " • " + additional + SpotifyDataType.TRACK -> stringResource(id = R.string.track) + " • " + additional + } +} + +//RELEASE DATE TO STRING +fun releaseDateToString(album: SimpleAlbum): String { + TODO() +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/binders/SpotifyPageBinder.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/binders/SpotifyPageBinder.kt new file mode 100644 index 00000000..6407227f --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/binders/SpotifyPageBinder.kt @@ -0,0 +1,68 @@ +package com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.adamratzman.spotify.models.Album +import com.adamratzman.spotify.models.Artist +import com.adamratzman.spotify.models.Playlist +import com.adamratzman.spotify.models.Track +import com.bobbyesp.spowlo.features.spotify_api.model.SpotifyDataType +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.pages.AlbumPage +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.pages.ArtistPage +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.pages.PlaylistViewPage +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.pages.TrackPage + +@Composable +fun SpotifyPageBinder( + data: Any, + type: SpotifyDataType, + modifier: Modifier = Modifier, + trackDownloadCallback: (String, String) -> Unit, +) { + + LazyColumn(modifier = modifier.padding(top = 6.dp), verticalArrangement = Arrangement.Top) { + + when (type) { + SpotifyDataType.ALBUM -> { + val album = data as? Album + item { + album?.let { + AlbumPage(album, modifier, trackDownloadCallback) + } + } + } + + SpotifyDataType.ARTIST -> { + val artist = data as? Artist + item { + artist?.let { + ArtistPage(artist, modifier) + } + } + } + + SpotifyDataType.PLAYLIST -> { + val playlist = data as? Playlist + item { + playlist?.let { + PlaylistViewPage(playlist, modifier, trackDownloadCallback) + } + } + + } + + SpotifyDataType.TRACK -> { + val track = data as? Track + item { + track?.let { + TrackPage(track, modifier, trackDownloadCallback) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/AlbumPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/AlbumPage.kt new file mode 100644 index 00000000..4ae9f81e --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/AlbumPage.kt @@ -0,0 +1,157 @@ +package com.bobbyesp.spowlo.ui.pages.metadata_viewer.pages + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.adamratzman.spotify.models.Album +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.common.AsyncImageImpl +import com.bobbyesp.spowlo.ui.components.HorizontalDivider +import com.bobbyesp.spowlo.ui.components.MarqueeText +import com.bobbyesp.spowlo.ui.components.songs.metadata_viewer.TrackComponent +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders.dataStringToString + +@Composable +fun AlbumPage( + data: Album, + modifier: Modifier, + trackDownloadCallback: (String, String) -> Unit +) { + val localConfig = LocalConfiguration.current + + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Top + ) { + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .fillMaxWidth() + .padding(bottom = 6.dp), + contentAlignment = Alignment.Center + ) { + //calculate the image size based on the screen size and the aspect ratio as 1:1 (square) based on the height + val size = (localConfig.screenHeightDp / 3) + AsyncImageImpl( + modifier = Modifier + .size(size.dp) + .aspectRatio( + 1f, matchHeightConstraintsFirst = true + ) + .clip(MaterialTheme.shapes.small), + model = data.images[0].url, + contentDescription = stringResource(id = R.string.track_artwork), + contentScale = ContentScale.Crop, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp) + ) { + SelectionContainer { + MarqueeText( + text = data.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineMedium + ) + } + Spacer(modifier = Modifier.height(6.dp)) + SelectionContainer { + Text( + text = data.artists.joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold + ), + modifier = Modifier.alpha(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(6.dp)) + SelectionContainer { + Text( + text = dataStringToString( + data = data.type, additional = data.releaseDate.year.toString(), + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(6.dp)) + if(data.externalUrls.spotify != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + FilledTonalIconButton( + onClick = { + trackDownloadCallback(data.externalUrls.spotify!!, data.name) + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = "Download full playlist icon", + modifier = Modifier + .weight(1f) + .padding(14.dp) + ) + } + + } + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) + if(data.tracks.size > 0) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + data.tracks.items.forEach { track -> + val taskName = StringBuilder().append(track.name).append(" - ").append( + track?.artists?.joinToString(", ") { it.name }).toString() + TrackComponent( + contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + songName = track.name, + artists = track.artists.joinToString(", ") { it.name }, + spotifyUrl = track.externalUrls.spotify ?: "", + isExplicit = track.explicit, + onClick = { + trackDownloadCallback( + track.externalUrls.spotify!!, + taskName + ) + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/ArtistPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/ArtistPage.kt new file mode 100644 index 00000000..8f7947a1 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/ArtistPage.kt @@ -0,0 +1,14 @@ +package com.bobbyesp.spowlo.ui.pages.metadata_viewer.pages + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.adamratzman.spotify.models.Artist +import com.bobbyesp.spowlo.ui.pages.commonPages.NotImplementedPage + +@Composable +fun ArtistPage( + data: Artist, + modifier: Modifier +) { + NotImplementedPage() +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/PlaylistViewPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/PlaylistViewPage.kt new file mode 100644 index 00000000..2f189e28 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/PlaylistViewPage.kt @@ -0,0 +1,183 @@ +package com.bobbyesp.spowlo.ui.pages.metadata_viewer.pages + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.adamratzman.spotify.models.Playlist +import com.bobbyesp.spowlo.App +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.common.AsyncImageImpl +import com.bobbyesp.spowlo.ui.components.HorizontalDivider +import com.bobbyesp.spowlo.ui.components.MarqueeText +import com.bobbyesp.spowlo.ui.components.songs.metadata_viewer.TrackComponent +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders.dataStringToString + +@Composable +fun PlaylistViewPage( + data: Playlist, + modifier: Modifier, + trackDownloadCallback: (String, String) -> Unit +) { + val localConfig = LocalConfiguration.current + + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Top + ) { + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .fillMaxWidth() + .padding(bottom = 6.dp), + contentAlignment = Alignment.Center + ) { + //calculate the image size based on the screen size and the aspect ratio as 1:1 (square) based on the height + val size = (localConfig.screenHeightDp / 3) + AsyncImageImpl( + modifier = Modifier + .size(size.dp) + .aspectRatio( + 1f, matchHeightConstraintsFirst = true + ) + .clip(MaterialTheme.shapes.small), + model = data.images[0].url, + contentDescription = stringResource(id = R.string.track_artwork), + contentScale = ContentScale.Crop, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp) + ) { + SelectionContainer { + MarqueeText( + text = data.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineMedium + ) + } + Spacer(modifier = Modifier.height(6.dp)) + SelectionContainer { + Text( + text = data.owner.displayName ?: data.owner.id, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold + ), + modifier = Modifier.alpha(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(6.dp)) + SelectionContainer { + Text( + text = dataStringToString( + data = data.type, + additional = data.followers.total.toString() + " " + App.context.getString(R.string.followers) + .lowercase() + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(6.dp)) + SelectionContainer { + Text( + text = data.description ?: "", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(alpha = 0.8f) + ) + } + if (data.externalUrls.spotify != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + FilledTonalIconButton( + onClick = { + trackDownloadCallback(data.externalUrls.spotify!!, data.name) + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = "Download full playlist icon", + modifier = Modifier + .weight(1f) + .padding(14.dp) + ) + } + + } + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) + if (data.tracks.size > 0) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + //for every track in the playlist, show the track name and the artist name + data.tracks.items.forEach { track -> + val actualTrack = track.track?.asTrack + val taskName = StringBuilder().append(actualTrack?.name).append(" - ") + .append(actualTrack?.artists?.joinToString(", ") { it.name }).toString() + TrackComponent( + contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + songName = actualTrack?.name ?: App.context.getString(R.string.unknown), + artists = actualTrack?.artists?.joinToString(", ") { it.name } ?: "", + spotifyUrl = actualTrack?.externalUrls?.spotify ?: "", + isExplicit = actualTrack?.explicit ?: false, + isPlaylist = true, + imageUrl = actualTrack?.album?.images?.getOrNull(0)?.url ?: "", + onClick = { + if (actualTrack != null) { + trackDownloadCallback( + actualTrack.externalUrls.spotify!!, + taskName + ) + } + } + ) + } + } + } + } + } +} + +/*@ExperimentalSerializationApi +object PlaylistSaver : Saver { + override fun restore(value: String): Playlist? { + return Json.decodeFromString(value) + } + + override fun SaverScope.save(value: Playlist?): String { + return Json.encodeToString(value) + } +}*/ \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/TrackPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/TrackPage.kt new file mode 100644 index 00000000..5757e5c3 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/pages/TrackPage.kt @@ -0,0 +1,209 @@ +package com.bobbyesp.spowlo.ui.pages.metadata_viewer.pages + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.adamratzman.spotify.models.AudioFeatures +import com.adamratzman.spotify.models.Track +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.features.spotify_api.data.remote.SpotifyApiRequests +import com.bobbyesp.spowlo.ui.common.AsyncImageImpl +import com.bobbyesp.spowlo.ui.components.HorizontalDivider +import com.bobbyesp.spowlo.ui.components.MarqueeText +import com.bobbyesp.spowlo.ui.components.songs.metadata_viewer.ExtraInfoCard +import com.bobbyesp.spowlo.ui.components.songs.metadata_viewer.TrackComponent +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders.dataStringToString +import com.bobbyesp.spowlo.utils.GeneralTextUtils +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@OptIn(ExperimentalSerializationApi::class) +@Composable +fun TrackPage( + data: Track, + modifier: Modifier, + trackDownloadCallback: (String, String) -> Unit, +) { + val localConfig = LocalConfiguration.current + var audioFeatures by rememberSaveable(stateSaver = AudioFeaturesSaver) { + mutableStateOf(null) + } + var trackData by rememberSaveable(stateSaver = TrackSaver) { + mutableStateOf(data) + } + + LaunchedEffect(Unit) { + if (audioFeatures == null) { + val feats = SpotifyApiRequests.providesGetAudioFeatures(data.id) + audioFeatures = feats + } + if (trackData != data) { + trackData = data + } + } + + Column( + modifier = modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .fillMaxWidth() + .padding(bottom = 6.dp), contentAlignment = Alignment.Center + ) { + //calculate the image size based on the screen size and the aspect ratio as 1:1 (square) based on the height + val size = (localConfig.screenHeightDp / 3) + AsyncImageImpl( + modifier = Modifier + .size(size.dp) + .aspectRatio( + 1f, matchHeightConstraintsFirst = true + ) + .clip(MaterialTheme.shapes.small), + model = trackData.album.images[0].url, + contentDescription = stringResource(id = R.string.track_artwork), + contentScale = ContentScale.Crop, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp) + ) { + SelectionContainer { + MarqueeText( + text = trackData.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineMedium + ) + } + Spacer(modifier = Modifier.height(6.dp)) + SelectionContainer { + Text( + text = trackData.artists.joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold + ), + modifier = Modifier.alpha(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(6.dp)) + SelectionContainer { + Text( + text = dataStringToString( + data = trackData.type, additional = trackData.album.releaseDate?.year.toString() + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(alpha = 0.8f) + ) + } + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + val taskName = StringBuilder().append(trackData.name).append(" - ") + .append(trackData.artists.joinToString(", ") { it.name }).toString() + TrackComponent(contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + songName = trackData.name, + artists = trackData.artists.joinToString(", ") { it.name }, + spotifyUrl = trackData.externalUrls.spotify!!, + isExplicit = trackData.explicit, + onClick = { trackDownloadCallback(trackData.externalUrls.spotify!!, taskName) }) + } + Spacer(modifier = Modifier.padding(vertical = 8.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + ExtraInfoCard( + headlineText = stringResource(id = R.string.track_popularity), + bodyText = trackData.popularity.toString(), + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(16.dp)) + ExtraInfoCard( + headlineText = stringResource(id = R.string.track_duration), + bodyText = GeneralTextUtils.convertDuration(trackData.durationMs.toDouble()), + modifier = Modifier.weight(1f) + ) + } + AnimatedVisibility(visible = audioFeatures != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + ExtraInfoCard( + headlineText = stringResource(id = R.string.loudness), + bodyText = audioFeatures!!.loudness.toString(), + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(16.dp)) + ExtraInfoCard( + headlineText = stringResource(id = R.string.tempo), + bodyText = audioFeatures!!.tempo.toString() + " BPM", + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +//create a saver for the audio features +@ExperimentalSerializationApi +object AudioFeaturesSaver : Saver { + override fun restore(value: String): AudioFeatures? { + return Json.decodeFromString(value) + } + + override fun SaverScope.save(value: AudioFeatures?): String { + return Json.encodeToString(value) + } +} + +@ExperimentalSerializationApi +object TrackSaver : Saver { + override fun restore(value: String): Track { + return Json.decodeFromString(value) + } + + override fun SaverScope.save(value: Track): String { + return Json.encodeToString(value) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/playlists/PlaylistPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/playlists/PlaylistPage.kt new file mode 100644 index 00000000..313b4ec2 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/playlists/PlaylistPage.kt @@ -0,0 +1,89 @@ +package com.bobbyesp.spowlo.ui.pages.metadata_viewer.playlists + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.components.BackButton +import com.bobbyesp.spowlo.ui.pages.commonPages.LoadingPage +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders.SpotifyPageBinder +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders.typeOfSpotifyDataType + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun PlaylistPage( + onBackPressed: () -> Unit, + playlistPageViewModel: PlaylistPageViewModel = hiltViewModel(), + id: String, + type: String, +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + + val viewState by playlistPageViewModel.viewStateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(id) { + playlistPageViewModel.loadData(id, typeOfSpotifyDataType(type)) + } + + with(viewState) { + when (this.state) { + is PlaylistDataState.Loading -> { + LoadingPage() + } + + is PlaylistDataState.Error -> { + Text(text = this.state.error.message.toString()) + } + + is PlaylistDataState.Loaded -> { + Scaffold(modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + , topBar = { + TopAppBar(title = { + Text( + text = stringResource(id = R.string.metadata_viewer), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp) + ) + }, navigationIcon = { + BackButton { onBackPressed() } + }, actions = {}, scrollBehavior = scrollBehavior + ) + }) { paddings -> + SpotifyPageBinder( + data = state.data, + type = typeOfSpotifyDataType(type), + modifier = Modifier + .fillMaxSize() + .padding(paddings), + trackDownloadCallback = { url, name -> + playlistPageViewModel.downloadTrack(url, name) + }, + ) + + } + } + } + } +} + +sealed class PlaylistDataState { + object Loading : PlaylistDataState() + class Error(val error: Exception) : PlaylistDataState() + class Loaded(val data: Any) : PlaylistDataState() +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/playlists/PlaylistPageViewModel.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/playlists/PlaylistPageViewModel.kt new file mode 100644 index 00000000..a4d3d422 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/metadata_viewer/playlists/PlaylistPageViewModel.kt @@ -0,0 +1,107 @@ +package com.bobbyesp.spowlo.ui.pages.metadata_viewer.playlists + +import android.util.Log +import androidx.lifecycle.ViewModel +import com.bobbyesp.spowlo.Downloader +import com.bobbyesp.spowlo.features.spotify_api.data.remote.SpotifyApiRequests +import com.bobbyesp.spowlo.features.spotify_api.model.SpotifyDataType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +class PlaylistPageViewModel @Inject constructor() : ViewModel() { + + private val mutableViewStateFlow = MutableStateFlow(ViewState()) + val viewStateFlow = mutableViewStateFlow.asStateFlow() + + data class ViewState( + val id: String = "", + val state: PlaylistDataState = PlaylistDataState.Loading, + ) + + suspend fun loadData(id: String, type: SpotifyDataType = SpotifyDataType.TRACK) { + mutableViewStateFlow.update { + it.copy( + id = id, + state = PlaylistDataState.Loading + ) + } + when (type) { + SpotifyDataType.TRACK -> { + kotlin.runCatching { + Log.d("SpotifyApiRequests", "provideGetTrackById($id)") + SpotifyApiRequests.provideGetTrackById(id) + }.onSuccess { data -> + mutableViewStateFlow.update { + it.copy( + state = PlaylistDataState.Loaded( + data!! + ) + ) + } + }.onFailure { + mutableViewStateFlow.update { + it.copy( + state = PlaylistDataState.Error(Exception("Error while loading data")) + ) + } + } + } + + SpotifyDataType.ALBUM -> { + kotlin.runCatching { + SpotifyApiRequests.providesGetAlbumById(id) + }.onSuccess { data -> + mutableViewStateFlow.update { + it.copy( + state = PlaylistDataState.Loaded( + data!! + ) + ) + } + }.onFailure { + mutableViewStateFlow.update { + it.copy( + state = PlaylistDataState.Error(Exception("Error while loading data")) + ) + } + } + + } + + SpotifyDataType.PLAYLIST -> { + kotlin.runCatching { + Log.d("SpotifyApiRequests", "provideGetPlaylistById($id)") + SpotifyApiRequests.provideGetPlaylistById(id) + }.onSuccess { data -> + mutableViewStateFlow.update { + it.copy( + state = PlaylistDataState.Loaded( + data!! + ) + ) + } + }.onFailure { + mutableViewStateFlow.update { + it.copy( + state = PlaylistDataState.Error(Exception("Error while loading data")) + ) + } + } + + } + + SpotifyDataType.ARTIST -> { + + } + } + } + + fun downloadTrack(url: String, name: String) { + Downloader.executeParallelDownloadWithUrl(url, name) + } + +} + + diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/mod_downloader/ModsDownloaderPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/mod_downloader/ModsDownloaderPage.kt index 1b6e13bd..fb34dc72 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/mod_downloader/ModsDownloaderPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/mod_downloader/ModsDownloaderPage.kt @@ -26,8 +26,7 @@ import com.bobbyesp.spowlo.ui.components.PreferenceInfo @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModsDownloaderPage( - onBackPressed: () -> Unit, - modsDownloaderViewModel: ModsDownloaderViewModel + onBackPressed: () -> Unit, modsDownloaderViewModel: ModsDownloaderViewModel ) { val apiResponse = modsDownloaderViewModel.apiResponseFlow.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() @@ -44,8 +43,7 @@ fun ModsDownloaderPage( ) }, navigationIcon = { BackButton { onBackPressed() } - }, actions = { - }, scrollBehavior = scrollBehavior + }, actions = {}, scrollBehavior = scrollBehavior ) }) { paddings -> LazyColumn( @@ -59,8 +57,7 @@ fun ModsDownloaderPage( PreferenceInfo(text = stringResource(id = R.string.mods_advertisement)) } item { - PackagesListItem( - type = PackagesListItemType.Regular, + PackagesListItem(type = PackagesListItemType.Regular, expanded = false, onClick = {}, packages = apps.Regular.sortedByDescending { it.version }, @@ -68,8 +65,7 @@ fun ModsDownloaderPage( ) } item { - PackagesListItem( - type = PackagesListItemType.RegularCloned, + PackagesListItem(type = PackagesListItemType.RegularCloned, expanded = false, onClick = {}, packages = apps.Regular_Cloned.sortedByDescending { it.version }, @@ -77,8 +73,7 @@ fun ModsDownloaderPage( ) } item { - PackagesListItem( - type = PackagesListItemType.Amoled, + PackagesListItem(type = PackagesListItemType.Amoled, expanded = false, onClick = {}, packages = apps.AMOLED.sortedByDescending { it.version }, @@ -86,8 +81,7 @@ fun ModsDownloaderPage( ) } item { - PackagesListItem( - type = PackagesListItemType.AmoledCloned, + PackagesListItem(type = PackagesListItemType.AmoledCloned, expanded = false, onClick = {}, packages = apps.AMOLED_Cloned.sortedByDescending { it.version }, @@ -96,8 +90,7 @@ fun ModsDownloaderPage( } item { - PackagesListItem( - type = PackagesListItemType.Lite, + PackagesListItem(type = PackagesListItemType.Lite, expanded = false, onClick = {}, packages = apps.Lite.sortedByDescending { it.version }, diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/searcher/SearcherPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/searcher/SearcherPage.kt new file mode 100644 index 00000000..8f8da68b --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/searcher/SearcherPage.kt @@ -0,0 +1,450 @@ +package com.bobbyesp.spowlo.ui.pages.searcher + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.common.Route +import com.bobbyesp.spowlo.ui.components.AutoResizableText +import com.bobbyesp.spowlo.ui.components.HorizontalDivider +import com.bobbyesp.spowlo.ui.components.songs.search_feat.SearchingSongComponent +import com.bobbyesp.spowlo.ui.dialogs.bottomsheets.IndicatorBehindScrollableTabRow +import com.bobbyesp.spowlo.ui.dialogs.bottomsheets.getString +import com.bobbyesp.spowlo.ui.dialogs.bottomsheets.tabIndicatorOffset +import com.bobbyesp.spowlo.ui.pages.commonPages.ErrorPage +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders.typeOfDataToString +import com.bobbyesp.spowlo.ui.pages.metadata_viewer.binders.typeOfSpotifyDataType +import com.bobbyesp.spowlo.ui.theme.harmonizeWithPrimary +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun SearcherPage( + searcherPageViewModel: SearcherPageViewModel = hiltViewModel(), + navController: NavController +) { + val viewState by searcherPageViewModel.viewStateFlow.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + SearcherPageImpl( + viewState = viewState, + onValueChange = { query -> + searcherPageViewModel.updateSearchText(query) + }, + onItemClick = { type, id -> navController.navigate(Route.PLAYLIST_PAGE + "/" + type + "/" + id) }, + reloadPageCallback = { + scope.launch { + searcherPageViewModel.makeSearch() + } + } + ) + } + LaunchedEffect(viewState.query) { + if (viewState.query.isEmpty()) return@LaunchedEffect + delay(300) + searcherPageViewModel.makeSearch() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SearcherPageImpl( + viewState: SearcherPageViewModel.ViewState, + onValueChange: (String) -> Unit, + onItemClick: (String, String) -> Unit, + reloadPageCallback: () -> Unit = {} +) { + Scaffold(modifier = Modifier.fillMaxSize()) { + with(viewState) { + Column(modifier = Modifier.fillMaxSize()) { + QueryTextBox( + modifier = Modifier.padding( + top = 16.dp, + start = 16.dp, + end = 16.dp + ), + query = query, + onValueChange = { query -> + onValueChange(query) + } + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(it) + ) { + when (viewState.viewState) { + is ViewSearchState.Idle -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + AutoResizableText( + text = stringResource(id = R.string.search), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + } + + is ViewSearchState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + modifier = Modifier + .size(72.dp) + .padding(6.dp), + strokeWidth = 4.dp + ) + Text( + text = stringResource(id = R.string.loading), + modifier = Modifier.align( + Alignment.CenterHorizontally + ), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + + } + + } + + is ViewSearchState.Error -> { + ErrorPage( + onReload = { reloadPageCallback() }, + exception = viewState.viewState.error, + modifier = Modifier.fillMaxSize() + ) + } + + is ViewSearchState.Success -> { + val pagerState = rememberPagerState(initialPage = 0) + val pages = listOf( + SearcherPages.TRACKS, + SearcherPages.PLAYLISTS, + SearcherPages.ALBUMS + ) + val scope = rememberCoroutineScope() + + IndicatorBehindScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + modifier = Modifier + .animateContentSize() + .fillMaxWidth(), + indicator = { tabPositions -> + Box( + Modifier + .padding(vertical = 12.dp) + .tabIndicatorOffset(tabPositions[pagerState.currentPage]) + .fillMaxHeight() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer) + ) + }, + edgePadding = 16.dp, + tabAlignment = Alignment.CenterStart, + ) { + pages.forEachIndexed { index, page -> + Tab( + text = { Text(text = page) }, + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + ) + } + } + + HorizontalPager( + pageCount = pages.size, state = pagerState, modifier = Modifier + .animateContentSize() + .fillMaxSize() + ) { + when (pages[it]) { + SearcherPages.TRACKS -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + viewState.viewState.data.let { data -> + item { + Text( + text = stringResource(R.string.showing_results).format( + data.tracks?.size + ), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold + ), + modifier = Modifier + .padding(16.dp) + .alpha(0.7f), + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + fontWeight = FontWeight.Bold + ) + + } + data.tracks?.items?.forEachIndexed { index, track -> + item { + val artists: List = + track.artists.map { artist -> artist.name } + SearchingSongComponent( + artworkUrl = track.album.images[2].url, + songName = track.name, + artists = artists.joinToString(", "), + spotifyUrl = track.externalUrls.spotify + ?: "", + onClick = { + onItemClick( + track.type, + track.id + ) + }, + type = typeOfDataToString( + type = typeOfSpotifyDataType( + track.type + ) + ) + ) + HorizontalDivider( + modifier = Modifier.alpha(0.35f), + color = MaterialTheme.colorScheme.primary.harmonizeWithPrimary() + ) + } + } + } + } + } + + SearcherPages.PLAYLISTS -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + viewState.viewState.data.let { data -> + item { + Text( + text = stringResource(R.string.showing_results).format( + data.playlists?.size + ), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold + ), + modifier = Modifier + .padding(16.dp) + .alpha(0.7f), + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + fontWeight = FontWeight.Bold + ) + + } + data.playlists?.items?.forEachIndexed { index, playlist -> + item { + SearchingSongComponent( + artworkUrl = playlist.images[0].url, + songName = playlist.name, + artists = playlist.owner.displayName + ?: stringResource(R.string.unknown), + spotifyUrl = playlist.externalUrls.spotify + ?: "", + onClick = { + onItemClick( + playlist.type, + playlist.id + ) + }, + type = typeOfDataToString( + type = typeOfSpotifyDataType( + playlist.type + ) + ) + ) + HorizontalDivider( + modifier = Modifier.alpha(0.35f), + color = MaterialTheme.colorScheme.primary.harmonizeWithPrimary() + ) + } + } + } + } + } + + SearcherPages.ALBUMS -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + viewState.viewState.data.let { data -> + item { + Text( + text = stringResource(R.string.showing_results).format( + data.playlists?.size + ), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold + ), + modifier = Modifier + .padding(16.dp) + .alpha(0.7f), + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + fontWeight = FontWeight.Bold + ) + + } + data.albums?.items?.forEachIndexed { index, album -> + item { + SearchingSongComponent( + artworkUrl = album.images[0].url, + songName = album.name, + artists = album.artists[0].name, + spotifyUrl = album.externalUrls.spotify + ?: "", + onClick = { + onItemClick( + album.type, + album.id + ) + }, + type = typeOfDataToString( + type = typeOfSpotifyDataType( + album.type + ) + ) + ) + HorizontalDivider( + modifier = Modifier.alpha(0.35f), + color = MaterialTheme.colorScheme.primary.harmonizeWithPrimary() + ) + } + } + } + } + } + } + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@Composable +fun QueryTextBox( + modifier: Modifier = Modifier, + query: String, + onValueChange: (String) -> Unit +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val softwareKeyboardController = LocalSoftwareKeyboardController.current + + OutlinedTextField( + value = query, + onValueChange = onValueChange, + placeholder = { + if (query.isEmpty()) { + Text(text = stringResource(id = R.string.searcher_page_query_text_box_label)) + } + }, + modifier = modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + softwareKeyboardController?.hide() + } + ), + leadingIcon = { + Icon(imageVector = Icons.Rounded.Search, contentDescription = null) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onValueChange("") }) { + Icon(imageVector = Icons.Rounded.Close, contentDescription = null) + } + } + }, + singleLine = true, + colors = TextFieldDefaults.outlinedTextFieldColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( + 8.dp + ), unfocusedBorderColor = MaterialTheme.colorScheme.surfaceVariant + ), + ) +} + +object SearcherPages { + val TRACKS = getString(R.string.tracks) + val PLAYLISTS = getString(R.string.playlists) + val ALBUMS = getString(R.string.albums) +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/searcher/SearcherPageViewModel.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/searcher/SearcherPageViewModel.kt new file mode 100644 index 00000000..bf2c907e --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/searcher/SearcherPageViewModel.kt @@ -0,0 +1,59 @@ +package com.bobbyesp.spowlo.ui.pages.searcher + +import androidx.lifecycle.ViewModel +import com.adamratzman.spotify.models.SpotifySearchResult +import com.bobbyesp.spowlo.features.spotify_api.data.remote.SpotifyApiRequests +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +class SearcherPageViewModel @Inject constructor() : ViewModel() { + + private val mutableViewStateFlow = MutableStateFlow(ViewState()) + val viewStateFlow = mutableViewStateFlow.asStateFlow() + + data class ViewState( + val query : String = "", + val viewState: ViewSearchState = ViewSearchState.Idle, + ) + + private val api = SpotifyApiRequests + + fun updateSearchText(text: String) { + mutableViewStateFlow.update { + it.copy(query = text) + } + } + + suspend fun makeSearch() { + mutableViewStateFlow.update { + it.copy(viewState = ViewSearchState.Loading) + } + kotlin.runCatching { + api.searchAllTypes(viewStateFlow.value.query) + }.onSuccess { result -> + if (result == SpotifySearchResult(null, null, null, null, null, null)) { + mutableViewStateFlow.update { viewState -> + viewState.copy(viewState = ViewSearchState.Error("No results found")) + } + return@onSuccess + } + mutableViewStateFlow.update { viewState -> + viewState.copy(viewState = ViewSearchState.Success(result)) + } + }.onFailure { + mutableViewStateFlow.update { viewState -> + viewState.copy(viewState = ViewSearchState.Error(it.message.toString())) + } + it.printStackTrace() + } + } +} + +//create the possible states of the view +sealed class ViewSearchState { + object Idle : ViewSearchState() + object Loading : ViewSearchState() + data class Success(val data: SpotifySearchResult) : ViewSearchState() + data class Error(val error: String) : ViewSearchState() +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/SettingsPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/SettingsPage.kt index a01400a7..aaf439ce 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/SettingsPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/SettingsPage.kt @@ -12,21 +12,27 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Aod import androidx.compose.material.icons.filled.AudioFile +import androidx.compose.material.icons.filled.Cookie +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Help import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.SettingsApplications import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.outlined.Cookie import androidx.compose.material.icons.rounded.EnergySavingsLeaf import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,18 +40,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.ui.common.LocalDarkTheme import com.bobbyesp.spowlo.ui.common.Route import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.PreferencesHintCard -import com.bobbyesp.spowlo.ui.components.SettingItem import com.bobbyesp.spowlo.ui.components.SettingTitle import com.bobbyesp.spowlo.ui.components.SmallTopAppBar +import com.bobbyesp.spowlo.ui.components.fraction +import com.bobbyesp.spowlo.ui.components.settings.SettingsItemNew import com.bobbyesp.spowlo.ui.pages.settings.about.LocalAsset @OptIn(ExperimentalMaterial3Api::class) @@ -77,15 +87,27 @@ fun SettingsPage(navController: NavController) { topBar = { SmallTopAppBar( titleText = stringResource(id = R.string.settings), + title = { + Text( + text = stringResource(id = R.string.settings), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = fraction(scrollBehavior.state.overlappedFraction) + ), + maxLines = 1 + ) + }, navigationIcon = { BackButton { navController.popBackStack() } }, scrollBehavior = scrollBehavior ) }) { + LazyColumn( - modifier = Modifier.padding(it) + modifier = Modifier.padding(it), + contentPadding = PaddingValues(horizontal = 16.dp) ) { item { - SettingTitle(text = stringResource(id = R.string.settings)) + SettingTitle(text = stringResource(id = R.string.settings), fontWeight = FontWeight.Bold) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (context.packageManager.queryIntentActivities( @@ -113,112 +135,208 @@ fun SettingsPage(navController: NavController) { } } item { - SettingItem( - title = stringResource(id = R.string.general_settings), - description = stringResource( - id = R.string.general_settings_desc - ), - icon = Icons.Filled.SettingsApplications - ) { - navController.navigate(Route.GENERAL_DOWNLOAD_PREFERENCES) { - launchSingleTop = true - } - } + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.general), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.general_settings_desc)) }, + icon = Icons.Filled.SettingsApplications, + onClick = { + navController.navigate(Route.GENERAL_DOWNLOAD_PREFERENCES) { + launchSingleTop = true + } + }, + addTonalElevation = true, + modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)), + highlightIcon = true + ) } item { - SettingItem( - title = stringResource(id = R.string.spotify_settings), - description = stringResource( - id = R.string.spotify_settings_desc - ), - icon = LocalAsset(id = R.drawable.spotify_logo) - ) { - navController.navigate(Route.SPOTIFY_PREFERENCES) { + SettingsItemNew(onClick = { + navController.navigate(Route.DOWNLOADER_SETTINGS) { launchSingleTop = true } - } + }, title = { + Text( + text = stringResource(id = R.string.downloader), + fontWeight = FontWeight.Bold + ) + }, description = { + Text(text = stringResource(id = R.string.downloader_settings_desc)) + }, icon = Icons.Filled.Download, + highlightIcon = true + ) + } item { - SettingItem( - title = stringResource(id = R.string.download_directory), - description = stringResource( - id = R.string.download_directory_desc - ), - icon = Icons.Filled.Folder - ) { - navController.navigate(Route.DOWNLOAD_DIRECTORY) { - launchSingleTop = true - } - } + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.spotify_settings), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.spotify_settings_desc)) }, + icon = LocalAsset(id = R.drawable.spotify_logo), + onClick = { + navController.navigate(Route.SPOTIFY_PREFERENCES) { + launchSingleTop = true + } + }, + addTonalElevation = true, + highlightIcon = true + ) } item { - SettingItem( - title = stringResource(id = R.string.format), - description = stringResource(id = R.string.format_settings_desc), - icon = Icons.Filled.AudioFile - ) { - navController.navigate(Route.DOWNLOAD_FORMAT) { - launchSingleTop = true - } - } + //new settings item for download directory + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.download_directory), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.download_directory_desc)) }, + icon = Icons.Filled.Folder, + onClick = { + navController.navigate(Route.DOWNLOAD_DIRECTORY) { + launchSingleTop = true + } + }, + addTonalElevation = true, + highlightIcon = true + ) } - /*item { - SettingItem( - title = stringResource(id = R.string.network), - description = stringResource(id = R.string.network_settings_desc), - icon = if (App.connectivityManager.isActiveNetworkMetered) Icons.Filled.SignalCellular4Bar else Icons.Filled.SignalWifi4Bar - ) { - navController.navigate(Route.NETWORK_PREFERENCES) { - launchSingleTop = true - } - } - }*/ item { - SettingItem( - title = stringResource(id = R.string.appearance), description = stringResource( - id = R.string.appearance_settings - ), icon = Icons.Filled.Aod - ) { - navController.navigate(Route.APPEARANCE) { launchSingleTop = true } - } + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.format), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.format_settings_desc)) }, + icon = Icons.Filled.AudioFile, + onClick = { + navController.navigate(Route.DOWNLOAD_FORMAT) { + launchSingleTop = true + } + }, + addTonalElevation = true, + highlightIcon = true + ) + } + item { + //rewrite this with new settings item + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.appearance), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.appearance_settings)) }, + icon = Icons.Filled.Aod, + onClick = { + navController.navigate(Route.APPEARANCE) { + launchSingleTop = true + } + }, + addTonalElevation = true, + highlightIcon = true + ) } item { //Cookies page - SettingItem( - title = stringResource(id = R.string.cookies), description = stringResource( - id = R.string.cookies_desc - ), icon = Icons.Outlined.Cookie - ) { - navController.navigate(Route.COOKIE_PROFILE) { launchSingleTop = true } - } + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.cookies), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.cookies_desc)) }, + icon = Icons.Filled.Cookie, + onClick = { + navController.navigate(Route.COOKIE_PROFILE) { + launchSingleTop = true + } + }, + addTonalElevation = true, + highlightIcon = true + ) } + /*item { + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.documentation), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.documentation_desc)) }, + icon = Icons.Filled.Help, + onClick = { + navController.navigate(Route.DOCUMENTATION) { + launchSingleTop = true + } + }, + addTonalElevation = true, + highlightIcon = true + ) + } +*/ item { - SettingItem(title = stringResource(id = R.string.documentation), description = stringResource( - id = R.string.documentation_desc - ), icon = Icons.Filled.Help ) { - navController.navigate(Route.DOCUMENTATION) { launchSingleTop = true } - } + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.updates_channels), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.updates_channels_desc)) }, + icon = Icons.Filled.Update, + onClick = { + navController.navigate(Route.UPDATER_PAGE) { + launchSingleTop = true + } + }, + addTonalElevation = true, + highlightIcon = true + ) } - item{ - SettingItem( - title = stringResource(id = R.string.updates_channels), description = stringResource( - id = R.string.updates_channels_desc - ), icon = Icons.Filled.Update - ) { - navController.navigate(Route.UPDATER_PAGE) { launchSingleTop = true } - } + item { + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.about), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.about_page)) }, + icon = Icons.Filled.Info, + onClick = { + navController.navigate(Route.ABOUT) { + launchSingleTop = true + } + }, + addTonalElevation = true, + highlightIcon = true, + modifier = Modifier.clip( + RoundedCornerShape( + bottomStart = 8.dp, + bottomEnd = 8.dp + ) + ) + ) } - item { - SettingItem( - title = stringResource(id = R.string.about), description = stringResource( - id = R.string.about_page - ), icon = Icons.Filled.Info - ) { - navController.navigate(Route.ABOUT) { launchSingleTop = true } - } + Spacer(modifier = Modifier.height(24.dp)) } } } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/about/AboutPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/about/AboutPage.kt index 0b0375e0..ba970fd2 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/about/AboutPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/about/AboutPage.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bobbyesp.spowlo.App import com.bobbyesp.spowlo.App.Companion.packageInfo @@ -69,6 +70,7 @@ fun AboutPage(onBackPressed: () -> Unit) { Text( modifier = Modifier, text = stringResource(id = R.string.about), + fontWeight = FontWeight.Bold ) }, navigationIcon = { BackButton { diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/AppThemePreferencesPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/AppThemePreferencesPage.kt index cffe1abe..cab16e6b 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/AppThemePreferencesPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/AppThemePreferencesPage.kt @@ -3,6 +3,7 @@ package com.bobbyesp.spowlo.ui.pages.settings.appearance import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Contrast import androidx.compose.material3.ExperimentalMaterial3Api @@ -13,15 +14,17 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.ui.common.LocalDarkTheme import com.bobbyesp.spowlo.ui.components.BackButton -import com.bobbyesp.spowlo.ui.components.PreferenceSingleChoiceItem import com.bobbyesp.spowlo.ui.components.PreferenceSubtitle -import com.bobbyesp.spowlo.ui.components.PreferenceSwitch +import com.bobbyesp.spowlo.ui.components.settings.SettingsNewSingleChoiceItem +import com.bobbyesp.spowlo.ui.components.settings.SettingsSwitch import com.bobbyesp.spowlo.utils.DarkThemePreference.Companion.FOLLOW_SYSTEM import com.bobbyesp.spowlo.utils.DarkThemePreference.Companion.OFF import com.bobbyesp.spowlo.utils.DarkThemePreference.Companion.ON @@ -45,7 +48,7 @@ fun AppThemePreferencesPage(onBackPressed: () -> Unit) { title = { Text( modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.dark_theme), + text = stringResource(R.string.dark_theme), fontWeight = FontWeight.Bold ) }, navigationIcon = { BackButton() { @@ -54,36 +57,55 @@ fun AppThemePreferencesPage(onBackPressed: () -> Unit) { }, scrollBehavior = scrollBehavior ) }, content = { - LazyColumn(modifier = Modifier.padding(it)) { + LazyColumn( + modifier = Modifier + .padding(it) + .padding(16.dp) + ) { item { - PreferenceSingleChoiceItem( + SettingsNewSingleChoiceItem( text = stringResource(R.string.follow_system), - selected = darkThemePreference.darkThemeValue == FOLLOW_SYSTEM + selected = darkThemePreference.darkThemeValue == FOLLOW_SYSTEM, + modifier = Modifier.clip( + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp + ) + ) ) { PreferencesUtil.modifyDarkThemePreference(FOLLOW_SYSTEM) } } + item { - PreferenceSingleChoiceItem( + SettingsNewSingleChoiceItem( text = stringResource(R.string.on), selected = darkThemePreference.darkThemeValue == ON ) { PreferencesUtil.modifyDarkThemePreference(ON) } } + item { - PreferenceSingleChoiceItem( + SettingsNewSingleChoiceItem( text = stringResource(R.string.off), - selected = darkThemePreference.darkThemeValue == OFF + selected = darkThemePreference.darkThemeValue == OFF, + modifier = Modifier.clip( + RoundedCornerShape( + bottomStart = 16.dp, + bottomEnd = 16.dp + ) + ) ) { PreferencesUtil.modifyDarkThemePreference(OFF) } } item { PreferenceSubtitle(text = stringResource(R.string.additional_settings)) } item { - PreferenceSwitch( - title = stringResource(R.string.high_contrast), - icon = Icons.Outlined.Contrast, - isChecked = isHighContrastModeEnabled, onClick = { + SettingsSwitch( + onCheckedChange = { PreferencesUtil.modifyDarkThemePreference(isHighContrastModeEnabled = !isHighContrastModeEnabled) - } - ) + }, + checked = isHighContrastModeEnabled, + title = { Text(text = stringResource(R.string.high_contrast)) }, + icon = Icons.Outlined.Contrast, + clipCorners = true) } } }) diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/AppearancePage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/AppearancePage.kt index 252c8772..e4dcb10f 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/AppearancePage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/AppearancePage.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -48,6 +50,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import com.bobbyesp.library.dto.Song @@ -61,10 +64,10 @@ import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.ConfirmButton import com.bobbyesp.spowlo.ui.components.DismissButton import com.bobbyesp.spowlo.ui.components.LargeTopAppBar -import com.bobbyesp.spowlo.ui.components.PreferenceItem -import com.bobbyesp.spowlo.ui.components.PreferenceSwitch -import com.bobbyesp.spowlo.ui.components.PreferenceSwitchWithDivider import com.bobbyesp.spowlo.ui.components.SingleChoiceItem +import com.bobbyesp.spowlo.ui.components.settings.SettingsItemNew +import com.bobbyesp.spowlo.ui.components.settings.SettingsSwitch +import com.bobbyesp.spowlo.ui.components.settings.SettingsSwitchWithDivider import com.bobbyesp.spowlo.ui.components.songs.SongCard import com.bobbyesp.spowlo.ui.theme.DEFAULT_SEED_COLOR import com.bobbyesp.spowlo.utils.DarkThemePreference.Companion.FOLLOW_SYSTEM @@ -74,9 +77,7 @@ import com.bobbyesp.spowlo.utils.PreferencesUtil import com.bobbyesp.spowlo.utils.getLanguageDesc import com.bobbyesp.spowlo.utils.palettesMap import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.HorizontalPagerIndicator -import com.google.accompanist.pager.rememberPagerState import com.google.android.material.color.DynamicColors import com.kyant.monet.Hct import com.kyant.monet.LocalTonalPalettes @@ -102,7 +103,9 @@ val colorList = listOf( ) @OptIn( - ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, + ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class, + ExperimentalFoundationApi::class, ExperimentalPagerApi::class ) @Composable @@ -111,43 +114,40 @@ fun AppearancePage( ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), - canScroll = { true } - ) + canScroll = { true }) var showDarkThemeDialog by remember { mutableStateOf(false) } val darkTheme = LocalDarkTheme.current var darkThemeValue by remember { mutableStateOf(darkTheme.darkThemeValue) } val image by remember { mutableStateOf( listOf( - R.drawable.sample, - R.drawable.sample1, - R.drawable.sample2, - R.drawable.sample3 + R.drawable.sample, R.drawable.sample1, R.drawable.sample2, R.drawable.sample3 ).random() ) } - Scaffold( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection), + Scaffold(modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - LargeTopAppBar( - title = { - Text( - modifier = Modifier, - text = stringResource(id = R.string.display), - ) - }, navigationIcon = { - BackButton { - navController.popBackStack() - } - }, scrollBehavior = scrollBehavior + LargeTopAppBar(title = { + Text( + modifier = Modifier, + text = stringResource(id = R.string.display), + fontWeight = FontWeight.Bold + ) + }, navigationIcon = { + BackButton { + navController.popBackStack() + } + }, scrollBehavior = scrollBehavior ) - }, content = { + }, + content = { Column( Modifier .padding(it) + .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()) ) { SongCard( @@ -157,22 +157,22 @@ fun AppearancePage( explicit = true, cover_url = "https://i.scdn.co/image/ab67616d0000b273a152de6438e748b4c0cddff7", duration = 132.954 - ), modifier = Modifier.padding(16.dp) + ), modifier = Modifier.padding(16.dp), isPreview = true ) val pagerState = - rememberPagerState( - initialPage = colorList.indexOf(Color(LocalSeedColor.current)) - .run { if (equals(-1)) 1 else this }) + rememberPagerState(initialPage = colorList.indexOf(Color(LocalSeedColor.current)) + .run { if (equals(-1)) 1 else this }) HorizontalPager( modifier = Modifier .fillMaxWidth() - .clearAndSetSemantics { }, state = pagerState, - count = colorList.size, contentPadding = PaddingValues(horizontal = 12.dp) + .clearAndSetSemantics { }, + state = pagerState, + pageCount = colorList.size, + contentPadding = PaddingValues(horizontal = 6.dp) ) { - Row() { ColorButtons(colorList[it]) } + Row { ColorButtons(colorList[it]) } } - HorizontalPagerIndicator( - pagerState = pagerState, + HorizontalPagerIndicator(pagerState = pagerState, pageCount = colorList.size, modifier = Modifier .clearAndSetSemantics { } @@ -181,77 +181,94 @@ fun AppearancePage( activeColor = MaterialTheme.colorScheme.primary, inactiveColor = MaterialTheme.colorScheme.outlineVariant, indicatorHeight = 6.dp, - indicatorWidth = 6.dp - ) + indicatorWidth = 6.dp) if (DynamicColors.isDynamicColorAvailable()) { - PreferenceSwitch( - title = stringResource(id = R.string.dynamic_color), - description = stringResource( - id = R.string.dynamic_color_desc - ), + SettingsSwitch( + title = { + Text( + stringResource(id = R.string.dynamic_color), + fontWeight = FontWeight.Bold + ) + }, + description = { + Text( + stringResource(id = R.string.dynamic_color_desc) + ) + }, icon = Icons.Outlined.Palette, - isChecked = LocalDynamicColorSwitch.current, - onClick = { + checked = LocalDynamicColorSwitch.current, + onCheckedChange = { PreferencesUtil.switchDynamicColor() - } - ) + }, modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))) } val isDarkTheme = LocalDarkTheme.current.isDarkTheme() - PreferenceSwitchWithDivider( - title = stringResource(id = R.string.dark_theme), - icon = Icons.Outlined.DarkMode, - isChecked = isDarkTheme, - description = LocalDarkTheme.current.getDarkThemeDesc(), - onChecked = { PreferencesUtil.modifyDarkThemePreference(if (isDarkTheme) OFF else ON) }, - onClick = { navController.navigate(Route.APP_THEME) } + SettingsSwitchWithDivider( + onCheckedChange = { + PreferencesUtil.modifyDarkThemePreference( + if (isDarkTheme) OFF else ON + ) + }, checked = isDarkTheme, + title = { + Text( + stringResource(id = R.string.dark_theme), fontWeight = FontWeight.Bold + ) + }, + description = { + Text( + LocalDarkTheme.current.getDarkThemeDesc() + ) + }, icon = Icons.Outlined.DarkMode, + onClick = { navController.navigate(Route.APP_THEME) }) + + if (Build.VERSION.SDK_INT >= 24) SettingsItemNew( + title = { Text(text = stringResource(R.string.language), fontWeight = FontWeight.Bold) }, + icon = Icons.Outlined.Language, + description = {Text(getLanguageDesc())}, + onClick ={ navController.navigate(Route.LANGUAGES) }, + modifier = Modifier.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) ) - //todo: add the languages page - if (Build.VERSION.SDK_INT >= 24) - PreferenceItem( - title = stringResource(R.string.language), - icon = Icons.Outlined.Language, - description = getLanguageDesc() - ) { navController.navigate(Route.LANGUAGES) } } }) - if (showDarkThemeDialog) - AlertDialog(onDismissRequest = { + if (showDarkThemeDialog) AlertDialog( + onDismissRequest = { showDarkThemeDialog = false darkThemeValue = darkTheme.darkThemeValue - }, confirmButton = { + }, + confirmButton = { ConfirmButton { showDarkThemeDialog = false PreferencesUtil.modifyDarkThemePreference(darkThemeValue) } - }, dismissButton = { + }, + dismissButton = { DismissButton { showDarkThemeDialog = false darkThemeValue = darkTheme.darkThemeValue } - }, icon = { Icon(Icons.Outlined.DarkMode, null) }, - title = { Text(stringResource(R.string.dark_theme)) }, text = { - Column { - SingleChoiceItem( - text = stringResource(R.string.follow_system), - selected = darkThemeValue == FOLLOW_SYSTEM - ) { - darkThemeValue = FOLLOW_SYSTEM - } - SingleChoiceItem( - text = stringResource(R.string.on), - selected = darkThemeValue == ON - ) { - darkThemeValue = ON - } - SingleChoiceItem( - text = stringResource(R.string.off), - selected = darkThemeValue == OFF - ) { - darkThemeValue = OFF - } + }, + icon = { Icon(Icons.Outlined.DarkMode, null) }, + title = { Text(stringResource(R.string.dark_theme)) }, + text = { + Column { + SingleChoiceItem( + text = stringResource(R.string.follow_system), + selected = darkThemeValue == FOLLOW_SYSTEM + ) { + darkThemeValue = FOLLOW_SYSTEM + } + SingleChoiceItem( + text = stringResource(R.string.on), selected = darkThemeValue == ON + ) { + darkThemeValue = ON + } + SingleChoiceItem( + text = stringResource(R.string.off), selected = darkThemeValue == OFF + ) { + darkThemeValue = OFF } - }) + } + }) } @Composable @@ -271,11 +288,7 @@ fun RowScope.ColorButton( val tonalPalettes = color.toTonalPalettes(tonalStyle) val isSelect = !LocalDynamicColorSwitch.current && LocalSeedColor.current == color.toArgb() && LocalPaletteStyleIndex.current == index - ColorButtonImpl( - modifier = modifier, - tonalPalettes = tonalPalettes, - isSelected = { isSelect } - ) { + ColorButtonImpl(modifier = modifier, tonalPalettes = tonalPalettes, isSelected = { isSelect }) { PreferencesUtil.switchDynamicColor(enabled = false) PreferencesUtil.modifyThemeSeedColor(color.toArgb(), index) } @@ -309,22 +322,18 @@ fun RowScope.ColorButtonImpl( color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), onClick = { onClick() }) { Box(Modifier.fillMaxSize()) { - Box( - modifier = modifier - .size(48.dp) - .clip(CircleShape) - .drawBehind { drawCircle(color1) } - .align(Alignment.Center) - ) { + Box(modifier = modifier + .size(48.dp) + .clip(CircleShape) + .drawBehind { drawCircle(color1) } + .align(Alignment.Center)) { Surface( - color = color2, - modifier = Modifier + color = color2, modifier = Modifier .align(Alignment.BottomStart) .size(24.dp) ) {} Surface( - color = color3, - modifier = Modifier + color = color3, modifier = Modifier .align(Alignment.BottomEnd) .size(24.dp) ) {} diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/LanguagePage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/LanguagePage.kt index 9f96a344..ef829169 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/LanguagePage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/appearance/LanguagePage.kt @@ -1,10 +1,11 @@ package com.bobbyesp.spowlo.ui.pages.settings.appearance -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Translate import androidx.compose.material3.ExperimentalMaterial3Api @@ -19,16 +20,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bobbyesp.spowlo.MainActivity import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.ui.common.LocalDarkTheme import com.bobbyesp.spowlo.ui.components.BackButton -import com.bobbyesp.spowlo.ui.components.PreferenceSingleChoiceItem import com.bobbyesp.spowlo.ui.components.PreferencesHintCard +import com.bobbyesp.spowlo.ui.components.settings.SettingsNewSingleChoiceItem import com.bobbyesp.spowlo.utils.ChromeCustomTabsUtil import com.bobbyesp.spowlo.utils.LANGUAGE import com.bobbyesp.spowlo.utils.PreferencesUtil @@ -63,7 +66,7 @@ fun LanguagePage(onBackPressed: () -> Unit) { title = { Text( modifier = Modifier, - text = stringResource(id = R.string.language), + text = stringResource(id = R.string.language), fontWeight = FontWeight.Bold ) }, navigationIcon = { BackButton { @@ -75,6 +78,7 @@ fun LanguagePage(onBackPressed: () -> Unit) { LazyColumn( modifier = Modifier .padding(it) + .padding(horizontal = 16.dp) .selectableGroup() ) { item { @@ -86,18 +90,37 @@ fun LanguagePage(onBackPressed: () -> Unit) { ) { ChromeCustomTabsUtil.openUrl(spowloWeblateUrl) } } item { - PreferenceSingleChoiceItem( - text = stringResource(R.string.follow_system), - selected = language == SYSTEM_DEFAULT, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp) - ) { setLanguage(SYSTEM_DEFAULT) } + Column(modifier = Modifier.padding(vertical = 16.dp)) { + SettingsNewSingleChoiceItem( + text = stringResource(R.string.follow_system), + selected = language == SYSTEM_DEFAULT, + modifier = Modifier.clip(RoundedCornerShape(8.dp)) + ) { setLanguage(SYSTEM_DEFAULT) } + } + } - for (languageData in languageMap) { + for ((index, languageData) in languageMap.entries.withIndex()) { item { - PreferenceSingleChoiceItem( + SettingsNewSingleChoiceItem( text = getLanguageDesc(languageData.key), selected = language == languageData.key, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp) + modifier = when (index) { + 0 -> Modifier.clip( + RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp + ) + ) + + languageMap.size - 1 -> Modifier.clip( + RoundedCornerShape( + bottomStart = 8.dp, + bottomEnd = 8.dp + ) + ) + + else -> Modifier + } ) { setLanguage(languageData.key) } } } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/cookies/CookiesSettingsPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/cookies/CookiesSettingsPage.kt index 1f800b71..602bc944 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/cookies/CookiesSettingsPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/cookies/CookiesSettingsPage.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.unit.dp @@ -109,7 +110,7 @@ fun CookieProfilePage( LargeTopAppBar(title = { Text( modifier = Modifier, - text = stringResource(id = R.string.cookies), + text = stringResource(id = R.string.cookies), fontWeight = FontWeight.Bold ) }, navigationIcon = { BackButton { @@ -213,9 +214,6 @@ fun CookieProfilePage( } } -@OptIn( - ExperimentalMaterial3Api::class -) @Composable fun CookieGeneratorDialog( cookiesViewModel: CookiesSettingsViewModel = viewModel(), @@ -253,7 +251,8 @@ fun CookieGeneratorDialog( TextButtonWithIcon( onClick = { navigateToCookieGeneratorPage() }, icon = Icons.Outlined.GeneratingTokens, - text = stringResource(id = R.string.generate_new_cookies) + text = stringResource(id = R.string.generate_new_cookies), + enabled = url.isNotEmpty() ) } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/cookies/WebViewPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/cookies/WebViewPage.kt index aef61b01..27c40889 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/cookies/WebViewPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/cookies/WebViewPage.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.utils.connectWithDelimiter @@ -93,7 +94,7 @@ fun WebViewPage( Scaffold(modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text(webViewState.pageTitle.toString(), maxLines = 1) }, + title = { Text(webViewState.pageTitle.toString(), maxLines = 1, fontWeight = FontWeight.Bold) }, navigationIcon = { IconButton( onClick = { onDismissRequest() }) { diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/directories/DownloadsDirectoriesPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/directories/DownloadsDirectoriesPage.kt index f8e5aed0..811c98ed 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/directories/DownloadsDirectoriesPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/directories/DownloadsDirectoriesPage.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.SdCardAlert import androidx.compose.material.icons.outlined.LibraryMusic @@ -27,20 +28,19 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import com.bobbyesp.spowlo.utils.CUSTOM_PATH -import com.bobbyesp.spowlo.utils.PreferencesUtil -import com.bobbyesp.spowlo.utils.SUBDIRECTORY import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bobbyesp.spowlo.App import com.bobbyesp.spowlo.R @@ -48,12 +48,12 @@ import com.bobbyesp.spowlo.ui.common.LocalDarkTheme import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.LargeTopAppBar import com.bobbyesp.spowlo.ui.components.PreferenceInfo -import com.bobbyesp.spowlo.ui.components.PreferenceItem -import com.bobbyesp.spowlo.ui.components.PreferenceSubtitle -import com.bobbyesp.spowlo.ui.components.PreferenceSwitchWithDivider import com.bobbyesp.spowlo.ui.components.PreferencesHintCard -import com.bobbyesp.spowlo.utils.CUSTOM_COMMAND +import com.bobbyesp.spowlo.ui.components.settings.SettingsItemNew +import com.bobbyesp.spowlo.ui.components.settings.SettingsSwitchWithDivider +import com.bobbyesp.spowlo.utils.CUSTOM_PATH import com.bobbyesp.spowlo.utils.FilesUtil +import com.bobbyesp.spowlo.utils.PreferencesUtil import com.bobbyesp.spowlo.utils.PreferencesUtil.getString import com.bobbyesp.spowlo.utils.SDCARD_DOWNLOAD import com.bobbyesp.spowlo.utils.SDCARD_URI @@ -65,6 +65,7 @@ private const val validDirectoryRegex = "/storage/emulated/0/(Download|Documents private fun String.isValidDirectory(): Boolean { return this.contains(Regex(validDirectoryRegex)) } + private enum class Directory { AUDIO, SDCARD } @@ -85,8 +86,6 @@ fun DownloadsDirectoriesPage( val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } - var isSubdirectoryEnabled - by remember { mutableStateOf(PreferencesUtil.getValue(SUBDIRECTORY)) } var isCustomPathEnabled by remember { mutableStateOf(PreferencesUtil.getValue(CUSTOM_PATH)) } @@ -101,16 +100,12 @@ fun DownloadsDirectoriesPage( mutableStateOf(PreferencesUtil.getValue(SDCARD_DOWNLOAD)) } - var pathTemplateText by remember { mutableStateOf(PreferencesUtil.getOutputPathTemplate()) } + var pathTemplateText by remember { mutableStateOf(PreferencesUtil.getExtraDirectory()) } var showClearTempDialog by remember { mutableStateOf(false) } var editingDirectory by remember { mutableStateOf(Directory.AUDIO) } - val isCustomCommandEnabled by remember { - mutableStateOf(PreferencesUtil.getValue(CUSTOM_COMMAND)) - } - val storagePermission = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) @@ -141,7 +136,7 @@ fun DownloadsDirectoriesPage( } val path = FilesUtil.getRealPath(it) App.updateDownloadDir(path) - audioDirectoryText = path + audioDirectoryText = path } } @@ -167,6 +162,7 @@ fun DownloadsDirectoriesPage( Text( modifier = Modifier, text = stringResource(id = R.string.download_directory), + fontWeight = FontWeight.Bold ) }, navigationIcon = { BackButton { @@ -175,8 +171,12 @@ fun DownloadsDirectoriesPage( }, scrollBehavior = scrollBehavior ) }, content = { - LazyColumn(modifier = Modifier.padding(it)) { - if(sdcardUri.isEmpty()) + LazyColumn( + modifier = Modifier + .padding(it) + .padding(horizontal = 16.dp) + ) { + if (sdcardUri.isEmpty()) item { PreferenceInfo(text = stringResource(id = R.string.sdcard_not_activable_hint)) } @@ -200,41 +200,57 @@ fun DownloadsDirectoriesPage( } item { Text( - text = stringResource(id = R.string.general_settings), + text = stringResource(id = R.string.general), modifier = Modifier .fillMaxWidth() - .padding(start = 18.dp, top = 12.dp, bottom = 4.dp), + .padding(start = 2.dp, top = 12.dp, bottom = 4.dp), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge ) } - item{ - PreferenceItem( - title = stringResource(id = R.string.audio_directory), - description = audioDirectoryText, - enabled = !isCustomCommandEnabled && !sdcardDownload, - icon = Icons.Outlined.LibraryMusic - ) { - editingDirectory = Directory.AUDIO - openDirectoryChooser() - } + item { + SettingsItemNew( + onClick = { + editingDirectory = Directory.AUDIO + openDirectoryChooser() + }, + title = { + Text( + text = stringResource(id = R.string.audio_directory), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = audioDirectoryText) }, + icon = Icons.Outlined.LibraryMusic, + modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + ) } item { - PreferenceSwitchWithDivider( - title = stringResource(id = R.string.sdcard_directory), - description = sdcardUri, - isChecked = sdcardDownload, - enabled = !isCustomCommandEnabled, - isSwitchEnabled = !isCustomCommandEnabled && sdcardUri.isNotBlank(), - onChecked = { + SettingsSwitchWithDivider( + onCheckedChange = { sdcardDownload = !sdcardDownload PreferencesUtil.updateValue(SDCARD_DOWNLOAD, sdcardDownload) }, + checked = sdcardDownload, + title = { + Text( + text = stringResource(id = R.string.sdcard_directory), + fontWeight = FontWeight.Bold + ) + }, + description = { + Text( + text = sdcardUri, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + }, + enabled = sdcardUri.isNotBlank(), icon = Icons.Outlined.SdCard, onClick = { editingDirectory = Directory.SDCARD openDirectoryChooser() - } + }, + modifier = Modifier.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) ) } } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/documentation/DocumentationPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/documentation/DocumentationPage.kt index 3655b632..608c38ea 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/documentation/DocumentationPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/documentation/DocumentationPage.kt @@ -11,19 +11,16 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.navigation.NavController import com.bobbyesp.spowlo.R -import com.bobbyesp.spowlo.ui.common.Route import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.HorizontalDivider import com.bobbyesp.spowlo.ui.components.InlineEnterItem import com.bobbyesp.spowlo.ui.components.LargeTopAppBar import com.bobbyesp.spowlo.ui.components.PreferenceInfo -import com.bobbyesp.spowlo.utils.FilesUtil.inputStreamToString @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -45,7 +42,7 @@ fun DocumentationPage( title = { Text( modifier = Modifier, - text = stringResource(id = R.string.documentation) + text = stringResource(id = R.string.documentation), fontWeight = FontWeight.Bold ) }, navigationIcon = { BackButton { diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/downloader/DownloaderSettingsPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/downloader/DownloaderSettingsPage.kt new file mode 100644 index 00000000..c881ec92 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/downloader/DownloaderSettingsPage.kt @@ -0,0 +1,226 @@ +package com.bobbyesp.spowlo.ui.pages.settings.downloader + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cached +import androidx.compose.material.icons.outlined.Filter +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.ui.common.intState +import com.bobbyesp.spowlo.ui.components.BackButton +import com.bobbyesp.spowlo.ui.components.LargeTopAppBar +import com.bobbyesp.spowlo.ui.components.PreferenceSubtitle +import com.bobbyesp.spowlo.ui.components.settings.ElevatedSettingsCard +import com.bobbyesp.spowlo.ui.components.settings.SettingsSwitch +import com.bobbyesp.spowlo.utils.DONT_FILTER_RESULTS +import com.bobbyesp.spowlo.utils.GEO_BYPASS +import com.bobbyesp.spowlo.utils.PreferencesUtil +import com.bobbyesp.spowlo.utils.PreferencesUtil.updateInt +import com.bobbyesp.spowlo.utils.THREADS +import com.bobbyesp.spowlo.utils.USE_CACHING +import kotlinx.coroutines.DelicateCoroutinesApi + +@OptIn(ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class) +@Composable +fun DownloaderSettingsPage( + onBackPressed: () -> Unit, +) { + + var threadsNumber = THREADS.intState + + var useCache by remember { + mutableStateOf( + PreferencesUtil.getValue(USE_CACHING) + ) + } + + var dontFilter by remember { + mutableStateOf( + PreferencesUtil.getValue(DONT_FILTER_RESULTS) + ) + } + + var useGeobypass by remember { + mutableStateOf( + PreferencesUtil.getValue(GEO_BYPASS) + ) + } + + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState(), + canScroll = { true }) + + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { + Text( + text = stringResource(id = R.string.downloader), + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + BackButton { onBackPressed() } + }, + scrollBehavior = scrollBehavior + ) + }, + content = { + LazyColumn( + modifier = Modifier + .padding(it) + .padding(horizontal = 20.dp, vertical = 10.dp) + ) { + item { + PreferenceSubtitle(text = stringResource(id = R.string.general)) + } + item { + ElevatedSettingsCard { + SettingsSwitch( + onCheckedChange = { + useCache = !useCache + PreferencesUtil.updateValue(USE_CACHING, useCache) + }, + checked = useCache, + title = { + Text( + text = stringResource(id = R.string.use_cache), + fontWeight = FontWeight.Bold + ) + }, + icon = Icons.Outlined.Cached, + description = { Text(text = stringResource(id = R.string.use_cache_desc)) }, + ) + } + } + item { + PreferenceSubtitle(text = stringResource(id = R.string.experimental_features)) + } + item { + ElevatedSettingsCard { + SettingsSwitch( + onCheckedChange = { + useGeobypass = !useGeobypass + PreferencesUtil.updateValue(GEO_BYPASS, useGeobypass) + }, + checked = useGeobypass, + title = { + Text( + text = stringResource(id = R.string.geo_bypass), + fontWeight = FontWeight.Bold + ) + }, + icon = Icons.Outlined.MyLocation, + description = { Text(text = stringResource(id = R.string.use_geobypass_desc)) }, + ) + + SettingsSwitch( + onCheckedChange = { + dontFilter = !dontFilter + PreferencesUtil.updateValue(DONT_FILTER_RESULTS, dontFilter) + }, + checked = dontFilter, + title = { + Text( + text = stringResource(id = R.string.dont_filter_results), + fontWeight = FontWeight.Bold + ) + }, + icon = Icons.Outlined.Filter, + description = { Text(text = stringResource(id = R.string.dont_filter_results_desc)) }, + ) + } + } + item { + PreferenceSubtitle(text = stringResource(id = R.string.advanced_features)) + } + item { + ElevatedSettingsCard { + //threads number item with a slicer + Column( + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.threads), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(start = 16.dp, top = 16.dp) + .weight(1f) + ) + Text( + text = stringResource(id = R.string.threads_number) + ": " + threadsNumber.value.toString(), + style = MaterialTheme.typography.labelLarge.copy( + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.6f + ) + ), + modifier = Modifier.padding(end = 16.dp, top = 16.dp) + ) + } + Text( + text = stringResource(id = R.string.threads_number_desc), + modifier = Modifier.padding( + vertical = 12.dp, horizontal = 16.dp + ), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + ) + + } + } + Slider( + value = threadsNumber.value.toFloat(), + onValueChange = { + threadsNumber.value = it.toInt() + THREADS.updateInt(it.toInt()) + }, + valueRange = 1f..10f, + steps = 9, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/AudioProviderDialog.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/AudioProviderDialog.kt index 86e5ef2d..0310af16 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/AudioProviderDialog.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/AudioProviderDialog.kt @@ -13,15 +13,15 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.ui.components.ConfirmButton -import com.bobbyesp.spowlo.ui.components.SingleChoiceItemWithIcon +import com.bobbyesp.spowlo.ui.components.SingleChoiceItem import com.bobbyesp.spowlo.utils.AUDIO_PROVIDER import com.bobbyesp.spowlo.utils.PreferencesUtil @@ -45,16 +45,16 @@ fun AudioProviderDialog( style = MaterialTheme.typography.bodyLarge ) LazyColumn { - for (i in 0..1) { + for (i in 0..3) { item { - SingleChoiceItemWithIcon( + SingleChoiceItem( text = PreferencesUtil.getAudioProviderDesc(i), selected = audioProvider == i, - icon = PreferencesUtil.getAudioProviderIcon(i), onClick = { audioProvider = i - } + }, ) + } } } @@ -68,9 +68,11 @@ fun AudioProviderDialog( } ) }, - dismissButton = { TextButton(onClick = { onDismissRequest() }) { - Text(text = stringResource(id = R.string.dismiss)) - } }, + dismissButton = { + TextButton(onClick = { onDismissRequest() }) { + Text(text = stringResource(id = R.string.dismiss)) + } + }, ) } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/FormatSettingsDialogs.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/FormatSettingsDialogs.kt index fb592b0f..38a32306 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/FormatSettingsDialogs.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/FormatSettingsDialogs.kt @@ -1,5 +1,6 @@ package com.bobbyesp.spowlo.ui.pages.settings.format +import android.util.Log import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,89 +33,83 @@ import com.bobbyesp.spowlo.utils.PreferencesUtil @Composable fun AudioFormatDialog(onDismissRequest: () -> Unit, onConfirm: () -> Unit = {}) { var audioFormat by remember { mutableStateOf(PreferencesUtil.getAudioFormat()) } - AlertDialog( - onDismissRequest = onDismissRequest, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text(stringResource(R.string.dismiss)) - } - }, - icon = { Icon(Icons.Outlined.AudioFile, null) }, - title = { - Text(stringResource(R.string.audio_format)) - }, confirmButton = { - TextButton(onClick = { - PreferencesUtil.encodeInt(AUDIO_FORMAT, audioFormat) - onConfirm() - onDismissRequest() - }) { - Text(text = stringResource(R.string.confirm)) - } - }, text = { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - text = stringResource(R.string.audio_format_desc), - style = MaterialTheme.typography.bodyLarge - ) - for (i in 0..5) - SingleChoiceItem( - text = PreferencesUtil.getAudioFormatDesc(i), - selected = audioFormat == i - ) { audioFormat = i } - } - }) + AlertDialog(onDismissRequest = onDismissRequest, dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.dismiss)) + } + }, icon = { Icon(Icons.Outlined.AudioFile, null) }, title = { + Text(stringResource(R.string.audio_format)) + }, confirmButton = { + TextButton(onClick = { + PreferencesUtil.encodeInt(AUDIO_FORMAT, audioFormat) + onConfirm() + onDismissRequest() + }) { + Text(text = stringResource(R.string.confirm)) + } + }, text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + text = stringResource(R.string.audio_format_desc), + style = MaterialTheme.typography.bodyLarge + ) + for (i in 0..5) SingleChoiceItem( + text = PreferencesUtil.getAudioFormatDesc(i), selected = audioFormat == i + ) { audioFormat = i } + } + }) } @Composable fun AudioQualityDialog(onDismissRequest: () -> Unit, onConfirm: () -> Unit = {}) { var audioQuality by remember { mutableStateOf(PreferencesUtil.getAudioQuality()) } - AlertDialog( - onDismissRequest = onDismissRequest, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text(stringResource(R.string.dismiss)) - } - }, - icon = { Icon(Icons.Outlined.HighQuality, null) }, - title = { - Text(stringResource(R.string.audio_quality)) - }, confirmButton = { - TextButton(onClick = { - PreferencesUtil.encodeInt(AUDIO_QUALITY, audioQuality) - onConfirm() - onDismissRequest() - }) { - Text(text = stringResource(R.string.confirm)) - } - }, text = { - Column(modifier = Modifier) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - text = stringResource(R.string.audio_quality_desc), - style = MaterialTheme.typography.bodyLarge - ) - LazyColumn(content = { - for (i in 0..17) - item { - SingleChoiceItem( - text = PreferencesUtil.getAudioQualityDesc(i), - selected = audioQuality == i - ) { audioQuality = i } + AlertDialog(onDismissRequest = onDismissRequest, dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.dismiss)) + } + }, icon = { Icon(Icons.Outlined.HighQuality, null) }, title = { + Text(stringResource(R.string.audio_quality)) + }, confirmButton = { + TextButton(onClick = { + Log.d("FormatSettingsDialog", "The chosen audioQuality is: $audioQuality") + PreferencesUtil.encodeInt(AUDIO_QUALITY, audioQuality) + Log.d( + "FormatSettingsDialog", + "The encoded int to the AUDIO_QUALITY settings var is: ${PreferencesUtil.getAudioQuality()}" + ) + onConfirm() + onDismissRequest() + }) { + Text(text = stringResource(R.string.confirm)) + } + }, text = { + Column(modifier = Modifier) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + text = stringResource(R.string.audio_quality_desc), + style = MaterialTheme.typography.bodyLarge + ) + LazyColumn( + content = { + for (i in 17 downTo 0) item { + SingleChoiceItem( + text = PreferencesUtil.getAudioQualityDesc(i), + selected = audioQuality == i + ) { + audioQuality = i + Log.d( + "FormatSettingsDialog", "Changed to $i" + ) } - }, modifier = Modifier.size(400.dp)) - } - }) -} - -@Composable -fun CustomOutputBottomDrawer( - -){ - + } + }, modifier = Modifier.size(400.dp) + ) + } + }) } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/SettingsFormatsPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/SettingsFormatsPage.kt index d3dc8844..5f26b0ee 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/SettingsFormatsPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/format/SettingsFormatsPage.kt @@ -3,31 +3,34 @@ package com.bobbyesp.spowlo.ui.pages.settings.format import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.Audiotrack import androidx.compose.material.icons.outlined.HighQuality +import androidx.compose.material.icons.outlined.ShuffleOn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.LargeTopAppBar -import com.bobbyesp.spowlo.ui.components.PreferenceInfo -import com.bobbyesp.spowlo.ui.components.PreferenceItem import com.bobbyesp.spowlo.ui.components.PreferenceSubtitle -import com.bobbyesp.spowlo.ui.components.PreferenceSwitch -import com.bobbyesp.spowlo.utils.CUSTOM_COMMAND +import com.bobbyesp.spowlo.ui.components.settings.SettingsItemNew +import com.bobbyesp.spowlo.ui.components.settings.SettingsSwitch import com.bobbyesp.spowlo.utils.ORIGINAL_AUDIO import com.bobbyesp.spowlo.utils.PreferencesUtil @@ -36,8 +39,7 @@ import com.bobbyesp.spowlo.utils.PreferencesUtil fun SettingsFormatsPage(onBackPressed: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), - canScroll = { true } - ) + canScroll = { true }) var audioFormat by remember { mutableStateOf(PreferencesUtil.getAudioFormatDesc()) } var audioQuality by remember { mutableStateOf(PreferencesUtil.getAudioQualityDesc()) } @@ -48,89 +50,101 @@ fun SettingsFormatsPage(onBackPressed: () -> Unit) { var showAudioProviderDialog by remember { mutableStateOf(false) } - Scaffold( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection), + Scaffold(modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - LargeTopAppBar( - title = { - Text( - modifier = Modifier, - text = stringResource(id = R.string.format), - ) - }, navigationIcon = { - BackButton { - onBackPressed() - } - }, scrollBehavior = scrollBehavior - ) - }, content = { - val isCustomCommandEnabled by remember { - mutableStateOf( - PreferencesUtil.getValue(CUSTOM_COMMAND) + LargeTopAppBar(title = { + Text( + modifier = Modifier, + text = stringResource(id = R.string.format), + fontWeight = FontWeight.Bold ) - } - LazyColumn(Modifier.padding(it)) { + }, navigationIcon = { + BackButton { + onBackPressed() + } + }, scrollBehavior = scrollBehavior + ) + }, + content = { + LazyColumn(Modifier.padding(it).padding(horizontal = 16.dp)) { item { PreferenceSubtitle(text = stringResource(id = R.string.audio)) } item { - PreferenceSwitch( - title = stringResource(id = R.string.preserve_original_audio), - description = stringResource(id = R.string.preserve_original_audio_desc), + SettingsSwitch(onCheckedChange = { + preserveOriginalAudio = !preserveOriginalAudio + PreferencesUtil.updateValue(ORIGINAL_AUDIO, preserveOriginalAudio) + }, + checked = preserveOriginalAudio, + title = { + Text( + text = stringResource(id = R.string.preserve_original_audio), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.preserve_original_audio_desc)) }, icon = Icons.Outlined.Audiotrack, - isChecked = preserveOriginalAudio, - onClick = { - preserveOriginalAudio = !preserveOriginalAudio - PreferencesUtil.updateValue(ORIGINAL_AUDIO, preserveOriginalAudio) - } + modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) ) } item { - PreferenceItem( - title = stringResource(R.string.audio_format), - description = audioFormat, + SettingsItemNew(title = { + Text( + text = stringResource(id = R.string.audio_format), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = audioFormat) }, icon = Icons.Outlined.AudioFile, - enabled = true, - ) { showAudioFormatDialog = true } + onClick = { showAudioFormatDialog = true }) } item { - PreferenceItem( - title = stringResource(R.string.audio_quality), - description = audioQuality, + SettingsItemNew( + title = { + Text( + text = stringResource(id = R.string.audio_quality), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = audioQuality) }, icon = Icons.Outlined.HighQuality, + onClick = { showAudioQualityDialog = true }, enabled = !preserveOriginalAudio, - ) { showAudioQualityDialog = true } + ) } item { - PreferenceItem( - title = stringResource(R.string.audio_provider), - description = stringResource(R.string.audio_provider_desc), - icon = Icons.Outlined.HighQuality, - enabled = !isCustomCommandEnabled, - ) { showAudioProviderDialog = true } + SettingsItemNew(title = { + Text( + text = stringResource(id = R.string.audio_provider), + fontWeight = FontWeight.Bold + ) + }, + description = { Text(text = stringResource(id = R.string.audio_provider_desc)) }, + icon = Icons.Outlined.ShuffleOn, + onClick = { showAudioProviderDialog = true }, + modifier = Modifier.clip( + RoundedCornerShape( + bottomStart = 8.dp, bottomEnd = 8.dp + ) + ) + ) } } }) if (showAudioFormatDialog) { - AudioFormatDialog( - onDismissRequest = { showAudioFormatDialog = false } - ) { + AudioFormatDialog(onDismissRequest = { showAudioFormatDialog = false }) { audioFormat = PreferencesUtil.getAudioFormatDesc() } } if (showAudioQualityDialog) { - AudioQualityDialog( - onDismissRequest = { showAudioQualityDialog = false } - ) { + AudioQualityDialog(onDismissRequest = { showAudioQualityDialog = false }) { audioQuality = PreferencesUtil.getAudioQualityDesc() } } if (showAudioProviderDialog) { - AudioProviderDialog( - onDismissRequest = { showAudioProviderDialog = false } - ) + AudioProviderDialog(onDismissRequest = { showAudioProviderDialog = false }) } } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/general/GeneralSettingsPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/general/GeneralSettingsPage.kt index acdb35ef..9ddeb389 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/general/GeneralSettingsPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/general/GeneralSettingsPage.kt @@ -3,11 +3,12 @@ package com.bobbyesp.spowlo.ui.pages.settings.general import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Cached -import androidx.compose.material.icons.outlined.Filter +import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.NotificationsActive +import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material.icons.outlined.Print import androidx.compose.material.icons.outlined.PrintDisabled import androidx.compose.material3.ExperimentalMaterial3Api @@ -23,26 +24,32 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import com.bobbyesp.library.SpotDL -import com.bobbyesp.library.SpotDLRequest import com.bobbyesp.spowlo.App import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.ui.common.booleanState import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.LargeTopAppBar -import com.bobbyesp.spowlo.ui.components.PreferenceItem import com.bobbyesp.spowlo.ui.components.PreferenceSubtitle -import com.bobbyesp.spowlo.ui.components.PreferenceSwitch +import com.bobbyesp.spowlo.ui.components.settings.ElevatedSettingsCard +import com.bobbyesp.spowlo.ui.components.settings.SettingsItemNew +import com.bobbyesp.spowlo.ui.components.settings.SettingsSwitch +import com.bobbyesp.spowlo.ui.dialogs.bottomsheets.getString +import com.bobbyesp.spowlo.utils.CONFIGURE import com.bobbyesp.spowlo.utils.DEBUG -import com.bobbyesp.spowlo.utils.DONT_FILTER_RESULTS -import com.bobbyesp.spowlo.utils.GEO_BYPASS +import com.bobbyesp.spowlo.utils.NOTIFICATION import com.bobbyesp.spowlo.utils.PreferencesUtil -import com.bobbyesp.spowlo.utils.USE_CACHING +import com.bobbyesp.spowlo.utils.PreferencesUtil.getString +import com.bobbyesp.spowlo.utils.SPOTDL +import com.bobbyesp.spowlo.utils.ToastUtil +import com.bobbyesp.spowlo.utils.UpdateUtil import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -64,38 +71,36 @@ fun GeneralSettingsPage( var displayErrorReport by DEBUG.booleanState - var useCache by remember { + var useNotifications by remember { mutableStateOf( - PreferencesUtil.getValue(USE_CACHING) + PreferencesUtil.getValue(NOTIFICATION) ) } + var isUpdatingLib by remember { mutableStateOf(false) } - var dontFilter by remember { + val loadingString = App.context.getString(R.string.loading) + + var spotDLVersion by remember { mutableStateOf( - PreferencesUtil.getValue(DONT_FILTER_RESULTS) + loadingString ) } - var useGeobypass by remember { + var configureBeforeDownload by remember { mutableStateOf( - PreferencesUtil.getValue(GEO_BYPASS) + PreferencesUtil.getValue(CONFIGURE) ) } - val loadingString = App.context.getString(R.string.loading) - - var spotDLVersion by remember { mutableStateOf( - loadingString - ) } - //create a non-blocking coroutine to get the version LaunchedEffect(Unit) { GlobalScope.launch { try { withContext(Dispatchers.IO) { - spotDLVersion = SpotDL.getInstance().execute(SpotDLRequest().addOption("-v"), null, null).output + spotDLVersion = SpotDL.getInstance().version(appContext = App.context) + ?: getString(R.string.unknown) } - }catch (e: Exception) { + } catch (e: Exception) { spotDLVersion = e.message ?: e.toString() } @@ -106,89 +111,116 @@ fun GeneralSettingsPage( .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - LargeTopAppBar( - title = { Text(text = stringResource(id = R.string.general_settings)) }, - navigationIcon = { - BackButton { onBackPressed() } - }, - scrollBehavior = scrollBehavior + LargeTopAppBar(title = { + Text( + text = stringResource(id = R.string.general), fontWeight = FontWeight.Bold + ) + }, navigationIcon = { + BackButton { onBackPressed() } + }, scrollBehavior = scrollBehavior ) }, content = { LazyColumn( - modifier = Modifier.padding(it) + modifier = Modifier + .padding(it) + .padding(horizontal = 20.dp, vertical = 10.dp) ) { item { - PreferenceItem( - title = stringResource(id = R.string.spotdl_version), - description = spotDLVersion, - icon = Icons.Outlined.Info, - onClick = { - }, - onClickLabel = stringResource(id = R.string.update), - onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + ElevatedSettingsCard { + SettingsItemNew( + onClick = { + scope.launch { + runCatching { + isUpdatingLib = true + UpdateUtil.updateSpotDL() + spotDLVersion = SPOTDL.getString() + }.onFailure { + ToastUtil.makeToastSuspend(App.context.getString(R.string.spotdl_update_failed)) + }.onSuccess { + ToastUtil.makeToastSuspend( + App.context.getString(R.string.spotdl_update_success) + .format(spotDLVersion) + ) + } + } + }, + title = { + Text( + text = stringResource(id = R.string.spotdl_version), + fontWeight = FontWeight.Bold + ) + }, + icon = Icons.Outlined.Info, + description = { Text(text = spotDLVersion) }) - }, onLongClickLabel = stringResource(id = R.string.open_settings) - ) - } - item { - PreferenceSwitch( - title = stringResource(R.string.print_details), - description = stringResource(R.string.print_details_desc), - icon = if (displayErrorReport) Icons.Outlined.Print else Icons.Outlined.PrintDisabled, - enabled = true, - onClick = { - displayErrorReport = !displayErrorReport - PreferencesUtil.updateValue(DEBUG, displayErrorReport) - }, - isChecked = displayErrorReport - ) + SettingsSwitch( + onCheckedChange = { + displayErrorReport = !displayErrorReport + PreferencesUtil.updateValue(DEBUG, displayErrorReport) + }, + checked = displayErrorReport, + title = { + Text( + text = stringResource(R.string.print_details), + fontWeight = FontWeight.Bold + ) + }, + icon = if (displayErrorReport) Icons.Outlined.Print else Icons.Outlined.PrintDisabled, + description = { Text(text = stringResource(R.string.print_details_desc)) }, + ) + } } - item{ - PreferenceSubtitle(text = stringResource(id = R.string.library_settings)) - } item { - PreferenceSwitch( - title = stringResource(id = R.string.use_cache), - description = stringResource(id = R.string.use_cache_desc), - icon = Icons.Outlined.Cached, - onClick = { - scope.launch { - useCache = !useCache - PreferencesUtil.updateValue(USE_CACHING, useCache) - } - }, - isChecked = useCache - ) + PreferenceSubtitle(text = stringResource(id = R.string.general_settings)) } item { - PreferenceSwitch( - title = stringResource(id = R.string.geo_bypass), - description = stringResource(id = R.string.use_geobypass_desc), - icon = Icons.Outlined.MyLocation, - onClick = { - scope.launch { - useGeobypass = !useGeobypass - PreferencesUtil.updateValue(GEO_BYPASS, useGeobypass) - } + SettingsSwitch( + onCheckedChange = { + useNotifications = !useNotifications + PreferencesUtil.updateValue(NOTIFICATION, useNotifications) }, - isChecked = useGeobypass + checked = useNotifications, + title = { + Text( + text = stringResource(R.string.use_notifications), + fontWeight = FontWeight.Bold + ) + }, + icon = if (useNotifications) Icons.Outlined.NotificationsActive else Icons.Outlined.NotificationsOff, + description = { + Text(text = stringResource(R.string.use_notifications_desc)) + }, + modifier = Modifier.clip( + RoundedCornerShape( + topStart = 8.dp, topEnd = 8.dp + ) + ), ) } item { - PreferenceSwitch( - title = stringResource(id = R.string.dont_filter_results), - description = stringResource(id = R.string.dont_filter_results_desc), - icon = Icons.Outlined.Filter, - onClick = { - scope.launch { - dontFilter = !dontFilter - PreferencesUtil.updateValue(DONT_FILTER_RESULTS, dontFilter) - } + SettingsSwitch( + onCheckedChange = { + configureBeforeDownload = !configureBeforeDownload + PreferencesUtil.updateValue(CONFIGURE, configureBeforeDownload) + }, + checked = configureBeforeDownload, + title = { + Text( + text = stringResource(R.string.pre_configure_download), + fontWeight = FontWeight.Bold + ) + }, + icon = Icons.Outlined.Construction, + description = { + Text(text = stringResource(R.string.pre_configure_download_desc)) }, - isChecked = dontFilter + modifier = Modifier.clip( + RoundedCornerShape( + bottomStart = 8.dp, bottomEnd = 8.dp + ) + ), ) } } diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/spotify/SpotifySettingsPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/spotify/SpotifySettingsPage.kt index 639ba1ef..2ccf6ecb 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/spotify/SpotifySettingsPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/spotify/SpotifySettingsPage.kt @@ -4,30 +4,41 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.PermIdentity +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.HorizontalDivider import com.bobbyesp.spowlo.ui.components.LargeTopAppBar import com.bobbyesp.spowlo.ui.components.PreferenceInfo -import com.bobbyesp.spowlo.ui.components.PreferenceItem import com.bobbyesp.spowlo.ui.components.PreferenceSubtitle -import com.bobbyesp.spowlo.ui.components.PreferenceSwitch +import com.bobbyesp.spowlo.ui.components.settings.SettingsItemNew +import com.bobbyesp.spowlo.ui.components.settings.SettingsSwitch import com.bobbyesp.spowlo.utils.PreferencesUtil import com.bobbyesp.spowlo.utils.SPOTIFY_CLIENT_ID import com.bobbyesp.spowlo.utils.SPOTIFY_CLIENT_SECRET @@ -63,6 +74,7 @@ fun SpotifySettingsPage(onBackPressed: () -> Unit) { var showClientIdDialog by remember { mutableStateOf(false) } var showClientSecretDialog by remember { mutableStateOf(false) } + var showHelpDialog by remember { mutableStateOf(false) } Scaffold( modifier = Modifier @@ -73,48 +85,85 @@ fun SpotifySettingsPage(onBackPressed: () -> Unit) { title = { Text( modifier = Modifier, - text = stringResource(id = R.string.spotify_settings), + text = stringResource(id = R.string.spotify_settings), fontWeight = FontWeight.Bold ) }, navigationIcon = { BackButton { onBackPressed() } - }, scrollBehavior = scrollBehavior + }, + scrollBehavior = scrollBehavior, + actions = { + IconButton(onClick = { + showHelpDialog = !showHelpDialog + }) { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = stringResource(R.string.how_does_it_work) + ) + } + } ) }, content = { - LazyColumn(Modifier.padding(it)) { + LazyColumn( + Modifier + .padding(it) + .padding(horizontal = 20.dp) + ) { item { - PreferenceSubtitle(text = stringResource(id = R.string.general_settings)) + PreferenceSubtitle(text = stringResource(id = R.string.general)) } item { - PreferenceSwitch( - title = stringResource(id = R.string.use_spotify_credentials), - isChecked = useSpotifyCredentials, - onClick = { - useSpotifyCredentials = !useSpotifyCredentials - PreferencesUtil.updateValue(USE_SPOTIFY_CREDENTIALS, useSpotifyCredentials) - } - ) - PreferenceItem( - title = stringResource(id = R.string.spotify_client_id), - description = stringResource(id = R.string.spotify_client_id_description), - icon = Icons.Outlined.PermIdentity, - enabled = useSpotifyCredentials, - onClick = { - showClientIdDialog = true - } - ) - PreferenceItem( - title = stringResource(id = R.string.spotify_client_secret), - description = stringResource(id = R.string.spotify_client_secret_description), - icon = Icons.Outlined.Key, - enabled = useSpotifyCredentials, - onClick = { - showClientSecretDialog = true - } - ) + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + ) { + SettingsSwitch( + title = { + Text(stringResource(id = R.string.use_spotify_credentials), fontWeight = FontWeight.Bold) + }, + checked = useSpotifyCredentials, + onCheckedChange = { + useSpotifyCredentials = !useSpotifyCredentials + PreferencesUtil.updateValue( + USE_SPOTIFY_CREDENTIALS, + useSpotifyCredentials + ) + }, + addTonalElevation = true + ) + Divider(color = MaterialTheme.colorScheme.surfaceVariant) + SettingsItemNew( + title = { + Text(stringResource(id = R.string.spotify_client_id), fontWeight = FontWeight.Bold) + }, + description = { + Text(stringResource(id = R.string.spotify_client_id_description)) + }, + icon = Icons.Outlined.PermIdentity, + onClick = { + showClientIdDialog = true + }, + enabled = useSpotifyCredentials, + addTonalElevation = true + ) + + SettingsItemNew( + title = { + Text(stringResource(id = R.string.spotify_client_secret), fontWeight = FontWeight.Bold) + }, + description = { + Text(stringResource(id = R.string.spotify_client_secret_description)) + }, + icon = Icons.Outlined.Key, + onClick = { + showClientSecretDialog = true + }, + enabled = useSpotifyCredentials, + addTonalElevation = true + ) + } } - item{ + item { HorizontalDivider(Modifier.padding(vertical = 6.dp)) PreferenceInfo( modifier = Modifier @@ -122,7 +171,6 @@ fun SpotifySettingsPage(onBackPressed: () -> Unit) { text = stringResource(id = R.string.spotify_credentials_info) ) } - } }) if (showClientIdDialog) { @@ -135,4 +183,33 @@ fun SpotifySettingsPage(onBackPressed: () -> Unit) { showClientSecretDialog = !showClientSecretDialog } } + if (showHelpDialog) { + SpotifySettingsPageInfoDialog { + showHelpDialog = !showHelpDialog + } + } +} + +@Composable +fun SpotifySettingsPageInfoDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(stringResource(id = R.string.spotify_api_info)) + }, + text = { + Text(stringResource(id = R.string.spotify_api_info_description)) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(id = R.string.agree)) + } + }, + icon = { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = stringResource(id = R.string.how_does_it_work) + ) + } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/updater/UpdaterPage.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/updater/UpdaterPage.kt index c47dcd87..ec22e63c 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/updater/UpdaterPage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/pages/settings/updater/UpdaterPage.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.ButtonDefaults @@ -29,10 +30,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.ui.common.booleanState @@ -40,9 +43,9 @@ import com.bobbyesp.spowlo.ui.common.intState import com.bobbyesp.spowlo.ui.components.BackButton import com.bobbyesp.spowlo.ui.components.HorizontalDivider import com.bobbyesp.spowlo.ui.components.PreferenceInfo -import com.bobbyesp.spowlo.ui.components.PreferenceSingleChoiceItem import com.bobbyesp.spowlo.ui.components.PreferenceSubtitle import com.bobbyesp.spowlo.ui.components.PreferenceSwitchWithContainer +import com.bobbyesp.spowlo.ui.components.settings.SettingsNewSingleChoiceItem import com.bobbyesp.spowlo.ui.dialogs.UpdateDialog import com.bobbyesp.spowlo.utils.AUTO_UPDATE import com.bobbyesp.spowlo.utils.PRE_RELEASE @@ -77,7 +80,7 @@ fun UpdaterPage(onBackPressed: () -> Unit) { LargeTopAppBar(title = { Text( modifier = Modifier, - text = stringResource(id = R.string.auto_update), + text = stringResource(id = R.string.auto_update), fontWeight = FontWeight.Bold ) }, navigationIcon = { BackButton { @@ -104,10 +107,11 @@ fun UpdaterPage(onBackPressed: () -> Unit) { ) } item { - PreferenceSingleChoiceItem( + SettingsNewSingleChoiceItem( text = stringResource(id = R.string.stable_channel), selected = updateChannel == STABLE, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) ) { updateChannel = STABLE UPDATE_CHANNEL.updateInt(updateChannel) @@ -115,10 +119,11 @@ fun UpdaterPage(onBackPressed: () -> Unit) { } item { - PreferenceSingleChoiceItem( + SettingsNewSingleChoiceItem( text = stringResource(id = R.string.pre_release_channel), selected = updateChannel == PRE_RELEASE, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) ) { updateChannel = PRE_RELEASE UPDATE_CHANNEL.updateInt(updateChannel) @@ -128,7 +133,7 @@ fun UpdaterPage(onBackPressed: () -> Unit) { var isLoading by remember { mutableStateOf(false) } Row( horizontalArrangement = Arrangement.End, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(top = 12.dp) ) { ProgressIndicatorButton( modifier = Modifier diff --git a/app/src/main/java/com/bobbyesp/spowlo/ui/theme/Theme.kt b/app/src/main/java/com/bobbyesp/spowlo/ui/theme/Theme.kt index 1b18406e..77e6eaa1 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/ui/theme/Theme.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/ui/theme/Theme.kt @@ -3,25 +3,19 @@ package com.bobbyesp.spowlo.ui.theme import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.os.Build import android.view.Window import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle -import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.style.LineBreak -import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.android.material.color.DynamicColors @@ -48,7 +42,6 @@ private tailrec fun Context.findWindow(): Window? = else -> null } -@OptIn(ExperimentalTextApi::class) @Composable fun SpowloTheme( darkTheme: Boolean = isSystemInDarkTheme(), diff --git a/app/src/main/java/com/bobbyesp/spowlo/utils/DownloaderUtil.kt b/app/src/main/java/com/bobbyesp/spowlo/utils/DownloaderUtil.kt index aa3e9b1d..5dbbc6c6 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/utils/DownloaderUtil.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/utils/DownloaderUtil.kt @@ -9,12 +9,20 @@ import com.bobbyesp.library.SpotDLRequest import com.bobbyesp.library.dto.Song import com.bobbyesp.spowlo.App.Companion.audioDownloadDir import com.bobbyesp.spowlo.App.Companion.context +import com.bobbyesp.spowlo.Downloader +import com.bobbyesp.spowlo.Downloader.onProcessEnded +import com.bobbyesp.spowlo.Downloader.onProcessStarted +import com.bobbyesp.spowlo.Downloader.onTaskEnded +import com.bobbyesp.spowlo.Downloader.onTaskError +import com.bobbyesp.spowlo.Downloader.onTaskStarted +import com.bobbyesp.spowlo.Downloader.toNotificationId import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.database.DownloadedSongInfo import com.bobbyesp.spowlo.ui.pages.settings.cookies.Cookie import com.bobbyesp.spowlo.utils.FilesUtil.getCookiesFile import com.bobbyesp.spowlo.utils.FilesUtil.getSdcardTempDir import com.bobbyesp.spowlo.utils.FilesUtil.moveFilesToSdcard +import com.bobbyesp.spowlo.utils.PreferencesUtil.getInt import com.bobbyesp.spowlo.utils.PreferencesUtil.getString import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,8 +32,8 @@ import java.util.UUID object DownloaderUtil { - private const val COOKIE_HEADER = "# Netscape HTTP Cookie File\n" + - "# Auto-generated by Spowlo built-in WebView\n" + private const val COOKIE_HEADER = + "# Netscape HTTP Cookie File\n" + "# Auto-generated by Spowlo built-in WebView\n" private const val TAG = "DownloaderUtil" @@ -41,9 +49,7 @@ object DownloaderUtil { data class DownloadPreferences( val downloadPlaylist: Boolean = PreferencesUtil.getValue(PLAYLIST), - val subdirectory: Boolean = PreferencesUtil.getValue(SUBDIRECTORY), val customPath: Boolean = PreferencesUtil.getValue(CUSTOM_PATH), - val outputPathTemplate: String = PreferencesUtil.getOutputPathTemplate(), val maxFileSize: String = MAX_FILE_SIZE.getString(), val cookies: Boolean = PreferencesUtil.getValue(COOKIES), val cookiesContent: String = PreferencesUtil.getCookies(), @@ -58,11 +64,13 @@ object DownloaderUtil { val useSyncedLyrics: Boolean = PreferencesUtil.getValue(SYNCED_LYRICS), val useCaching: Boolean = PreferencesUtil.getValue(USE_CACHING), val dontFilter: Boolean = PreferencesUtil.getValue(DONT_FILTER_RESULTS), - val geoBypass : Boolean = PreferencesUtil.getValue(GEO_BYPASS), + val geoBypass: Boolean = PreferencesUtil.getValue(GEO_BYPASS), val formatId: String = "", val privateMode: Boolean = PreferencesUtil.getValue(PRIVATE_MODE), val sdcard: Boolean = PreferencesUtil.getValue(SDCARD_DOWNLOAD), - val sdcardUri: String = SDCARD_URI.getString() + val sdcardUri: String = SDCARD_URI.getString(), + val extraDirectory: String = PreferencesUtil.getExtraDirectory(), + val threads: Int = THREADS.getInt() ) object CookieScheme { @@ -80,7 +88,8 @@ object DownloaderUtil { flush() } SQLiteDatabase.openDatabase( - "/data/data/com.bobbyesp.spowlo/app_webview/Default/Cookies", null, + "/data/data/com.bobbyesp.spowlo/app_webview/Default/Cookies", + null, SQLiteDatabase.OPEN_READONLY ).run { val projection = arrayOf( @@ -141,23 +150,20 @@ object DownloaderUtil { @CheckResult private fun getSongInfo( url: String? = null, - id: String = getRandomUUID() - ): Result> = - kotlin.runCatching { - val response: List = SpotDL.getInstance().getSongInfo(url ?: "") - mutableSongsState.update { - response - } + ): Result> = kotlin.runCatching { + val response: List = SpotDL.getInstance().getSongInfo(url ?: "") + mutableSongsState.update { response } + response + } @CheckResult fun fetchSongInfoFromUrl( - url: String, playlistItem: Int = 0, preferences: DownloadPreferences = DownloadPreferences() - ): Result> = - kotlin.run { - getSongInfo(url) - } + url: String + ): Result> = kotlin.run { + getSongInfo(url) + } fun updateSongsState(songs: List) { mutableSongsState.update { @@ -180,49 +186,57 @@ object DownloaderUtil { //get the audio quality private fun SpotDLRequest.addAudioQuality(): SpotDLRequest = this.apply { when (PreferencesUtil.getAudioQuality()) { - 0 -> addOption("--bitrate", "8k") - 1 -> addOption("--bitrate", "16k") - 2 -> addOption("--bitrate", "24k") - 3 -> addOption("--bitrate", "32k") - 4 -> addOption("--bitrate", "40k") - 5 -> addOption("--bitrate", "48k") - 6 -> addOption("--bitrate", "64k") - 7 -> addOption("--bitrate", "80k") - 8 -> addOption("--bitrate", "96k") - 9 -> addOption("--bitrate", "112k") - 10 -> addOption("--bitrate", "128k") - 11 -> addOption("--bitrate", "160k") - 12 -> addOption("--bitrate", "192k") - 13 -> addOption("--bitrate", "224k") - 14 -> addOption("--bitrate", "256k") - 15 -> addOption("--bitrate", "320k") - 16 -> addOption("--bitrate", "disable") - 17 -> addOption("--bitrate", "auto") + 0 -> addOption("--bitrate", "auto") + 1 -> addOption("--bitrate", "8k") + 2 -> addOption("--bitrate", "16k") + 3 -> addOption("--bitrate", "24k") + 4 -> addOption("--bitrate", "32k") + 5 -> addOption("--bitrate", "40k") + 6 -> addOption("--bitrate", "48k") + 7 -> addOption("--bitrate", "64k") + 8 -> addOption("--bitrate", "80k") + 9 -> addOption("--bitrate", "96k") + 10 -> addOption("--bitrate", "112k") + 11 -> addOption("--bitrate", "128k") + 12 -> addOption("--bitrate", "160k") + 13 -> addOption("--bitrate", "192k") + 14 -> addOption("--bitrate", "224k") + 15 -> addOption("--bitrate", "256k") + 16 -> addOption("--bitrate", "320k") + 17 -> addOption("--bitrate", "disable") } } - @CheckResult - fun downloadSong( - songInfo: Song = Song(), - playlistUrl: String = "", - playlistItem: Int = 0, - taskId: String, - downloadPreferences: DownloadPreferences, - progressCallback: ((Float, Long, String) -> Unit)? - ): Result> { - if (songInfo == Song()) return Result.failure(Throwable(context.getString(R.string.fetch_info_error_msg))) - with(downloadPreferences) { - val url = playlistUrl.ifEmpty { - songInfo.url - ?: return Result.failure(Throwable(context.getString(R.string.fetch_info_error_msg))) + private fun SpotDLRequest.addAudioProvider(): SpotDLRequest = this.apply { + when (PreferencesUtil.getAudioProvider()) { + 0 -> null + 1 -> { + addOption("--audio", "youtube-music") + addOption("youtube") } - val request = SpotDLRequest() - val pathBuilder = StringBuilder() + 2 -> addOption("--audio", "youtube-music") + 3 -> addOption("--audio", "youtube") + } + } + + //HERE GOES ALL THE DOWNLOADER OPTIONS + private fun commonRequest( + downloadPreferences: DownloadPreferences, + url: String, + request: SpotDLRequest, + pathBuilder: StringBuilder + ): SpotDLRequest { + with(downloadPreferences) { request.apply { addOption("download", url) pathBuilder.append(audioDownloadDir) + + if (extraDirectory.isNotEmpty()) { + pathBuilder.append("/").append(extraDirectory) + } + Log.d(TAG, "downloadSong: $pathBuilder") addOption("--output", pathBuilder.toString()) @@ -247,7 +261,7 @@ object DownloaderUtil { addOption("--lyrics", "synced") } - if(geoBypass) { + if (geoBypass) { addOption("--geo-bypass") } @@ -259,44 +273,63 @@ object DownloaderUtil { addAudioFormat() } - addOption("--audio", PreferencesUtil.getAudioProviderDesc()) - - if (useSpotifyPreferences) { - if (spotifyClientID.isEmpty() || spotifyClientSecret.isEmpty()) return Result.failure( - Throwable("Spotify client ID or secret is empty while you have the custom credentials option enabled! \n Please check your settings.") - ) - addOption("--client-id", spotifyClientID) - addOption("--client-secret", spotifyClientSecret) - } + addAudioProvider() for (s in request.buildCommand()) Log.d(TAG, s) - }.runCatching { - SpotDL.getInstance().execute(this, taskId, callback = progressCallback) - }.onFailure { th -> - return if (th.message?.contains("No such file or directory") == true) { - th.printStackTrace() - onFinishDownloading( - this, - songInfo = songInfo, - downloadPath = pathBuilder.toString(), - sdcardUri = sdcardUri - ) - } else { - return Result.failure(th) - } - }.onSuccess { response -> - return when { - response.output.contains("LookupError") -> Result.failure(Throwable("A LookupError occurred. The song wasn't found.")) - response.output.contains("YT-DLP") -> Result.failure(Throwable("An error occurred to yt-dlp while downloading the song. Please, report this issue in GitHub.")) - else -> onFinishDownloading( - this, - songInfo = songInfo, - downloadPath = pathBuilder.toString(), - sdcardUri = sdcardUri - ) + } + } + return request + } + @CheckResult + fun downloadSong( + songInfo: Song = Song(), + taskId: String, + downloadPreferences: DownloadPreferences, + progressCallback: ((Float, Long, String) -> Unit)? + ): Result> { + if (songInfo == Song()) return Result.failure(Throwable(context.getString(R.string.fetch_info_error_msg))) + with(downloadPreferences) { + val url = songInfo.url + + val request = SpotDLRequest() + val pathBuilder = StringBuilder() + commonRequest(downloadPreferences, url, request, pathBuilder) + .apply { + if (useSpotifyPreferences) { + if (spotifyClientID.isEmpty() || spotifyClientSecret.isEmpty()) return Result.failure( + Throwable("Spotify client ID or secret is empty while you have the custom credentials option enabled! \n Please check your settings.") + ) + addOption("--client-id", spotifyClientID) + addOption("--client-secret", spotifyClientSecret) + } + }.runCatching { + SpotDL.getInstance().execute(this, taskId, callback = progressCallback) + }.onFailure { th -> + return if (th.message?.contains("No such file or directory") == true) { + th.printStackTrace() + onFinishDownloading( + this, + songInfo = songInfo, + downloadPath = pathBuilder.toString(), + sdcardUri = sdcardUri + ) + } else { + return Result.failure(th) + } + }.onSuccess { response -> + return when { + response.output.contains("LookupError") -> Result.failure(Throwable("A LookupError occurred. The song wasn't found. Try changing the audio provider in the settings and also disabling the 'Don't filter results' option.")) + response.output.contains("YT-DLP") -> Result.failure(Throwable("An error occurred to yt-dlp while downloading the song. Please, report this issue in GitHub.")) + else -> onFinishDownloading( + this, + songInfo = songInfo, + downloadPath = pathBuilder.toString(), + sdcardUri = sdcardUri + ) + + } } - } return onFinishDownloading( this, songInfo = songInfo, @@ -307,21 +340,16 @@ object DownloaderUtil { } private fun onFinishDownloading( - preferences: DownloadPreferences, - songInfo: Song, - downloadPath: String, - sdcardUri: String + preferences: DownloadPreferences, songInfo: Song, downloadPath: String, sdcardUri: String ): Result> = preferences.run { if (privateMode) { Result.success(emptyList()) } else if (sdcard) { - Result.success( - moveFilesToSdcard( - sdcardUri = sdcardUri, - tempPath = context.getSdcardTempDir(songInfo.song_id) - ).apply { - insertInfoIntoDownloadHistory(songInfo, this) - }) + Result.success(moveFilesToSdcard( + sdcardUri = sdcardUri, tempPath = context.getSdcardTempDir(songInfo.song_id) + ).apply { + insertInfoIntoDownloadHistory(songInfo, this) + }) } else { Result.success( scanVideoIntoDownloadHistory( @@ -344,13 +372,15 @@ object DownloaderUtil { } private fun insertInfoIntoDownloadHistory( - songInfo: Song, - filePaths: List + songInfo: Song, filePaths: List ) { filePaths.forEach { filePath -> + val fullString = StringBuilder() + fullString.append(songInfo.name) + fullString.append(filePath) DatabaseUtil.insertInfo( DownloadedSongInfo( - id = 0, + id = createIntFromString(fullString.toString()), songName = songInfo.name, songAuthor = songInfo.artist, songUrl = songInfo.url, @@ -362,4 +392,74 @@ object DownloaderUtil { ) } } + + private fun createIntFromString(string: String): Int { + var int = 0 + for (i in string.indices) { + int += string[i].code + } + return int + } + + + fun executeParallelDownload(url: String, name: String) { + val taskId = Downloader.makeKey(url, url.reversed()) + ToastUtil.makeToastSuspend(context.getString(R.string.download_started_msg)) + + val pathBuilder = StringBuilder() + val downloadPreferences = DownloadPreferences() + val request = commonRequest(downloadPreferences, url, SpotDLRequest(), pathBuilder).apply { + addOption("--threads", downloadPreferences.threads.toString()) + } + + val isPlaylist = url.contains("playlist") + + onProcessStarted() + onTaskStarted(url, name) + kotlin.runCatching { + val response = SpotDL.getInstance().execute( + request = request, + processId = taskId, + forceProcessDestroy = true, + callback = { progress, _, text -> + NotificationsUtil.notifyProgress( + name + " - " + context.getString(R.string.parallel_download), + notificationId = taskId.toNotificationId(), + progress = progress.toInt(), + text = text + ) + Downloader.updateTaskOutput( + url = url, line = text, progress = progress, isPlaylist = isPlaylist + ) + }) + //clear all the lines that contains a "…" on it + val finalResponse = removeDuplicateLines(clearLinesWithEllipsis(response.output)) + onTaskEnded(url, finalResponse, name) + }.onFailure { + Log.d("Canceled?", "Exception: $it") + it.printStackTrace() + ToastUtil.makeToastSuspend(context.getString(R.string.download_error_msg)) + if (it is SpotDL.CanceledException) return@onFailure + it.message.run { + if (isNullOrEmpty()) onTaskEnded(url) + else onTaskError(this, url) + } + } + onProcessEnded() + ToastUtil.makeToastSuspend(context.getString(R.string.download_finished_msg)) + } + + fun clearLinesWithEllipsis(input: String): String { + val lines = input.split("\n") + .filterNot { it.contains("…") } + .joinToString("\n") + return lines + } + + fun removeDuplicateLines(input: String): String { + val lines = input.split("\n") + .distinct() + .joinToString("\n") + return lines + } } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/utils/NotificationsUtil.kt b/app/src/main/java/com/bobbyesp/spowlo/utils/NotificationsUtil.kt new file mode 100644 index 00000000..798383ed --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/utils/NotificationsUtil.kt @@ -0,0 +1,245 @@ +package com.bobbyesp.spowlo.utils + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE +import com.bobbyesp.spowlo.App.Companion.context +import com.bobbyesp.spowlo.NotificationActionReceiver +import com.bobbyesp.spowlo.NotificationActionReceiver.Companion.ACTION_CANCEL_TASK +import com.bobbyesp.spowlo.NotificationActionReceiver.Companion.ACTION_ERROR_REPORT +import com.bobbyesp.spowlo.NotificationActionReceiver.Companion.ACTION_KEY +import com.bobbyesp.spowlo.NotificationActionReceiver.Companion.ERROR_REPORT_KEY +import com.bobbyesp.spowlo.NotificationActionReceiver.Companion.NOTIFICATION_ID_KEY +import com.bobbyesp.spowlo.NotificationActionReceiver.Companion.TASK_ID_KEY +import com.bobbyesp.spowlo.R + +private const val TAG = "NotificationUtil" + +@SuppressLint("StaticFieldLeak") +object NotificationsUtil { + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private const val PROGRESS_MAX = 100 + private const val PROGRESS_INITIAL = 0 + private const val CHANNEL_ID = "download_notification" + private const val SERVICE_CHANNEL_ID = "download_service" + private const val NOTIFICATION_GROUP_ID = "spowlo.download.notification" + private const val DEFAULT_NOTIFICATION_ID = 100 + const val SERVICE_NOTIFICATION_ID = 123 + private lateinit var serviceNotification: Notification + + // private var builder = +// NotificationCompat.Builder(context, CHANNEL_ID).setSmallIcon(R.drawable.ic_stat_seal) + private val commandNotificationBuilder = + NotificationCompat.Builder(context, CHANNEL_ID).setSmallIcon(R.drawable.ic_launcher_monochrome) + + @RequiresApi(Build.VERSION_CODES.O) + fun createNotificationChannel() { + val name = context.getString(R.string.channel_name) + val descriptionText = context.getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_LOW + val channelGroup = + NotificationChannelGroup(NOTIFICATION_GROUP_ID, context.getString(R.string.download)) + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + group = NOTIFICATION_GROUP_ID + } + val serviceChannel = NotificationChannel(SERVICE_CHANNEL_ID, name, importance).apply { + description = context.getString(R.string.service_title) + group = NOTIFICATION_GROUP_ID + } + notificationManager.createNotificationChannelGroup(channelGroup) + notificationManager.createNotificationChannel(channel) + notificationManager.createNotificationChannel(serviceChannel) + } + + fun notifyProgress( + title: String, + notificationId: Int = DEFAULT_NOTIFICATION_ID, + progress: Int = PROGRESS_INITIAL, + taskId: String? = null, + text: String? = null + ) { + if (!PreferencesUtil.getValue(NOTIFICATION)) return + val pendingIntent = taskId?.let { + Intent(context.applicationContext, NotificationActionReceiver::class.java) + .putExtra(TASK_ID_KEY, taskId) + .putExtra(NOTIFICATION_ID_KEY, notificationId) + .putExtra(ACTION_KEY, ACTION_CANCEL_TASK).run { + PendingIntent.getBroadcast( + context.applicationContext, + notificationId, + this, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + } + } + + NotificationCompat.Builder(context, CHANNEL_ID).setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(title) + .setProgress(PROGRESS_MAX, progress, progress <= 0) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .run { + pendingIntent?.let { + addAction( + R.drawable.outline_cancel_24, + context.getString(R.string.cancel), + it + ) + } + notificationManager.notify(notificationId, build()) + } + } + + fun finishNotification( + notificationId: Int = DEFAULT_NOTIFICATION_ID, + title: String? = null, + text: String? = null, + intent: PendingIntent? = null, + ) { + Log.d(TAG, "finishNotification: ") + notificationManager.cancel(notificationId) + if (!PreferencesUtil.getValue(NOTIFICATION)) return + + val builder = + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentText(text) + .setOngoing(false) + .setAutoCancel(true) + title?.let { builder.setContentTitle(title) } + intent?.let { builder.setContentIntent(intent) } + notificationManager.notify(notificationId, builder.build()) + } + + fun finishNotificationForParallelDownloads( + notificationId: Int = DEFAULT_NOTIFICATION_ID, + title: String? = null, + text: String? = null, + ) { +// notificationManager.cancel(notificationId) + val builder = + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentText(text) + .setProgress(0, 0, false) + .setAutoCancel(true) + .setOngoing(false) + .setStyle(null) + title?.let { builder.setContentTitle(title) } + + notificationManager.notify(notificationId, builder.build()) + } + + fun makeServiceNotification(intent: PendingIntent): Notification { + serviceNotification = NotificationCompat.Builder(context, SERVICE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(context.getString(R.string.service_title)) + .setOngoing(true) + .setContentIntent(intent) + .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE) + .build() + return serviceNotification + } + + fun updateServiceNotification(index: Int, itemCount: Int) { + serviceNotification = NotificationCompat.Builder(context, serviceNotification) + .setContentTitle(context.getString(R.string.service_title) + " ($index/$itemCount)") + .build() + notificationManager.notify(SERVICE_NOTIFICATION_ID, serviceNotification) + } + + fun cancelNotification(notificationId: Int) { + notificationManager.cancel(notificationId) + } + + fun makeErrorReportNotification( + title: String = context.getString(R.string.download_error_msg), + notificationId: Int, + error: String, + ) { + if (!PreferencesUtil.getValue(NOTIFICATION)) return + + val intent = Intent() + .setClass(context, NotificationActionReceiver::class.java) + .putExtra(NOTIFICATION_ID_KEY, notificationId) + .putExtra(ERROR_REPORT_KEY, error) + .putExtra(ACTION_KEY, ACTION_ERROR_REPORT) + + val pendingIntent = PendingIntent.getBroadcast( + context, + notificationId, + intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + NotificationCompat.Builder(context, CHANNEL_ID).setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(title) + .setContentText(error) + .setOngoing(false) + .addAction( + R.drawable.outline_content_copy_24, + context.getString(R.string.copy_error_report), + pendingIntent + ).run { + notificationManager.cancel(notificationId) + notificationManager.notify(notificationId, build()) + } + } + + fun makeNotificationForParallelDownloads( + notificationId: Int, + taskId: String, + progress: Int, + text: String? = null, + extraString: String, + taskUrl: String + ) { + if (!PreferencesUtil.getValue(NOTIFICATION)) return + + val intent = Intent(context.applicationContext, NotificationActionReceiver::class.java) + .putExtra(TASK_ID_KEY, taskId) + .putExtra(NOTIFICATION_ID_KEY, notificationId) + .putExtra(ACTION_KEY, ACTION_CANCEL_TASK) + + val pendingIntent = PendingIntent.getBroadcast( + context.applicationContext, + notificationId, + intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + NotificationCompat.Builder(context, CHANNEL_ID).setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle("[${extraString}_${taskUrl}] " + context.getString(R.string.execute_parallel_download)) + .setContentText(text) + .setOngoing(true) + .setProgress(PROGRESS_MAX, progress, progress == -1) + .addAction( + R.drawable.outline_cancel_24, + context.getString(R.string.cancel), + pendingIntent + ) + .run { + notificationManager.notify(notificationId, build()) + } + } + + fun cancelAllNotifications() { + notificationManager.cancelAll() + } + + fun areNotificationsEnabled(): Boolean { + return if (Build.VERSION.SDK_INT <= 24) true else notificationManager.areNotificationsEnabled() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/utils/PreferencesUtil.kt b/app/src/main/java/com/bobbyesp/spowlo/utils/PreferencesUtil.kt index d0717fdc..35063b63 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/utils/PreferencesUtil.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/utils/PreferencesUtil.kt @@ -8,9 +8,9 @@ import androidx.compose.ui.res.stringResource import androidx.core.os.LocaleListCompat import com.bobbyesp.spowlo.App import com.bobbyesp.spowlo.App.Companion.applicationScope +import com.bobbyesp.spowlo.App.Companion.context import com.bobbyesp.spowlo.App.Companion.isFDroidBuild import com.bobbyesp.spowlo.R -import com.bobbyesp.spowlo.database.CommandTemplate import com.bobbyesp.spowlo.database.CookieProfile import com.bobbyesp.spowlo.ui.pages.settings.about.LocalAsset import com.bobbyesp.spowlo.ui.theme.DEFAULT_SEED_COLOR @@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -const val CUSTOM_COMMAND = "custom_command" const val SPOTDL = "spotDL_Init" const val DEBUG = "debug" const val CONFIGURE = "configure" @@ -37,22 +36,23 @@ const val AUDIO_FORMAT = "audio_format" const val AUDIO_QUALITY = "audio_quality" const val WELCOME_DIALOG = "welcome_dialog" const val AUDIO_DIRECTORY = "audio_dir" +const val EXTRA_DIRECTORY = "extra_dir" const val ORIGINAL_AUDIO = "original_audio" const val SDCARD_DOWNLOAD = "sdcard_download" const val SDCARD_URI = "sd_card_uri" -const val SUBDIRECTORY = "sub-directory" const val PLAYLIST = "playlist" const val LANGUAGE = "language" const val NOTIFICATION = "notification" private const val THEME_COLOR = "theme_color" const val PALETTE_STYLE = "palette_style" const val CUSTOM_PATH = "custom_path" -const val OUTPUT_PATH_TEMPLATE = "path_template" const val USE_YT_METADATA = "use_yt_metadata" const val USE_SPOTIFY_CREDENTIALS = "use_spotify_credentials" const val SYNCED_LYRICS = "synced_lyrics" +const val SKIP_INFO_FETCH = "skip_info_fetch" + const val SPOTIFY_CLIENT_ID = "spotify_client_id" const val SPOTIFY_CLIENT_SECRET = "spotify_client_secret" @@ -60,6 +60,7 @@ const val USE_CACHING = "use_caching" const val DONT_FILTER_RESULTS = "dont_filter_results" const val GEO_BYPASS = "geo-bypass" const val AUDIO_PROVIDER = "audio_provider" +const val THREADS = "threads" const val SPOTDL_UPDATE = "spotdl_update" const val TEMPLATE_ID = "template_id" @@ -79,11 +80,9 @@ const val SYSTEM_DEFAULT = 0 const val STABLE = 0 const val PRE_RELEASE = 1 -const val TEMPLATE_EXAMPLE = """--audio youtube-music --dont-filter-results""" - private val StringPreferenceDefaults = mapOf( - OUTPUT_PATH_TEMPLATE to "{artists} - {title}.{output-ext}", + EXTRA_DIRECTORY to "", SPOTIFY_CLIENT_ID to "", SPOTIFY_CLIENT_SECRET to "", ) @@ -102,6 +101,8 @@ private val BooleanPreferenceDefaults = DONT_FILTER_RESULTS to false, SPOTDL_UPDATE to true, GEO_BYPASS to false, + SKIP_INFO_FETCH to false, + NOTIFICATION to true, ) private val IntPreferenceDefaults = mapOf( @@ -114,6 +115,7 @@ private val IntPreferenceDefaults = mapOf( AUDIO_QUALITY to 17, AUDIO_PROVIDER to 0, UPDATE_CHANNEL to STABLE, + THREADS to 1, ) val palettesMap = mapOf( @@ -146,7 +148,7 @@ object PreferencesUtil { fun encodeString(key: String, string: String) = key.updateString(string) fun containsKey(key: String) = kv.containsKey(key) - fun getOutputPathTemplate(): String = OUTPUT_PATH_TEMPLATE.getString() + fun getExtraDirectory(): String = EXTRA_DIRECTORY.getString() fun getAudioFormat(): Int = AUDIO_FORMAT.getInt() @@ -161,54 +163,59 @@ object PreferencesUtil { 2 -> "ogg" 3 -> "opus" 4 -> "m4a" - 5 -> "Default" + 5 -> context.getString(R.string.not_specified) else -> "mp3" } } fun getAudioProviderDesc(audioProviderInt: Int = getAudioProvider()): String { return when (audioProviderInt){ - 0 -> "youtube-music" - 1 -> "youtube" - else -> "youtube-music" + 0 -> context.getString(R.string.default_option) + 1 -> context.getString(R.string.both) + 2 -> "Youtube Music" + 3 -> "Youtube" + else -> "Youtube Music" } } @Composable fun getAudioProviderIcon(audioProviderInt: Int = getAudioProvider()): ImageVector { return when (audioProviderInt){ - 0 -> LocalAsset(id = R.drawable.youtube_music_icons8) - 1 -> LocalAsset(id = R.drawable.icons8_youtube) + 2 -> LocalAsset(id = R.drawable.youtube_music_icons8) + 3 -> LocalAsset(id = R.drawable.icons8_youtube) else -> LocalAsset(id = R.drawable.youtube_music_icons8) } } fun getAudioQualityDesc(audioQualityStr: Int = getAudioQuality()): String { return when (audioQualityStr) { - 0 -> "8k" - 1 -> "16k" - 2 -> "24k" - 3 -> "32k" - 4 -> "40k" - 5 -> "48k" - 6 -> "64k" - 7 -> "80k" - 8 -> "96k" - 9 -> "112k" - 10 -> "128k" - 11 -> "160k" - 12 -> "192k" - 13 -> "224k" - 14 -> "256k" - 15 -> "320k" - 16 -> "disable" - 17 -> "auto" + 0 -> context.getString(R.string.not_specified) + 1 -> "8 kbps" + 2 -> "16 kbps" + 3 -> "24 kbps" + 4 -> "32 kbps" + 5 -> "40 kbps" + 6 -> "48 kbps" + 7 -> "64 kbps" + 8 -> "80 kbps" + 9 -> "96 kbps" + 10 -> "112 kbps" + 11 -> "128 kbps" + 12 -> "160 kbps" + 13 -> "192 kbps" + 14 -> "224 kbps" + 15 -> "256 kbps" + 16 -> "320 kbps" + 17 -> context.getString(R.string.not_convert) else -> "auto" } } fun isNetworkAvailableForDownload() = - CELLULAR_DOWNLOAD.getBoolean() || !App.connectivityManager.isActiveNetworkMetered + !App.connectivityManager.isActiveNetworkMetered //CELLULAR_DOWNLOAD.getBoolean() || + + //check if the phone is connected to a network source (wifi or mobile data) + fun isNetworkAvailable() = App.connectivityManager.activeNetworkInfo?.isConnected == true fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(!isFDroidBuild()) @@ -303,17 +310,6 @@ object PreferencesUtil { fun getCookies(): String = cookiesStateFlow.value - val templateStateFlow: StateFlow> = - DatabaseUtil.getTemplateFlow().distinctUntilChanged().stateIn( - applicationScope, started = SharingStarted.Eagerly, emptyList() - ) - - fun getTemplate(): CommandTemplate { - return templateStateFlow.value.run { - find { it.id == TEMPLATE_ID.getInt() } ?: first() - } - } - } data class DarkThemePreference( diff --git a/app/src/main/java/com/bobbyesp/spowlo/utils/TextUtils.kt b/app/src/main/java/com/bobbyesp/spowlo/utils/TextUtils.kt index da649310..ff40868b 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/utils/TextUtils.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/utils/TextUtils.kt @@ -2,14 +2,10 @@ package com.bobbyesp.spowlo.utils import android.content.ClipboardManager import android.content.Context -import android.content.res.Resources -import android.util.Log import android.widget.Toast import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.core.text.isDigitsOnly -import com.bobbyesp.spowlo.App import com.bobbyesp.spowlo.App.Companion.applicationScope import com.bobbyesp.spowlo.App.Companion.context import com.bobbyesp.spowlo.R @@ -38,10 +34,10 @@ object GeneralTextUtils { fun convertDuration(durationOfSong: Double): String { //First of all the duration comes with this format "146052" but it has to be "146.052" var duration = 0.0 - if (durationOfSong > 100000.0){ - duration = durationOfSong / 1000 + duration = if (durationOfSong > 100000.0){ + durationOfSong / 1000 } else { - duration = durationOfSong + durationOfSong } val hours = (duration / 3600).toInt() val minutes = ((duration % 3600) / 60).toInt() diff --git a/app/src/main/java/com/bobbyesp/spowlo/utils/UpdateUtil.kt b/app/src/main/java/com/bobbyesp/spowlo/utils/UpdateUtil.kt index 41dbf50a..e21359f1 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/utils/UpdateUtil.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/utils/UpdateUtil.kt @@ -53,13 +53,13 @@ object UpdateUtil { private val _updateViewState = MutableStateFlow(UpdateViewState()) val updateViewState = _updateViewState.asStateFlow() - fun showUpdateDrawer(){ + fun showUpdateDrawer() { _updateViewState.update { it.copy(drawerState = ModalBottomSheetState(ModalBottomSheetValue.Expanded)) } } - fun hideUpdateDrawer(){ + fun hideUpdateDrawer() { _updateViewState.update { it.copy(drawerState = ModalBottomSheetState(ModalBottomSheetValue.Hidden)) } @@ -81,48 +81,44 @@ object UpdateUtil { .build() private val requestForReleases = - Request.Builder().url("https://api.github.com/repos/${OWNER}/${REPO}/releases") - .build() + Request.Builder().url("https://api.github.com/repos/${OWNER}/${REPO}/releases").build() private val jsonFormat = Json { ignoreUnknownKeys = true } - suspend fun updateSpotDL(): SpotDL.UpdateStatus? = - withContext(Dispatchers.IO) { - SpotDL.getInstance().updateSpotDL( - context, - "https://api.github.com/repos/spotDL/spotify-downloader/releases/latest" - ).apply { - if (this == SpotDL.UpdateStatus.DONE) - SpotDL.getInstance().version(context)?.let { - PreferencesUtil.encodeString(SPOTDL, it) - } + suspend fun updateSpotDL(): SpotDL.UpdateStatus? = withContext(Dispatchers.IO) { + SpotDL.getInstance().updateSpotDL( + context + ).apply { + if (this == SpotDL.UpdateStatus.DONE) SpotDL.getInstance().version(context)?.let { + PreferencesUtil.encodeString(SPOTDL, it) } } + } private suspend fun getLatestRelease(): LatestRelease { return suspendCoroutine { continuation -> client.newCall(requestForReleases).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - val responseData = response.body.string() + override fun onResponse(call: Call, response: Response) { + val responseData = response.body.string() // val latestRelease = jsonFormat.decodeFromString(responseData) - val releaseList = - jsonFormat.decodeFromString>(responseData) - val latestRelease = - releaseList.filter { if (UPDATE_CHANNEL.getInt() == STABLE) it.name.toVersion() is Version.Stable else true } - .maxByOrNull { it.name.toVersion() } - ?: throw Exception("null response") - releaseList.sortedBy { it.name.toVersion() }.forEach { - Log.d(TAG, it.tagName.toString()) + val releaseList = + jsonFormat.decodeFromString>(responseData) + val latestRelease = + releaseList.filter { if (UPDATE_CHANNEL.getInt() == STABLE) it.name.toVersion() is Version.Stable else true } + .maxByOrNull { it.name.toVersion() } + ?: throw Exception("null response") + releaseList.sortedBy { it.name.toVersion() }.forEach { + Log.d(TAG, it.tagName.toString()) + } + response.body.close() + continuation.resume(latestRelease) } - response.body.close() - continuation.resume(latestRelease) - } - override fun onFailure(call: Call, e: IOException) { - continuation.resumeWithException(e) - } - }) + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + }) } } @@ -146,8 +142,7 @@ object UpdateUtil { } - private fun Context.getLatestApk() = - File(getExternalFilesDir("apk"), "latest.apk") + private fun Context.getLatestApk() = File(getExternalFilesDir("apk"), "latest.apk") private fun Context.getFileProvider() = "${packageName}.provider" @@ -167,8 +162,7 @@ object UpdateUtil { } suspend fun downloadApk( - context: Context = App.context, - latestRelease: LatestRelease + context: Context = App.context, latestRelease: LatestRelease ): Flow = withContext(Dispatchers.IO) { val apkVersion = context.packageManager.getPackageArchiveInfo( context.getLatestApk().absolutePath, 0 @@ -289,10 +283,7 @@ object UpdateUtil { sealed class Version( - val major: Int, - val minor: Int, - val patch: Int, - val build: Int = 0 + val major: Int, val minor: Int, val patch: Int, val build: Int = 0 ) : Comparable { companion object { private const val BUILD = 1L @@ -306,8 +297,7 @@ object UpdateUtil { class Beta(versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int) : Version(versionMajor, versionMinor, versionPatch, versionBuild) { - override fun toVersionName(): String = - "${major}.${minor}.${patch}-beta.$build" + override fun toVersionName(): String = "${major}.${minor}.${patch}-beta.$build" override fun toNumber(): Long = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD @@ -316,8 +306,7 @@ object UpdateUtil { class Stable(versionMajor: Int = 0, versionMinor: Int = 0, versionPatch: Int = 0) : Version(versionMajor, versionMinor, versionPatch) { - override fun toVersionName(): String = - "${major}.${minor}.${patch}" + override fun toVersionName(): String = "${major}.${minor}.${patch}" override fun toNumber(): Long = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + 50 @@ -326,14 +315,9 @@ object UpdateUtil { } class ReleaseCandidate( - versionMajor: Int, - versionMinor: Int, - versionPatch: Int, - versionBuild: Int - ) : - Version(versionMajor, versionMinor, versionPatch, versionBuild) { - override fun toVersionName(): String = - "${major}.${minor}.${patch}-rc.$build" + versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int + ) : Version(versionMajor, versionMinor, versionPatch, versionBuild) { + override fun toVersionName(): String = "${major}.${minor}.${patch}-rc.$build" override fun toNumber(): Long = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + 25 diff --git a/app/src/main/res/drawable/outline_cancel_24.xml b/app/src/main/res/drawable/outline_cancel_24.xml new file mode 100644 index 00000000..50061cd3 --- /dev/null +++ b/app/src/main/res/drawable/outline_cancel_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_content_copy_24.xml b/app/src/main/res/drawable/outline_content_copy_24.xml new file mode 100644 index 00000000..875963c5 --- /dev/null +++ b/app/src/main/res/drawable/outline_content_copy_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a6b3daec..549a62e0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,2 +1,54 @@ - \ No newline at end of file + + Artista del álbum + Marcado + Sonido + Buscar actualizaciones + Artista + Cambia desde donde quieres descargar las canciones + Álbum + Mod AMOLED clonado + Mod AMOLED + Ocurrió un error en la búsqueda + Una descarga ya está en progreso. + Una nueva actualización está disponible! + Ocurrió un error al intentar actualizar la aplicación + Ocurrió un error al buscar acutalizaciones + Ocurrió un error al buscar la información de la canción + Ocurrió un error al intentar conectarse a la API de Spotify + Un mod de Spotify normal con saltos ilimitados, escucha en demanda, libre de anuncios y con las últimas funciones. Esto sustituye la versión original de Spotify (la que descargas desde la Play Store). + Canal Beta + Versión de la aplicación + Un mod de Spotify normal con saltos ilimitados, escucha en demanda, libre de anuncios y con las últimas funciones pero clonado. Esto significa que se instalará como una aplicación aparte de la original, podiendo tener ambas instaladas a la vez. + Proveedor de audio + Calidad de sonido + La llamada a la API de los Mods no fue bien… + Cancelar descarga + Mira el código fuente de Spowlo en GitHub! + Cambia como se ve la aplicación + Cambia ajustes relacionados con la API de Spotify… + Cambia el formato de tus descargas + Ajustes adicionales + Ocurrió un error al intentar descargar la canción + Acerca de + %.2f GB + Añadir + %.2f MB + Cambia tu configuración de internet + "Una versión ligera de Spotify sin anuncios y con saltos ilimitados. " + %1$d canción(es) + Estás seguro\? + Activa el uso de la aplicación en segundo plano para asegurarse de que todo funciona correctamente. + Ajusta tu descarga + Apariencia + Auto-actualizar + Álbum + Auto-actualización de la aplicación, canal de actualización… + Configuración de la batería + Cancelar + Cambia donde las descargas son guardadas + Apariencia + Directorio de los archivos de sonido + Formato del audio + Ocurrió un error desconocido. + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00d83843..c23b18e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,7 +37,7 @@ Spotify URL or query Activate the usage of the app in background to make sure that all works fine. Battery configuration - General + General Appearance Change how the app looks like About @@ -77,7 +77,7 @@ An error occurred while trying to download the song The download has finished Preserve original audio - Keep the original downloaded audio file without conversions/compression. + Keep the original downloaded audio file The best songs downloader for Android powered by spotDL. Console output Spotify client ID @@ -254,8 +254,76 @@ Audio provider Choose from where you want to download the songs Calling the MODs API wasn\'t successful - This mods are hosted by me. If you cannot download them, please, report it in the Telegram channel if possible! + This mods are hosted by me. If you cannot download them, please, report it in the Telegram chatroom if possible! SpotDL is up to date Geo bypass Use a localization bypass to download songs from countries that YT Music is restricted. + An error occurred while trying to connect to the Spotify API + What would you like to download? + No results were found + Single + Loading the page… + Not specified + Default + Don\'t convert + An error occurred while searching + Type something on the text box for searching through Spotify! + Reload the page + Copy the error + Track artwork + Album + Artist + Playlist + Track + Showing %1$d results + Sorry, this page is not yet implemented ;( + Go back + Both + The app updates checker failed + Popularity + Duration + Download started + Download finished + Loudness + Tempo + Threads + Change how many downloads at a time can be running. This doesn\'t apply on single track downloads. + Number of threads + Download log + Copy full log + Copy error report + Restart + There is no running downloads + Downloading + Open log + Restart task + Download tasks + Tasks + An error occurred + Font size + Download + Show up your downloads on notification centre + Spowlo is downloading + Executing parallel download + Followers + Here will appear all the downloads that you make from the searcher page. + The log has been copied to the clipboard + Advanced features + Change more advanced settings of spotDL and parallel downloads + General settings + Use notifications + Pop up notifications with the actual downloads progress + Agree + Spotify API + This Spotify credentials can be used for having more closer and precise results to the songs requested in the main downloader page of the app. \n\nThis doesn\'t make effect on the searcher page because the app already uses extended-quota API credentials provided by Spotify. + Parallel download + Tracks + Playlists + Configure download + Open the configuration dialog before every download + The spotDL update failed. + SpotDL is updated. You are using the version %1$s + See playlist + Metadata viewer + Albums \ No newline at end of file diff --git a/color/build.gradle.kts b/color/build.gradle.kts index c1bb3faa..89829254 100644 --- a/color/build.gradle.kts +++ b/color/build.gradle.kts @@ -37,6 +37,4 @@ dependencies { api(libs.androidx.core.ktx) api(libs.androidx.compose.foundation) api(libs.androidx.compose.material3) - implementation("androidx.core:core-ktx:+") - } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 3c5031eb..09678af1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,6 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +CLIENT_ID=abcad8ba647d4b0ebae797a8f444ac9b +CLIENT_SECRET=7ac6711e50044f1db20e4610f10f1f98 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 14796f37..602db51c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,27 +1,30 @@ [versions] -accompanist = "0.28.0" -androidGradlePlugin = "7.4.0" +accompanist = "0.29.2-rc" +androidGradlePlugin = "7.4.2" androidxComposeBom = "2023.01.00" androidxComposeCompiler = "1.4.0" -androidxCore = "1.9.0" +androidxCore = "1.10.0-rc01" androidMaterial = "1.9.0-alpha02" androidxAppCompat = "1.7.0-alpha02" androidxActivity = "1.6.1" markdownDependency = "0.3.2" navTransitions = "0.11.0-alpha" +androidxPaging = "3.1.1" +paginationCompose = "1.0.0-alpha18" -androidxLifecycle = "2.6.0-rc01" +androidxLifecycle = "2.6.0" androidxNavigation = "2.5.3" -androidxComposeMaterial3 = "1.1.0-alpha07" +androidxComposeMaterial3 = "1.1.0-alpha08" androidxEspresso = "3.5.1" androidxHiltNavigationCompose = "1.0.0" androidxTestExt = "1.1.5" -spotdlAndroidVersion = "fc391374c9" +spotdlAndroidVersion = "3d8e42a3f0" spotifyApiKotlinVersion = "3.8.8" +shimmerLibrary = "1.0.4" crashHandlerVersion = "2.0.2" coil = "2.2.2" @@ -64,6 +67,11 @@ accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist #Accompanist material component accompanist-material = { group = "com.google.accompanist", name = "accompanist-navigation-material", version.ref = "accompanist" } +#Paging3 +paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "androidxPaging" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paginationCompose" } + + #Markdown parser markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "markdownDependency" } @@ -118,6 +126,7 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = mmkv = { group = "com.tencent", name = "mmkv", version.ref = "mmkv" } crash-handler = { group = "com.github.thelumiereguy", name = "CrashWatcher-Android", version.ref = "crashHandlerVersion" } +shimmer = { group = "com.valentinilk.shimmer", name = "compose-shimmer", version.ref = "shimmerLibrary" } soup-anims-core = { group = "io.github.fornewid", name = "material-motion-compose-core", version.ref = "navTransitions" } soup-anims-navigation = { group = "io.github.fornewid", name = "material-motion-compose-navigation", version.ref = "navTransitions" }