diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1e8ec56..c18b214d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ plugins { apply(plugin = "dagger.hilt.android.plugin") val versionMajor = 0 -val versionMinor = 1 -val versionPatch = 3 +val versionMinor = 2 +val versionPatch = 0 val versionBuild = 0 val isStable = true @@ -22,9 +22,11 @@ val navigationVersion: String by rootProject.extra val roomVersion: String by rootProject.extra val accompanistVersion: String by rootProject.extra val composeMd3Version: String by rootProject.extra +val youtubedlAndroidVersion: String by rootProject.extra val coilVersion: String by rootProject.extra val okhttpVersion: String by rootProject.extra val hiltVersion: String by rootProject.extra +val spotifyLibrary: String by rootProject.extra val keystorePropertiesFile = rootProject.file("keystore.properties") @@ -86,10 +88,43 @@ android { ) if (keystorePropertiesFile.exists()) signingConfig = signingConfigs.getByName("debug") + buildConfigField("String", + "SPOTIFY_CLIENT_ID", + "\"abcad8ba647d4b0ebae797a8f444ac9b\"") + buildConfigField("String", + "SPOTIFY_REDIRECT_URI", + "\"spowlo://spotify-auth\"") + buildConfigField( + "String", + "SPOTIFY_REDIRECT_URI_PKCE", + "\"spowlo://spotify-pkce\"" + ) + packagingOptions { + resources.excludes.add("META-INF/*.kotlin_module") + } + matchingFallbacks.add(0, "debug") + matchingFallbacks.add(1, "release") } debug { if (keystorePropertiesFile.exists()) signingConfig = signingConfigs.getByName("debug") + buildConfigField( + "String", + "SPOTIFY_CLIENT_ID", + "\"abcad8ba647d4b0ebae797a8f444ac9b\"") + + buildConfigField( + "String", + "SPOTIFY_REDIRECT_URI_AUTH", + "\"spowlo://spotify-auth\"") + + buildConfigField( + "String", + "SPOTIFY_REDIRECT_URI_PKCE", + "\"spowlo://spotify-pkce\"" + ) + matchingFallbacks.add(0, "debug") + matchingFallbacks.add(1, "release") } } compileOptions { @@ -165,11 +200,11 @@ dependencies { kapt("androidx.hilt:hilt-compiler:1.0.0") implementation("com.google.dagger:hilt-android:$hiltVersion") kapt("com.google.dagger:hilt-android-compiler:$hiltVersion") - //Room (Databases) - /*implementation("androidx.room:room-runtime:$roomVersion") + //Room (Databases) + implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-ktx:$roomVersion") - kapt("androidx.room:room-compiler:$roomVersion")*/ + kapt("androidx.room:room-compiler:$roomVersion") // Retrofit and okhttp implementation("com.squareup.retrofit2:retrofit:2.9.0") @@ -184,6 +219,11 @@ dependencies { //SimpleStorage (SAF Simplifier) implementation("com.anggrayudi:storage:1.5.0") + //Yt-dlp + + //Spotify SDK Integration library + implementation("com.adamratzman:spotify-api-kotlin-core:$spotifyLibrary") + //Unit testing testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a15cd5fa..1bcb69a9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ android:name=".Spowlo" android:allowBackup="true" android:extractNativeLibs="true" + android:requestLegacyExternalStorage="true" android:icon="@drawable/spowlo_icon" android:label="@string/app_name" android:roundIcon="@drawable/spowlo_icon" @@ -64,6 +65,27 @@ android:name="autoStoreLocales" android:value="true" /> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/Spowlo.kt b/app/src/main/java/com/bobbyesp/spowlo/Spowlo.kt index 41893106..b6c58b2c 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/Spowlo.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/Spowlo.kt @@ -2,27 +2,66 @@ package com.bobbyesp.spowlo import android.annotation.SuppressLint import android.app.Application +import android.content.ClipboardManager import android.content.Context -import com.anggrayudi.storage.SimpleStorageHelper +import android.net.ConnectivityManager +import com.bobbyesp.spowlo.data.auth.AuthModel +import com.bobbyesp.spowlo.database.CommandTemplate +import com.bobbyesp.spowlo.util.DatabaseUtil +import com.bobbyesp.spowlo.util.PreferencesUtil +import com.bobbyesp.spowlo.util.PreferencesUtil.AUDIO_DIRECTORY +import com.bobbyesp.spowlo.util.PreferencesUtil.TEMPLATE_INDEX import com.google.android.material.color.DynamicColors import com.tencent.mmkv.MMKV import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch @HiltAndroidApp class Spowlo : Application() { + lateinit var model: AuthModel + override fun onCreate() { super.onCreate() MMKV.initialize(this) context = applicationContext + this.model = AuthModel applicationScope = CoroutineScope(SupervisorJob()) DynamicColors.applyToActivitiesIfAvailable(this) + + clipboard = getSystemService(ClipboardManager::class.java) + connectivityManager = getSystemService(ConnectivityManager::class.java) + + applicationScope.launch((Dispatchers.IO)) { + if (!PreferencesUtil.containsKey(TEMPLATE_INDEX)) { + PreferencesUtil.updateInt(TEMPLATE_INDEX, 0) + DatabaseUtil.insertTemplate( + CommandTemplate( + 0, + context.getString(R.string.custom_command_template), + PreferencesUtil.getString( + PreferencesUtil.TEMPLATE, context.getString(R.string.template_example) + ) + ) + ) + } + } } companion object{ private const val TAG = "Spowlo" lateinit var applicationScope: CoroutineScope + lateinit var clipboard: ClipboardManager + lateinit var audioDownloadDir: String + var ytdlpVersion = "" + lateinit var connectivityManager: ConnectivityManager + + fun updateDownloadDir(path: String) { + audioDownloadDir = path + PreferencesUtil.updateString(AUDIO_DIRECTORY, path) + } @SuppressLint("StaticFieldLeak") lateinit var context: Context diff --git a/app/src/main/java/com/bobbyesp/spowlo/data/auth/AuthModel.kt b/app/src/main/java/com/bobbyesp/spowlo/data/auth/AuthModel.kt new file mode 100644 index 00000000..526a259e --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/data/auth/AuthModel.kt @@ -0,0 +1,15 @@ +package com.bobbyesp.spowlo.data.auth + +import com.adamratzman.spotify.auth.SpotifyDefaultCredentialStore +import com.bobbyesp.spowlo.BuildConfig +import com.bobbyesp.spowlo.Spowlo + +object AuthModel { + val credentialStore by lazy { + SpotifyDefaultCredentialStore( + clientId = BuildConfig.SPOTIFY_CLIENT_ID, + redirectUri = BuildConfig.SPOTIFY_REDIRECT_URI_PKCE, + applicationContext = Spowlo.context + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/database/AppDatabase.kt b/app/src/main/java/com/bobbyesp/spowlo/database/AppDatabase.kt new file mode 100644 index 00000000..d572c402 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/database/AppDatabase.kt @@ -0,0 +1,12 @@ +package com.bobbyesp.spowlo.database + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [DownloadedSongInfo::class, CommandTemplate::class], version = 3, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + abstract fun songInfoDao(): SongInfoDao +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/database/CommandTemplate.kt b/app/src/main/java/com/bobbyesp/spowlo/database/CommandTemplate.kt new file mode 100644 index 00000000..175043e2 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/database/CommandTemplate.kt @@ -0,0 +1,12 @@ +package com.bobbyesp.spowlo.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +@kotlinx.serialization.Serializable +data class CommandTemplate( + @PrimaryKey(autoGenerate = true) val id: Int, + val name: String, + val template: String +) diff --git a/app/src/main/java/com/bobbyesp/spowlo/database/DownloadedSongInfo.kt b/app/src/main/java/com/bobbyesp/spowlo/database/DownloadedSongInfo.kt new file mode 100644 index 00000000..90482e7c --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/database/DownloadedSongInfo.kt @@ -0,0 +1,17 @@ +package com.bobbyesp.spowlo.database + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "DownloadedSongInfo") +data class DownloadedSongInfo( + @PrimaryKey(autoGenerate = true) val id: Int, + val songTitle: String, + val songArtist: String, + val songUrl: String, + val thumbnailUrl: String, + val songPath: String, + @ColumnInfo(defaultValue = "Unknown") + val extractor: String = "Unknown" +) \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/database/SongInfoDao.kt b/app/src/main/java/com/bobbyesp/spowlo/database/SongInfoDao.kt new file mode 100644 index 00000000..b0438437 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/database/SongInfoDao.kt @@ -0,0 +1,43 @@ +package com.bobbyesp.spowlo.database + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface SongInfoDao { + @Insert + suspend fun insertAll(vararg info: DownloadedSongInfo) + + @Query("select * from DownloadedSongInfo") + fun getAllMedia(): Flow> + + @Query("select * from DownloadedSongInfo where id=:id") + suspend fun getInfoById(id: Int): DownloadedSongInfo + + @Delete + suspend fun delete(info: DownloadedSongInfo) + + @Query("DELETE FROM DownloadedSongInfo WHERE id = :id") + suspend fun deleteInfoById(id: Int) + + @Query("DELETE FROM DownloadedSongInfo WHERE songPath = :path") + suspend fun deleteInfoByPath(path: String) + + @Query("SELECT * FROM CommandTemplate") + fun getTemplateFlow(): Flow> + + @Query("SELECT * FROM CommandTemplate") + suspend fun getTemplateList(): List + + @Insert + suspend fun insertTemplate(template: CommandTemplate) + + @Update + suspend fun updateTemplate(template: CommandTemplate) + + @Delete + suspend fun deleteTemplate(template: CommandTemplate) + + @Query("SELECT * FROM CommandTemplate where id = :id") + suspend fun getTemplateById(id: Int): CommandTemplate +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/domain/spotify/web_api/auth/SpotifyPkceLoginActivityImpl.kt b/app/src/main/java/com/bobbyesp/spowlo/domain/spotify/web_api/auth/SpotifyPkceLoginActivityImpl.kt new file mode 100644 index 00000000..bdaf695c --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/domain/spotify/web_api/auth/SpotifyPkceLoginActivityImpl.kt @@ -0,0 +1,31 @@ +package com.bobbyesp.spowlo.domain.spotify.web_api.auth + +import android.app.Activity +import com.adamratzman.spotify.SpotifyClientApi +import com.adamratzman.spotify.SpotifyScope +import com.adamratzman.spotify.auth.pkce.AbstractSpotifyPkceLoginActivity +import com.bobbyesp.spowlo.BuildConfig +import com.bobbyesp.spowlo.Spowlo +import com.bobbyesp.spowlo.presentation.MainActivity +import com.bobbyesp.spowlo.util.Utils.makeToast + +internal var pkceClassBackTo: Class? = null + +class SpotifyPkceLoginActivityImpl: AbstractSpotifyPkceLoginActivity() { + override val clientId = BuildConfig.SPOTIFY_CLIENT_ID + override val redirectUri = BuildConfig.SPOTIFY_REDIRECT_URI_PKCE + override val scopes = SpotifyScope.values().toList() + + override fun onFailure(exception: Exception) { + exception.printStackTrace() + pkceClassBackTo = null + makeToast("Auth failed: ${exception.message}") + } + + override fun onSuccess(api: SpotifyClientApi) { + val model = (application as Spowlo).model + model.credentialStore.setSpotifyApi(api) + val classBackTo = pkceClassBackTo ?: MainActivity::class.java + pkceClassBackTo = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/domain/spotify/web_api/utilities/VerifyLoggedInUtils.kt b/app/src/main/java/com/bobbyesp/spowlo/domain/spotify/web_api/utilities/VerifyLoggedInUtils.kt new file mode 100644 index 00000000..f4b37c3c --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/domain/spotify/web_api/utilities/VerifyLoggedInUtils.kt @@ -0,0 +1,51 @@ +package com.bobbyesp.spowlo.domain.spotify.web_api.utilities + +import android.app.Activity +import com.adamratzman.spotify.SpotifyClientApi +import com.adamratzman.spotify.SpotifyException +import com.adamratzman.spotify.auth.pkce.startSpotifyClientPkceLoginActivity +import com.bobbyesp.spowlo.data.auth.AuthModel +import com.bobbyesp.spowlo.domain.spotify.web_api.auth.SpotifyPkceLoginActivityImpl +import com.bobbyesp.spowlo.domain.spotify.web_api.auth.pkceClassBackTo +import kotlinx.coroutines.runBlocking + +fun Activity.guardValidSpotifyApi( + classBackTo: Class? = null, + alreadyTriedToReauthenticate: Boolean = false, + block: suspend (api: SpotifyClientApi) -> T +): T? { + return runBlocking { + try { + val api = AuthModel.credentialStore.getSpotifyClientPkceApi() ?: throw SpotifyException.ReAuthenticationNeededException() + block(api) + } catch (e: SpotifyException) { + e.printStackTrace() + val api = AuthModel.credentialStore.getSpotifyClientPkceApi()!! + if (!alreadyTriedToReauthenticate) { + try { + api.refreshToken() + AuthModel.credentialStore.spotifyToken = api.token + block(api) + } catch (e: SpotifyException.ReAuthenticationNeededException) { + e.printStackTrace() + return@runBlocking guardValidSpotifyApi( + classBackTo = classBackTo, + alreadyTriedToReauthenticate = true, + block = block + ) + } catch (e: IllegalArgumentException) { + e.printStackTrace() + return@runBlocking guardValidSpotifyApi( + classBackTo = classBackTo, + alreadyTriedToReauthenticate = true, + block = block + ) + } + } else { + pkceClassBackTo = classBackTo + startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java) + null + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/MainActivity.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/MainActivity.kt index e7c41c7d..b6bca9c2 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/presentation/MainActivity.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/MainActivity.kt @@ -1,5 +1,6 @@ package com.bobbyesp.spowlo.presentation +import android.annotation.SuppressLint import android.os.Build import android.os.Bundle import android.util.Log @@ -7,21 +8,36 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.core.os.LocaleListCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat +import com.bobbyesp.spowlo.R import com.bobbyesp.spowlo.Spowlo.Companion.applicationScope import com.bobbyesp.spowlo.Spowlo.Companion.context -import com.bobbyesp.spowlo.presentation.ui.common.LocalDarkTheme -import com.bobbyesp.spowlo.presentation.ui.common.LocalDynamicColorSwitch -import com.bobbyesp.spowlo.presentation.ui.common.LocalSeedColor -import com.bobbyesp.spowlo.presentation.ui.common.SettingsProvider +import com.bobbyesp.spowlo.presentation.ui.common.* +import com.bobbyesp.spowlo.presentation.ui.components.bottomNavBar.BottomNavBar +import com.bobbyesp.spowlo.presentation.ui.components.bottomNavBar.NavBarItem import com.bobbyesp.spowlo.presentation.ui.pages.InitialEntry +import com.bobbyesp.spowlo.presentation.ui.pages.downloader_page.SearcherViewModel import com.bobbyesp.spowlo.presentation.ui.pages.home.HomeViewModel import com.bobbyesp.spowlo.presentation.ui.theme.SpowloTheme import com.bobbyesp.spowlo.util.PreferencesUtil +import com.google.accompanist.navigation.animation.rememberAnimatedNavController import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -29,10 +45,13 @@ import kotlinx.coroutines.runBlocking @AndroidEntryPoint class MainActivity : ComponentActivity() { - private val homeViewModel: HomeViewModel by viewModels() + private val searcherViewModel: SearcherViewModel by viewModels() - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalMaterial3Api::class, + ExperimentalAnimationApi::class + ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -40,28 +59,75 @@ class MainActivity : ComponentActivity() { v.setPadding(0, 0, 0, 0) insets } - runBlocking { + context = this.baseContext + runBlocking { if (Build.VERSION.SDK_INT < 33) AppCompatDelegate.setApplicationLocales( LocaleListCompat.forLanguageTags(PreferencesUtil.getLanguageConfiguration()) ) } - context = this.baseContext - setContent { - val windowSizeClass = calculateWindowSizeClass(this) - SettingsProvider(windowSizeClass.widthSizeClass){ - SpowloTheme( - darkTheme = LocalDarkTheme.current.isDarkTheme(), - isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, - seedColor = LocalSeedColor.current, - isDynamicColorEnabled = LocalDynamicColorSwitch.current, - ) { - InitialEntry(homeViewModel) + setContent { + val navController = rememberAnimatedNavController() + val windowSizeClass = calculateWindowSizeClass(this) + //if the current route is not in the list of routes, then hide the nav bar modifying the visible var + val visible = remember { mutableStateOf(true) } + + /*if current route is not home or settings, change the visible var to false + * INFO: Hide the navbar when the user is in a page that is not the ones that are in the navbar + */ + navController.addOnDestinationChangedListener { _, destination, _ -> + visible.value = + destination.route in listOf(Route.HOME, /*Route.SETTINGS,*/ Route.SEARCHER_PAGE) + } + SettingsProvider(windowSizeClass.widthSizeClass) { + SpowloTheme( + darkTheme = LocalDarkTheme.current.isDarkTheme(), + isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, + seedColor = LocalSeedColor.current, + isDynamicColorEnabled = LocalDynamicColorSwitch.current, + ) { + Scaffold( + bottomBar = { + BottomNavBar( + items = listOf( + NavBarItem( + name = stringResource(id = R.string.home), + icon = Icons.Filled.Home, + route = Route.HOME + ), + /*NavBarItem( + name = stringResource(id = R.string.settings), + icon = Icons.Filled.Settings, + route = Route.SETTINGS, + ),*/ + NavBarItem( + name = stringResource(id = R.string.searcher), + icon = Icons.Filled.Search, + route = Route.SEARCHER_PAGE, + ), + ), navController = navController, + onItemClicked = { + //if the current route is the same as the one we are trying to navigate to, do nothing + if (navController.currentDestination?.route != it.route) { + navController.navigate(it.route) + } + }, + visible = visible.value + ) + }) { + //If the user is at a route different from home or settings, hide the bottom nav bar + InitialEntry( + homeViewModel, + modifier = Modifier.padding(paddingValues = it), + navController = navController, + searcherViewModel = searcherViewModel, + activity = this@MainActivity + ) + } + } } } - } - } companion object { private const val TAG = "MainActivity" diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/common/Route.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/common/Route.kt index c2a95c87..d99fe165 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/common/Route.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/common/Route.kt @@ -11,5 +11,9 @@ class Route { const val DISPLAY_SETTINGS = "display_settings" const val ABOUT_SETTINGS = "about_settings" const val DARK_THEME_SELECTOR = "dark_theme_selector" + const val DOWNLOADER = "downloader" + const val SEARCHER_PAGE = "searcher_page" + const val TEMPLATE = "template" + const val WELCOME_PAGE = "welcome_page" } } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/common/SettingsProvider.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/common/SettingsProvider.kt index 08ccee42..b815efce 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/common/SettingsProvider.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/common/SettingsProvider.kt @@ -2,11 +2,17 @@ package com.bobbyesp.spowlo.presentation.ui.common import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import coil.ImageLoader +import com.bobbyesp.spowlo.Spowlo.Companion.context import com.bobbyesp.spowlo.presentation.ui.theme.ColorScheme.DEFAULT_SEED_COLOR import com.bobbyesp.spowlo.util.PreferencesUtil val LocalDarkTheme = compositionLocalOf { PreferencesUtil.DarkThemePreference() } val LocalSeedColor = compositionLocalOf { DEFAULT_SEED_COLOR } +val LocalVideoThumbnailLoader = staticCompositionLocalOf { + ImageLoader.Builder(context).build() +} val LocalWindowWidthState = staticCompositionLocalOf { WindowWidthSizeClass.Compact } val settingFlow = PreferencesUtil.AppSettingsStateFlow val LocalDynamicColorSwitch = compositionLocalOf { false } @@ -16,6 +22,8 @@ fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Compo val appSettingsState = settingFlow.collectAsState().value CompositionLocalProvider( LocalDarkTheme provides appSettingsState.darkTheme, + LocalVideoThumbnailLoader provides ImageLoader.Builder(LocalContext.current) + .build(), LocalSeedColor provides appSettingsState.seedColor, LocalWindowWidthState provides windowWidthSizeClass, LocalDynamicColorSwitch provides appSettingsState.isDynamicColorEnabled, diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/RelevantInfoItem.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/RelevantInfoItem.kt index 5e3eb67f..e10af26b 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/RelevantInfoItem.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/RelevantInfoItem.kt @@ -48,6 +48,7 @@ fun RelevantInfoItem( ) } } + Divider() Row( @@ -62,8 +63,10 @@ fun RelevantInfoItem( ) Box(modifier = Modifier .fillMaxWidth() - .wrapContentSize(Alignment.CenterEnd)){ - Row(modifier = Modifier) { + .wrapContentSize(Alignment.CenterEnd), + contentAlignment = Alignment.Center) + { + Row(modifier = Modifier.padding(start = 4.dp, end = 4.dp)) { Text( text = originalSpotifyVersion, style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/bottomNavBar/BottomAppBar.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/bottomNavBar/BottomAppBar.kt new file mode 100644 index 00000000..3d560664 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/bottomNavBar/BottomAppBar.kt @@ -0,0 +1,56 @@ +package com.bobbyesp.spowlo.presentation.ui.components.bottomNavBar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.* +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.currentBackStackEntryAsState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomNavBar( + modifier: Modifier = Modifier, + items: List, + navController: NavController, + onItemClicked: (NavBarItem) -> Unit, + visible: Boolean = true +) { + val backStackEntry = navController.currentBackStackEntryAsState() + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + NavigationBar( + modifier = modifier, + tonalElevation = 3.dp + ){ + items.forEach { item -> + val selected = item.route == backStackEntry.value?.destination?.route + NavigationBarItem( + icon = { + Column(horizontalAlignment = CenterHorizontally) { + if(item.badgeCount > 0){ + BadgedBox(badge = { + Text(text = item.badgeCount.toString()) + }) { + Icon(imageVector = item.icon, contentDescription = item.name) + } + } + else { + Icon(imageVector = item.icon, contentDescription = item.name) + } + } + }, + label = { + Text(text = item.name) }, + selected = selected, + onClick = { onItemClicked(item) } + ) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/bottomNavBar/NavBarItem.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/bottomNavBar/NavBarItem.kt new file mode 100644 index 00000000..95938c5c --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/bottomNavBar/NavBarItem.kt @@ -0,0 +1,10 @@ +package com.bobbyesp.spowlo.presentation.ui.components.bottomNavBar + +import androidx.compose.ui.graphics.vector.ImageVector + +data class NavBarItem( + val name: String, + val icon: ImageVector, + val route: String, + val badgeCount: Int = 0 +) diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/songs/TrackItem.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/songs/TrackItem.kt new file mode 100644 index 00000000..ae9d753c --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/components/songs/TrackItem.kt @@ -0,0 +1,63 @@ +package com.bobbyesp.spowlo.presentation.ui.components.songs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.adamratzman.spotify.models.Track + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TrackItem( + modifier: Modifier = Modifier, + track: Track, + onClick: (Track) -> Unit +) { + Surface( + modifier = modifier.clickable(onClick = { onClick(track) }) + ) { + Row( + modifier = Modifier.padding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + AsyncImage( + modifier = Modifier + .size(100.dp) + .padding(start = 10.dp, end = 15.dp, top = 5.dp, bottom = 5.dp) + .clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Fit, + model = ImageRequest.Builder(LocalContext.current) + .data(track.album.images.firstOrNull()?.url) + .crossfade(true) + .build(), + contentDescription = "Album cover" + ) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = track.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = track.artists.joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodySmall + ) + + } + } + + } +} diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/InitialEntry.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/InitialEntry.kt index 0535a422..d8cf3979 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/InitialEntry.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/InitialEntry.kt @@ -12,41 +12,48 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController import com.bobbyesp.spowlo.presentation.ui.common.LocalWindowWidthState import com.bobbyesp.spowlo.presentation.ui.common.Route import com.bobbyesp.spowlo.presentation.ui.common.animatedComposable import com.bobbyesp.spowlo.presentation.ui.components.UpdateDialog import com.bobbyesp.spowlo.presentation.ui.pages.home.HomePage import com.bobbyesp.spowlo.presentation.ui.pages.home.HomeViewModel -import com.bobbyesp.spowlo.presentation.ui.pages.placeholders.PagePlaceholder import com.bobbyesp.spowlo.presentation.ui.pages.settings.SettingsPage import com.bobbyesp.spowlo.presentation.ui.pages.settings.appearence.AppearancePreferences import com.bobbyesp.spowlo.presentation.ui.pages.settings.appearence.DarkThemePreferences import com.bobbyesp.spowlo.presentation.ui.pages.settings.appearence.LanguagesPreferences -import com.bobbyesp.spowlo.util.PreferencesUtil import com.bobbyesp.spowlo.util.UpdateUtil import com.bobbyesp.spowlo.util.Utils import com.google.accompanist.navigation.animation.AnimatedNavHost -import com.google.accompanist.navigation.animation.rememberAnimatedNavController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.presentation.ui.pages.downloader_page.SearcherPage +import com.bobbyesp.spowlo.presentation.ui.pages.downloader_page.SearcherViewModel +import com.bobbyesp.spowlo.presentation.ui.pages.welcome_page.WelcomePage +import com.bobbyesp.spowlo.util.PreferencesUtil +import com.bobbyesp.spowlo.util.PreferencesUtil.IS_LOGGED private const val TAG = "InitialEntry" -@OptIn(ExperimentalAnimationApi::class) +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) @Composable -fun InitialEntry(homeViewModel: HomeViewModel) { - val navController = rememberAnimatedNavController() +fun InitialEntry(homeViewModel: HomeViewModel, + modifier: Modifier = Modifier, + navController: NavHostController, + searcherViewModel: SearcherViewModel, + activity: androidx.activity.ComponentActivity? = null) +{ + val context = LocalContext.current val scope = rememberCoroutineScope() var updateJob: Job? = null @@ -54,6 +61,7 @@ fun InitialEntry(homeViewModel: HomeViewModel) { var showUpdateDialog by rememberSaveable { mutableStateOf(false) } var currentDownloadStatus by remember { mutableStateOf(UpdateUtil.DownloadStatus.NotYet as UpdateUtil.DownloadStatus) } var latestRelease by remember { mutableStateOf(UpdateUtil.LatestRelease()) } + val searcherPageLoaded by remember { mutableStateOf(searcherViewModel.stateFlow.value.loaded) } val settings = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { UpdateUtil.installLatestApk() @@ -77,57 +85,75 @@ fun InitialEntry(homeViewModel: HomeViewModel) { } } } - val viewState = homeViewModel.stateFlow.collectAsState() - Box( - modifier = Modifier - .fillMaxSize() - .background(androidx.compose.material3.MaterialTheme.colorScheme.background) - ){ - AnimatedNavHost( + + val homeviewState = homeViewModel.stateFlow.collectAsState() + val searcherViewState = searcherViewModel.stateFlow.collectAsState() + + + Box(modifier = modifier){ + Box( modifier = Modifier - .fillMaxWidth( - when (LocalWindowWidthState.current) { - WindowWidthSizeClass.Compact -> 1f - WindowWidthSizeClass.Expanded -> 0.5f - else -> 0.8f + .fillMaxSize() + .background(androidx.compose.material3.MaterialTheme.colorScheme.background) + ){ + AnimatedNavHost( + modifier = Modifier + .fillMaxWidth( + when (LocalWindowWidthState.current) { + WindowWidthSizeClass.Compact -> 1f + WindowWidthSizeClass.Expanded -> 0.5f + else -> 0.8f + } + ) + .align(Alignment.Center), + navController = navController, + startDestination = routeIfLogged()) { + + animatedComposable(Route.HOME){ + HomePage(navController = navController, homeViewModel = homeViewModel) + if (!homeviewState.value.loaded){ + homeViewModel.setup() } - ) - .align(Alignment.Center), - navController = navController, - startDestination = Route.HOME) { - - animatedComposable(Route.HOME){ - HomePage(navController = navController, homeViewModel = homeViewModel) - if (!viewState.value.loaded){ - homeViewModel.setup() } - } - animatedComposable(Route.SETTINGS){ - SettingsPage(navController) - } + animatedComposable(Route.SETTINGS){ + SettingsPage(navController) + } - animatedComposable(Route.ABOUT){ - } + animatedComposable(Route.ABOUT){ + } - animatedComposable(Route.DISPLAY_SETTINGS){ - AppearancePreferences(navController) - } + animatedComposable(Route.DISPLAY_SETTINGS){ + AppearancePreferences(navController) + } - animatedComposable(Route.LANGUAGES){ - LanguagesPreferences{ - onBackPressed() + animatedComposable(Route.LANGUAGES){ + LanguagesPreferences{ + onBackPressed() + } } - } - animatedComposable(Route.DARK_THEME_SELECTOR){ - DarkThemePreferences { - onBackPressed() + animatedComposable(Route.DARK_THEME_SELECTOR){ + DarkThemePreferences { + onBackPressed() + } } - } + animatedComposable(Route.WELCOME_PAGE){ + WelcomePage(navController = navController, activity = activity) + } + animatedComposable(Route.SEARCHER_PAGE){ + SearcherPage(navController = navController, + searcherViewModel = searcherViewModel, + activity = activity) + if(!searcherPageLoaded){ + searcherViewModel.setup() + } + } + } } } + LaunchedEffect(Unit) { launch(Dispatchers.IO) { kotlin.runCatching { @@ -171,4 +197,12 @@ fun InitialEntry(homeViewModel: HomeViewModel) { downloadStatus = currentDownloadStatus ) } +} + +fun routeIfLogged(): String{ + return if (PreferencesUtil.getValue(IS_LOGGED)){ + Route.HOME + } else { + Route.WELCOME_PAGE + } } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/downloader_page/SearcherPage.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/downloader_page/SearcherPage.kt new file mode 100644 index 00000000..caea087b --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/downloader_page/SearcherPage.kt @@ -0,0 +1,164 @@ +package com.bobbyesp.spowlo.presentation.ui.pages.downloader_page + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource + +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.Spowlo.Companion.context + +import com.bobbyesp.spowlo.data.auth.AuthModel +import com.bobbyesp.spowlo.presentation.ui.components.songs.TrackItem +import com.google.accompanist.permissions.ExperimentalPermissionsApi + +@OptIn( + ExperimentalPermissionsApi::class, ExperimentalMaterialApi::class, + ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class +) +@Composable +fun SearcherPage( + navController: NavController, + searcherViewModel: SearcherViewModel = hiltViewModel(), + activity: Activity? = null +) { + lateinit var model: AuthModel + val viewState = searcherViewModel.stateFlow.collectAsState() + + with(viewState.value) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + color = MaterialTheme.colorScheme.background, + ) { + if (!logged) { + Column(modifier = Modifier.fillMaxSize()) { + Button(onClick = { + searcherViewModel.spotifyPkceLogin(activity) + }) { + Text("Connect to Spotify (spotify-web-api-kotlin integration, PKCE auth)") + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalArrangement = Arrangement.Top + ) { + SearchSongTextBox( + songName = searcherViewModel.searchQuery.value, + onValueChange = searcherViewModel::onSearch, + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn { + items( + items = listOfTracks, itemContent = { track -> + TrackItem(track = track, onClick = { + val browserIntent = + Intent( + Intent.ACTION_VIEW, + Uri.parse(track.externalUrls.first { it.name == "spotify" }.url) + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ContextCompat.startActivity( + context, + browserIntent, + null + ) + }) + Divider() + }) + } + } + } + } + } + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchSongTextBox( + songName: String, + onValueChange: (String) -> Unit +) { + OutlinedTextField( + value = songName, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + placeholder = { + Text(stringResource(id = R.string.search_song)) + }, + leadingIcon = { + Icon(imageVector = Icons.Rounded.Search, contentDescription = null) + }, + trailingIcon = { + if (songName.isNotEmpty()) { + IconButton(onClick = { + onValueChange("") + }) { + Icon(imageVector = Icons.Rounded.Clear, contentDescription = null) + } + } + }, + singleLine = true, + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + leadingIconColor = MaterialTheme.colorScheme.primary, + trailingIconColor = MaterialTheme.colorScheme.primary, + textColor = MaterialTheme.colorScheme.primary, + disabledLabelColor = MaterialTheme.colorScheme.primary, + disabledBorderColor = MaterialTheme.colorScheme.primary, + disabledLeadingIconColor = MaterialTheme.colorScheme.primary, + disabledTrailingIconColor = MaterialTheme.colorScheme.primary, + errorBorderColor = MaterialTheme.colorScheme.primary, + errorLabelColor = MaterialTheme.colorScheme.primary, + errorCursorColor = MaterialTheme.colorScheme.primary, + errorLeadingIconColor = MaterialTheme.colorScheme.primary, + errorTrailingIconColor = MaterialTheme.colorScheme.primary, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/downloader_page/SearcherViewModel.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/downloader_page/SearcherViewModel.kt new file mode 100644 index 00000000..6aaef123 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/downloader_page/SearcherViewModel.kt @@ -0,0 +1,136 @@ +package com.bobbyesp.spowlo.presentation.ui.pages.downloader_page + +import android.app.Activity +import android.util.Log +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.adamratzman.spotify.auth.implicit.startSpotifyImplicitLoginActivity +import com.adamratzman.spotify.auth.pkce.startSpotifyClientPkceLoginActivity +import com.adamratzman.spotify.models.Track +import com.adamratzman.spotify.notifications.SpotifyBroadcastEventData +import com.bobbyesp.spowlo.data.auth.AuthModel +import com.bobbyesp.spowlo.domain.spotify.web_api.auth.SpotifyPkceLoginActivityImpl +import com.bobbyesp.spowlo.domain.spotify.web_api.utilities.guardValidSpotifyApi +import com.bobbyesp.spowlo.presentation.MainActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SearcherViewModel @Inject constructor() : ViewModel() { + private val mutableStateFlow = MutableStateFlow(DownloaderViewState()) + val stateFlow = mutableStateFlow.asStateFlow() + private var currentJob: Job? = null + + private val _searchQuery = mutableStateOf("") + val searchQuery: State = _searchQuery + + + data class DownloaderViewState( + val spotUrl: String = "", + val ytUrl: String = "", + val progress: Float = 0f, + val isDownloading: Boolean = false, + val isCancelled: Boolean = false, + val songTitle: String = "", + val songArtist: String = "", + val isDownloadError: Boolean = false, + val debugMode: Boolean = false, + val showDownloadSettingDialog: Boolean = false, + val downloadingTaskId: String = "", + val isUrlSharingTriggered: Boolean = false, + val drawerState: Boolean = false, + val logged: Boolean = false, + val loaded: Boolean = false, + val recentBroadcasts: List = mutableListOf(), + val listOfTracks: List = mutableListOf(), + val activity: Activity? = MainActivity(), + /*val drawerState: ModalBottomSheetState = ModalBottomSheetState( + ModalBottomSheetValue.Hidden, + isSkipHalfExpanded = true + ),*/ + ) + + fun setup() { + currentJob = CoroutineScope(Job()).launch { + mutableStateFlow.update { + if (AuthModel.credentialStore.spotifyToken != null) { + it.copy(logged = true) + } else { + it.copy(logged = false) + } + } + if (AuthModel.credentialStore.spotifyToken == null) { + spotifyPkceLogin() + Log.d("DownloaderViewModel", "Spotify token is null, relogging") + } + mutableStateFlow.update { + it.copy(loaded = true) + } + } + } + + fun onSearch(query: String, activity: Activity? = DownloaderViewState().activity) { + Log.d("DownloaderViewModel", "onSearch: $activity") + _searchQuery.value = query + currentJob?.cancel() + currentJob = viewModelScope.launch { + delay(500L) + try { + val tracks = + activity?.guardValidSpotifyApi(classBackTo = MainActivity::class.java) { api -> + //if query is not empty, search for it + if (query.isNotEmpty()) { + api.search.searchTrack(query).items + } else { + //if query is empty, make the tracks list empty + listOf() + } + } + mutableStateFlow.update { + it.copy(listOfTracks = tracks ?: listOf()) + } + Log.d("DownloaderViewModel", "Search query: $tracks") + } catch (e: Exception) { + Log.d("DownloaderViewModel", "Error: $e") + } + } + } + + fun spotifyPkceLogin(activity: Activity? = null) { + activity?.startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java) + } + + fun updateUrl(url: String, isUrlSharingTriggered: Boolean = false) = + mutableStateFlow.update { + it.copy( + ytUrl = url, + isUrlSharingTriggered = isUrlSharingTriggered + ) + } + + fun hideDialog(scope: CoroutineScope, isDialog: Boolean) { + scope.launch { + if (isDialog) + mutableStateFlow.update { it.copy(showDownloadSettingDialog = false) } + else + stateFlow.value.drawerState//.hide() + } + } + + fun showDialog(scope: CoroutineScope, isDialog: Boolean) { + scope.launch { + if (isDialog) + mutableStateFlow.update { it.copy(showDownloadSettingDialog = true) } + else + stateFlow.value.drawerState//.show() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/home/HomePage.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/home/HomePage.kt index a747fd52..dc7c1256 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/home/HomePage.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/home/HomePage.kt @@ -49,14 +49,6 @@ fun HomePage(navController: NavController, homeViewModel: HomeViewModel = hiltVi val amoledVersions = viewState.value.amoled_versions val amoledClonedVersions = viewState.value.amoled_cloned_versions - val archs by remember{ - mutableStateOf( - listOf( - ArchType.Arm64, - ArchType.Arm - ).random() - ) - } with(viewState.value){ Box( modifier = Modifier @@ -64,14 +56,17 @@ fun HomePage(navController: NavController, homeViewModel: HomeViewModel = hiltVi .background(MaterialTheme.colorScheme.background) ){ Scaffold(modifier = Modifier - .align(Alignment.Center) - .fillMaxSize(), + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), topBar = { TopAppBar( title = {}, - modifier = Modifier.padding(horizontal = 8.dp), + modifier = Modifier.padding(horizontal = 8.dp) + .fillMaxWidth(), navigationIcon = { - IconButton(onClick = {navController.navigate(Route.SETTINGS) }) { + IconButton(onClick = { + navController.navigate(Route.SETTINGS) + }) { Icon( imageVector = Icons.Outlined.Settings, contentDescription = stringResource(id = R.string.settings) @@ -90,7 +85,7 @@ fun HomePage(navController: NavController, homeViewModel: HomeViewModel = hiltVi Row(modifier = Modifier .fillMaxWidth() .align(Alignment.Start) - .padding(start = 8.dp, top = 12.dp, bottom = 12.dp)) + .padding(start = 8.dp, top = 12.dp, bottom = 12.dp, end = 8.dp)) { Text( modifier = Modifier, diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/home/HomeViewModel.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/home/HomeViewModel.kt index 5de82c53..38939dbd 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/home/HomeViewModel.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/home/HomeViewModel.kt @@ -85,8 +85,6 @@ class HomeViewModel @Inject constructor( ), isLoading = false ) - println(state.value.APIResponse) - val localAPIResponse = state.value.APIResponse mutableStateFlow.update { diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/welcome_page/WelcomePage.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/welcome_page/WelcomePage.kt new file mode 100644 index 00000000..c8740c9a --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/welcome_page/WelcomePage.kt @@ -0,0 +1,82 @@ +package com.bobbyesp.spowlo.presentation.ui.pages.welcome_page + +import android.app.Activity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.Surface +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.bobbyesp.spowlo.R +import com.bobbyesp.spowlo.presentation.ui.common.Route + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WelcomePage( + navController: NavController, + viewModel: WelcomePageViewModel = hiltViewModel(), + activity: Activity? = null){ + + val viewState = viewModel.stateFlow.collectAsState() + /*Welcome Page with one button to login to spotify or enter without login + *If logged, navigate to main page + If not logged, navigate to login page*/ + with(viewState.value) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding(25.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Text( + modifier = Modifier, + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge + ) + } + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(bottom = 20.dp, top = 20.dp, start = 10.dp, end = 10.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom + ) { + Button(onClick = { + viewModel.loginToSpotify(activity, navController) + }) { + Text(text = stringResource(R.string.login)) + } + Spacer(modifier = Modifier.height(10.dp)) + + Button(onClick = { navController.navigate(Route.HOME) }) { + Text(text = stringResource(R.string.enter_without_login)) + } + + } + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/welcome_page/WelcomePageViewModel.kt b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/welcome_page/WelcomePageViewModel.kt new file mode 100644 index 00000000..18f392fe --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/presentation/ui/pages/welcome_page/WelcomePageViewModel.kt @@ -0,0 +1,34 @@ +package com.bobbyesp.spowlo.presentation.ui.pages.welcome_page + +import android.app.Activity +import androidx.lifecycle.ViewModel +import androidx.navigation.NavController +import com.adamratzman.spotify.auth.pkce.startSpotifyClientPkceLoginActivity +import com.bobbyesp.spowlo.domain.spotify.web_api.auth.SpotifyPkceLoginActivityImpl +import com.bobbyesp.spowlo.presentation.ui.common.Route +import com.bobbyesp.spowlo.util.PreferencesUtil +import com.bobbyesp.spowlo.util.PreferencesUtil.IS_LOGGED +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +class WelcomePageViewModel@Inject constructor() : ViewModel() { + private val mutableStateFlow = MutableStateFlow(WelcomePageViewState()) + val stateFlow = mutableStateFlow.asStateFlow() + private var currentJob: Job? = null + + data class WelcomePageViewState( + val isLogged: Boolean = false, + val isLoaded: Boolean = false + ) + + fun loginToSpotify(activity: Activity? = null, navController: NavController){ + activity?.startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java) + PreferencesUtil.updateValue(IS_LOGGED, true) + navController.navigate(Route.HOME) + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/util/DatabaseUtil.kt b/app/src/main/java/com/bobbyesp/spowlo/util/DatabaseUtil.kt new file mode 100644 index 00000000..5fa503d1 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/spowlo/util/DatabaseUtil.kt @@ -0,0 +1,75 @@ +package com.bobbyesp.spowlo.util + +import androidx.room.Room +import com.bobbyesp.spowlo.Spowlo.Companion.applicationScope +import com.bobbyesp.spowlo.Spowlo.Companion.context +import com.bobbyesp.spowlo.database.AppDatabase +import com.bobbyesp.spowlo.database.CommandTemplate +import com.bobbyesp.spowlo.database.DownloadedSongInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object DatabaseUtil { + val format = Json { prettyPrint = true } + private const val DATABASE_NAME = "app_database" + private val db = Room.databaseBuilder( + context, + AppDatabase::class.java, DATABASE_NAME + ).build() + private val dao = db.songInfoDao() + fun insertInfo(vararg infoList: DownloadedSongInfo) { + applicationScope.launch(Dispatchers.IO) { + for (info in infoList) { + dao.deleteInfoByPath(info.songPath) + dao.insertAll(info) + } + } + } + + fun getMediaInfo() = dao.getAllMedia() + + fun getTemplateFlow() = dao.getTemplateFlow() + + suspend fun getTemplateList() = dao.getTemplateList() + + suspend fun getInfoById(id: Int): DownloadedSongInfo = dao.getInfoById(id) + suspend fun deleteInfoById(id: Int) = dao.deleteInfoById(id) + + suspend fun insertTemplate(commandTemplate: CommandTemplate) { + dao.insertTemplate(commandTemplate) + } + + suspend fun updateTemplate(commandTemplate: CommandTemplate) { + dao.updateTemplate(commandTemplate) + } + + suspend fun deleteTemplate(commandTemplate: CommandTemplate) { + dao.deleteTemplate(commandTemplate) + } + + suspend fun exportTemplatesToJson(): String { + return format.encodeToString(getTemplateList()) + } + + suspend fun importTemplatesFromJson(json: String): Int { + val list = getTemplateList() + var cnt = 0 + try { + format.decodeFromString>(json) + .forEach { + if (!list.contains(it)) { + cnt++ + dao.insertTemplate(it.copy(id = 0)) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return cnt + } + + private const val TAG = "DatabaseUtil" +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/spowlo/util/DownloadUtil.kt b/app/src/main/java/com/bobbyesp/spowlo/util/DownloadUtil.kt index 9dab8cd7..380831e0 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/util/DownloadUtil.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/util/DownloadUtil.kt @@ -10,6 +10,28 @@ import java.io.File object DownloadUtil { + enum class ResultCode { + SUCCESS, EXCEPTION + } + + data class PlaylistInfo( + val url: String = "", + val size: Int = 0, + val title: String = "" + ) + + class Result(val resultCode: ResultCode, val filePath: List?) { + companion object { + fun failure(): Result { + return Result(ResultCode.EXCEPTION, null) + } + + fun success(filePaths: List?): Result { + return Result(ResultCode.SUCCESS, filePaths) + } + } + } + private const val TAG = "DownloadUtil" private var apkUrl: String = "" diff --git a/app/src/main/java/com/bobbyesp/spowlo/util/PreferencesUtil.kt b/app/src/main/java/com/bobbyesp/spowlo/util/PreferencesUtil.kt index db3ea89f..94a14fa9 100644 --- a/app/src/main/java/com/bobbyesp/spowlo/util/PreferencesUtil.kt +++ b/app/src/main/java/com/bobbyesp/spowlo/util/PreferencesUtil.kt @@ -39,6 +39,10 @@ object PreferencesUtil { const val DYNAMIC_COLOR = "dynamic_color" const val LANGUAGE = "language" const val SPOTIFY_URL = "spotify_url" + const val AUDIO_DIRECTORY = "audio_directory" + const val TEMPLATE_INDEX = "template_index" + const val TEMPLATE = "template" + const val IS_LOGGED = "isLogged" const val SYSTEM_DEFAULT = 0 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7f1d8dc7..d808865f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -41,4 +41,11 @@ Este tipo de arquitectura está basada en 64 bits. Esta puede correr cualquier tipo de app de Spotify (las de 32 bits son emuladas) Este tipo de arquitectura está basada en 32 bits. SOLO puede correr aplicaciones ARMEABI (32 bits) Para conocer la arquitectura de tu procesador, vé a la página principal de la app y desliza hasta abajo del todo. + Plantilla de comando + Inicio + Descargador + Inicia sesión en Spotify + Entra sin iniciar sesión + Buscador + Busca una canción \ 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 42da0c2d..9eb49683 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,4 +44,15 @@ This type of architecture is based on 64 bit. It can run any Spotify package (32 bit ones are emulated) This type of architecture is based on 32 bit. It can JUST run ARMEABI packages. To check your processor architecture, scroll down till the end of the main page. + Command template + --no-mtime -f \"bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4] / bv*+ba/b\" + Home + Downloader + Log in into Spotify + Enter without login + Searcher + Search a song + \"spowlo://spotify-auth\" + \"spowlo://spotify-pkce\" + \"abcad8ba647d4b0ebae797a8f444ac9b\" \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index f68be3de..ed83a3ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,12 +6,16 @@ buildscript { val accompanistVersion by extra("0.25.1") val composeMd3Version by extra("1.0.0-beta03") val coilVersion by extra("2.2.1") + val youtubedlAndroidVersion by extra("0.14.0") val okhttpVersion by extra("5.0.0-alpha.10") val kotlinVersion by extra("1.7.10") val hiltVersion by extra("2.44") + val spotifyLibrary by extra("3.8.8") repositories { mavenCentral() + //add jitpack repository + maven("https://jitpack.io") } dependencies {