diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 01323b6b..74eddc08 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -111,6 +111,14 @@ android:resource="@xml/widget_info" /> + + + + + + diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt index a0c56247..d56b4ab1 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt @@ -2509,14 +2509,17 @@ class MusicService : player.currentMediaItem?.mediaId?.let { mediaId -> songUrlCache.remove(mediaId) - tryOrNull { playerCache.removeResource(mediaId) } } val isConnectionError = (error.cause?.cause is PlaybackException) && (error.cause?.cause as PlaybackException).errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED if (!isNetworkConnected.value || isConnectionError) { - waitOnNetworkError() + if (dataStore.get(AutoSkipNextOnErrorKey, false)) { + skipOnError() + } else { + waitOnNetworkError() + } return } @@ -2579,14 +2582,18 @@ class MusicService : val mediaId = dataSpec.key ?: error("No media id") val checkLength = if (dataSpec.length > 0) dataSpec.length else 1L - // If the song is downloaded in downloadCache, it is fully on disk and never needs network - if (downloadCache.isCached(mediaId, dataSpec.position, checkLength)) { + val isDownloaded = downloadCache.isCached(mediaId, dataSpec.position, checkLength) + val isPlayerCached = playerCache.isCached(mediaId, dataSpec.position, checkLength) + + // If the song is cached in either downloadCache or playerCache, serve immediately from cache + if (isDownloaded || isPlayerCached) { scope.launch(Dispatchers.IO) { recoverSong(mediaId) } return@Factory dataSpec } - // If offline, allow playing whatever chunks are available in playerCache - if (!isNetworkConnected.value && playerCache.isCached(mediaId, dataSpec.position, checkLength)) { + // If offline, and we have any cached data for this mediaId in either cache, allow playing it + // directly without falling through to failing remote networks + if (!isNetworkConnected.value && (downloadCache.keys.contains(mediaId) || playerCache.keys.contains(mediaId))) { scope.launch(Dispatchers.IO) { recoverSong(mediaId) } return@Factory dataSpec } @@ -2718,7 +2725,6 @@ class MusicService : }.getOrNull() } if (jsStreamUrl != null) { - tryOrNull { playerCache.removeResource(mediaId) } Timber.tag("MusicService").d("JioSaavn Priority: Serving 320k for '${mediaMetadata.title}'") database.query { upsert( @@ -2839,7 +2845,6 @@ class MusicService : ) } if (jsStreamUrl != null) { - tryOrNull { playerCache.removeResource(mediaId) } database.query { upsert( FormatEntity( diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/SearchPillSwitcher.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SearchPillSwitcher.kt index 11c14821..f2390634 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/component/SearchPillSwitcher.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SearchPillSwitcher.kt @@ -31,9 +31,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp enum class SearchTab(val title: String) { - BROWSE_ALL("Browse all"), + RECENT_SEARCHES("Recent Searches"), AIRBEATS_CHARTS("AirBeats Charts"), - RECENT_SEARCHES("Recent Searches") + BROWSE_ALL("Browse all") } @Composable diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt index 1dc7e124..4b1c27e9 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt @@ -34,6 +34,8 @@ import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadService import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.utils.completedPlaylistPage import com.darkxvenom.airbeats.LocalDatabase import com.darkxvenom.airbeats.LocalDownloadUtil import com.darkxvenom.airbeats.LocalPlayerConnection @@ -48,12 +50,16 @@ import com.darkxvenom.airbeats.playback.queues.ListQueue import com.darkxvenom.airbeats.playback.queues.YouTubeQueue import com.darkxvenom.airbeats.ui.component.DefaultDialog import com.darkxvenom.airbeats.ui.component.DownloadGridMenu +import com.darkxvenom.airbeats.constants.SongSortType +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.models.MediaMetadata import com.darkxvenom.airbeats.ui.component.GridMenu import com.darkxvenom.airbeats.ui.component.GridMenuItem import com.darkxvenom.airbeats.ui.component.PlaylistListItem import com.darkxvenom.airbeats.ui.component.TextFieldDialog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.time.LocalDateTime @@ -93,6 +99,61 @@ fun PlaylistMenu( mutableIntStateOf(Download.STATE_STOPPED) } + val currentPlaylist = playlist + val currentSongs by androidx.compose.runtime.rememberUpdatedState(songs) + val exportPlaylistLauncher = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain") + ) { uri -> + if (uri != null) { + com.darkxvenom.airbeats.utils.SaveToStorageUtil.applicationScope.launch(Dispatchers.IO) { + val songEntities: List = when { + currentSongs.isNotEmpty() -> currentSongs + currentPlaylist.id == PlaylistEntity.LIKED_PLAYLIST_ID -> { + database.likedSongs(SongSortType.CREATE_DATE, true).first() + } + else -> { + database.playlistSongs(currentPlaylist.id).first().map(PlaylistSong::song) + } + } + + val mediaToExport = if (songEntities.isNotEmpty()) { + songEntities.map { song -> + MediaMetadata( + id = song.id, + title = song.title, + artists = song.artists.map { MediaMetadata.Artist(id = it.id, name = it.name) }, + duration = song.duration, + thumbnailUrl = song.thumbnailUrl, + album = song.album?.let { MediaMetadata.Album(id = it.id, title = it.title) } + ) + } + } else { + val browseId = currentPlaylist.playlist.browseId + if (browseId != null) { + YouTube.playlist(browseId).completedPlaylistPage().getOrNull()?.songs.orEmpty().map { it.toMediaMetadata() } + } else { + emptyList() + } + } + + val result = com.darkxvenom.airbeats.utils.PlaylistFileHelper.exportPlaylistToUri( + context = context, + uri = uri, + playlistName = currentPlaylist.playlist.name, + mediaList = mediaToExport + ) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + android.widget.Toast.makeText(context, R.string.playlist_exported, android.widget.Toast.LENGTH_SHORT).show() + } else { + android.widget.Toast.makeText(context, result.exceptionOrNull()?.message ?: "Export failed", android.widget.Toast.LENGTH_SHORT).show() + } + } + } + } + } + val editable: Boolean = playlist.playlist.isEditable == true LaunchedEffect(songs) { @@ -390,31 +451,40 @@ fun PlaylistMenu( ) } - if (songs.isNotEmpty()) { + if (songs.isNotEmpty() || playlist.songCount > 0 || playlist.playlist.browseId != null) { GridMenuItem( - icon = R.drawable.save_to_storage, - title = R.string.save_playlist_to_storage, + icon = R.drawable.export, + title = R.string.export_playlist, ) { - val hasPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { - true - } else { - androidx.core.content.ContextCompat.checkSelfPermission( - context, - android.Manifest.permission.WRITE_EXTERNAL_STORAGE - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - } + val safeName = playlist.playlist.name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().ifEmpty { "playlist" } + exportPlaylistLauncher.launch("$safeName.txt") + onDismiss() + } + } - if (hasPermission) { - val savingToastMsg = context.getString(R.string.saving_playlist_to_storage, playlist.playlist.name) - val playlistName = playlist.playlist.name - android.widget.Toast.makeText(context, savingToastMsg, android.widget.Toast.LENGTH_SHORT).show() - com.darkxvenom.airbeats.utils.SaveToStorageUtil.savePlaylistToMusicFolderAsync( - context = context, - playlistName = playlistName, - mediaList = songs.map { it.toMediaMetadata() } - ) - onDismiss() - } + GridMenuItem( + icon = R.drawable.save_to_storage, + title = R.string.save_playlist_to_storage, + ) { + val hasPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + true + } else { + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } + + if (hasPermission) { + val savingToastMsg = context.getString(R.string.saving_playlist_to_storage, playlist.playlist.name) + val playlistName = playlist.playlist.name + android.widget.Toast.makeText(context, savingToastMsg, android.widget.Toast.LENGTH_SHORT).show() + com.darkxvenom.airbeats.utils.SaveToStorageUtil.savePlaylistToMusicFolderAsync( + context = context, + playlistName = playlistName, + mediaList = songs.map { it.toMediaMetadata() } + ) + onDismiss() } } diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubePlaylistMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubePlaylistMenu.kt index 433311a1..ff8b1f8b 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubePlaylistMenu.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubePlaylistMenu.kt @@ -108,6 +108,36 @@ fun YouTubePlaylistMenu( mutableStateOf(mutableListOf()) } + val exportPlaylistLauncher = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain") + ) { uri -> + if (uri != null) { + val playlistTitle = playlist.title + val safeSongs = songs + val playlistId = playlist.id + com.darkxvenom.airbeats.utils.SaveToStorageUtil.applicationScope.launch(Dispatchers.IO) { + val songsToExport = safeSongs.ifEmpty { + YouTube.playlist(playlistId).completedPlaylistPage().getOrNull()?.songs.orEmpty() + }.map { it.toMediaMetadata() } + + val result = com.darkxvenom.airbeats.utils.PlaylistFileHelper.exportPlaylistToUri( + context = context, + uri = uri, + playlistName = playlistTitle, + mediaList = songsToExport + ) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + android.widget.Toast.makeText(context, R.string.playlist_exported, android.widget.Toast.LENGTH_SHORT).show() + } else { + android.widget.Toast.makeText(context, result.exceptionOrNull()?.message ?: "Export failed", android.widget.Toast.LENGTH_SHORT).show() + } + } + } + } + } + AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, onGetSong = { targetPlaylist -> @@ -448,6 +478,60 @@ fun YouTubePlaylistMenu( showChoosePlaylistDialog = true } + val inLibrary = dbPlaylist?.playlist?.bookmarkedAt != null + GridMenuItem( + icon = if (inLibrary) R.drawable.favorite else R.drawable.favorite_border, + title = if (inLibrary) R.string.remove_from_library else R.string.add_to_library, + ) { + if (dbPlaylist == null) { + val playlistEntity = PlaylistEntity( + id = playlist.id, + name = playlist.title, + browseId = playlist.id, + playEndpointParams = playlist.playEndpoint?.params, + shuffleEndpointParams = playlist.shuffleEndpoint?.params, + radioEndpointParams = playlist.radioEndpoint?.params + ).toggleLike() + database.transaction { + insert(playlistEntity) + } + coroutineScope.launch(Dispatchers.IO) { + val songsToInsert = songs.ifEmpty { + YouTube.playlist(playlist.id).completedPlaylistPage() + .getOrNull()?.songs.orEmpty() + }.map { it.toMediaMetadata() } + + database.transaction { + songsToInsert.forEach(::insert) + songsToInsert.mapIndexed { index, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistEntity.id, + position = index + ) + }.forEach(::insert) + } + } + android.widget.Toast.makeText(context, R.string.add_to_library, android.widget.Toast.LENGTH_SHORT).show() + } else { + database.transaction { + update(dbPlaylist!!.playlist.toggleLike()) + } + val msg = if (inLibrary) R.string.remove_from_library else R.string.add_to_library + android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show() + } + onDismiss() + } + + GridMenuItem( + icon = R.drawable.export, + title = R.string.export_playlist, + ) { + val safeName = playlist.title.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().ifEmpty { "playlist" } + exportPlaylistLauncher.launch("$safeName.txt") + onDismiss() + } + if (playlist.isEditable) { GridMenuItem( icon = R.drawable.edit, diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NavigationBuilder.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NavigationBuilder.kt index 24d8dd98..8d10bed0 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NavigationBuilder.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NavigationBuilder.kt @@ -126,42 +126,10 @@ fun NavGraphBuilder.navigationBuilder( } } composable(Screens.Search.route) { - val (navBarStyle, _) = rememberEnumPreference( - com.darkxvenom.airbeats.constants.NavBarStyleKey, - defaultValue = com.darkxvenom.airbeats.constants.NavBarStyle.NEW_CLASSIC - ) - val (homeScreenStyle, _) = rememberEnumPreference( - HomeScreenStyleKey, - defaultValue = HomeScreenStyle.CLASSIC - ) - - val useApple = navBarStyle == com.darkxvenom.airbeats.constants.NavBarStyle.APPLE || (navBarStyle != com.darkxvenom.airbeats.constants.NavBarStyle.SPOTIFY && homeScreenStyle == HomeScreenStyle.APPLE) - - if (homeScreenStyle == HomeScreenStyle.MATERIAL || navBarStyle == com.darkxvenom.airbeats.constants.NavBarStyle.MATERIAL) { - com.darkxvenom.airbeats.ui.screens.material.MaterialSearchScreen(navController = navController) - } else if (useApple) { - com.darkxvenom.airbeats.ui.screens.apple.AppleSearchScreen(navController = navController) - } else { - SpotifySearchScreen(navController = navController) - } + com.darkxvenom.airbeats.ui.screens.material.MaterialSearchScreen(navController = navController) } composable("search/") { - val (navBarStyle, _) = rememberEnumPreference( - com.darkxvenom.airbeats.constants.NavBarStyleKey, - defaultValue = com.darkxvenom.airbeats.constants.NavBarStyle.NEW_CLASSIC - ) - val (homeScreenStyle, _) = rememberEnumPreference( - HomeScreenStyleKey, - defaultValue = HomeScreenStyle.CLASSIC - ) - - val useApple = navBarStyle == com.darkxvenom.airbeats.constants.NavBarStyle.APPLE || (navBarStyle != com.darkxvenom.airbeats.constants.NavBarStyle.SPOTIFY && homeScreenStyle == HomeScreenStyle.APPLE) - - if (useApple) { - com.darkxvenom.airbeats.ui.screens.apple.AppleSearchScreen(navController = navController) - } else { - SpotifySearchScreen(navController = navController) - } + com.darkxvenom.airbeats.ui.screens.material.MaterialSearchScreen(navController = navController) } composable("history") { HistoryScreen(navController) diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryPlaylistsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryPlaylistsScreen.kt index 03b59ce4..616ea209 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryPlaylistsScreen.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryPlaylistsScreen.kt @@ -15,6 +15,7 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -28,6 +29,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.pullToRefresh @@ -223,8 +225,124 @@ fun LibraryPlaylistsScreen( } } + val context = LocalContext.current + val database = com.darkxvenom.airbeats.LocalDatabase.current + var showActionChooser by rememberSaveable { mutableStateOf(false) } var showCreatePlaylistDialog by rememberSaveable { mutableStateOf(false) } + val importPlaylistLauncher = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + coroutineScope.launch(Dispatchers.IO) { + val result = com.darkxvenom.airbeats.utils.PlaylistFileHelper.importPlaylistFromUri( + context = context, + uri = uri, + database = database + ) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + val (name, count) = result.getOrThrow() + android.widget.Toast.makeText( + context, + context.getString(R.string.playlist_imported_success, name, count), + android.widget.Toast.LENGTH_LONG + ).show() + } else { + android.widget.Toast.makeText( + context, + "${context.getString(R.string.import_playlist_failed)}: ${result.exceptionOrNull()?.message}", + android.widget.Toast.LENGTH_LONG + ).show() + } + } + } + } + } + + if (showActionChooser) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showActionChooser = false }, + icon = { + Icon( + painter = painterResource(R.drawable.add), + contentDescription = null + ) + }, + title = { + Text(text = stringResource(R.string.playlists)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Surface( + onClick = { + showActionChooser = false + showCreatePlaylistDialog = true + }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Icon( + painter = painterResource(R.drawable.add), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.create_playlist_option), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + } + } + + Surface( + onClick = { + showActionChooser = false + importPlaylistLauncher.launch(arrayOf("text/*", "application/json", "*/*")) + }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Icon( + painter = painterResource(R.drawable.save_to_storage), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.import_playlist_option), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + } + } + } + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { showActionChooser = false }) { + Text(text = stringResource(android.R.string.cancel)) + } + } + ) + } + if (showCreatePlaylistDialog) { CreatePlaylistDialog( onDismiss = { showCreatePlaylistDialog = false }, @@ -357,7 +475,7 @@ fun LibraryPlaylistsScreen( navController.navigate("generator") }, onCreatePlaylist = { - showCreatePlaylistDialog = true + showActionChooser = true }, ) } diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/material/MaterialSearchScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/material/MaterialSearchScreen.kt index e7793085..b1843d39 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/material/MaterialSearchScreen.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/material/MaterialSearchScreen.kt @@ -74,10 +74,34 @@ import com.darkxvenom.airbeats.constants.PauseSearchHistoryKey import com.darkxvenom.airbeats.db.entities.SearchHistory import com.darkxvenom.airbeats.viewmodels.OnlineSearchSuggestionViewModel import java.net.URLEncoder +import coil.compose.AsyncImage +import androidx.compose.ui.layout.ContentScale -private val SEARCH_GENRES = listOf( - "Pop", "Rock", "Hip-Hop", "Lo-Fi", "Electronic", "Indie", - "R&B", "Bollywood", "Jazz", "Metal", "Acoustic", "Chill" +data class GenreCategory( + val title: String, + val color: Color, + val imageUrl: String +) + +private val GENRE_CATEGORIES = listOf( + GenreCategory("Chill", Color(0xFF5A758D), "https://images.unsplash.com/photo-1501386761578-eac5c94b800a?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Community", Color(0xFF8F7523), "https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Energize", Color(0xFFA89547), "https://images.unsplash.com/photo-1514525253161-7a46d19cd819?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Feel good", Color(0xFF5B8E60), "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Focus", Color(0xFF6B7A7A), "https://images.unsplash.com/photo-1518609878373-06d740f60d8b?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Gaming", Color(0xFF383E44), "https://images.unsplash.com/photo-1542751371-adc38448a05e?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Party", Color(0xFF74528E), "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Romance", Color(0xFF9E422D), "https://images.unsplash.com/photo-1518199266791-5375a83190b7?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Sad", Color(0xFF535D5C), "https://images.unsplash.com/photo-1516589178581-6cd7833ae3b2?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Sleep", Color(0xFF4C3A7A), "https://images.unsplash.com/photo-1511295742362-92c96b124e52?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Workout", Color(0xFFA35728), "https://images.unsplash.com/photo-1517838277536-f5f99be501cd?auto=format&fit=crop&w=200&q=80"), + GenreCategory("African", Color(0xFF10722C), "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Arabic", Color(0xFF984617), "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Bengali", Color(0xFF837E43), "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Pop", Color(0xFFAD3869), "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Rock", Color(0xFF8F2929), "https://images.unsplash.com/photo-1498038432885-c6f3f1b912ee?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Hip-Hop", Color(0xFFBA5A20), "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?auto=format&fit=crop&w=200&q=80"), + GenreCategory("Bollywood", Color(0xFF8B2F57), "https://images.unsplash.com/photo-1533174072545-7a4b6ad7a6c3?auto=format&fit=crop&w=200&q=80") ) /** @@ -112,7 +136,7 @@ fun MaterialSearchScreen( defaultValue = false ) val plainBg = if (pureBlack) Color.Black else MaterialTheme.colorScheme.background - var selectedTab by remember { mutableStateOf(com.darkxvenom.airbeats.ui.component.SearchTab.BROWSE_ALL) } + var selectedTab by remember { mutableStateOf(com.darkxvenom.airbeats.ui.component.SearchTab.RECENT_SEARCHES) } val chartsViewModel: com.darkxvenom.airbeats.viewmodels.ChartsViewModel = hiltViewModel() var showRegionSheet by remember { mutableStateOf(false) } var regionCode by com.darkxvenom.airbeats.utils.rememberPreference( @@ -156,30 +180,22 @@ fun MaterialSearchScreen( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground ) - Spacer(Modifier.height(10.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + } + items(GENRE_CATEGORIES.chunked(2)) { pair -> + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth() ) { - SEARCH_GENRES.forEach { genre -> - FilterChip( - selected = false, - onClick = { onPerformSearch(genre) }, - label = { - Text( - text = genre, - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp - ) - }, - shape = CircleShape, - colors = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - labelColor = MaterialTheme.colorScheme.onSurface, - ), + pair.forEach { category -> + GenreCard( + category = category, + onClick = { onPerformSearch(category.title) }, + modifier = Modifier.weight(1f) ) } + if (pair.size == 1) { + Spacer(Modifier.weight(1f)) + } } } } @@ -346,3 +362,55 @@ fun MaterialSearchScreen( } } } + +@Composable +private fun GenreCard( + category: GenreCategory, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = category.color), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + modifier = modifier + .fillMaxWidth() + .height(76.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 14.dp, top = 6.dp, bottom = 6.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = category.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) + + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = category.imageUrl, + contentDescription = category.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/LocalPlaylistScreen.kt index 29334764..c0e74da6 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/LocalPlaylistScreen.kt @@ -227,6 +227,29 @@ fun LocalPlaylistScreen( var downloadState by remember { mutableStateOf(Download.STATE_STOPPED) } var showQualityDialog by remember { mutableStateOf(false) } + val exportPlaylistLauncher = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain") + ) { uri -> + if (uri != null && playlist != null) { + coroutineScope.launch(Dispatchers.IO) { + val result = com.darkxvenom.airbeats.utils.PlaylistFileHelper.exportPlaylistToUri( + context = context, + uri = uri, + playlistName = playlist!!.playlist.name, + songs = songs.map { it.song } + ) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + android.widget.Toast.makeText(context, R.string.playlist_exported, android.widget.Toast.LENGTH_SHORT).show() + } else { + android.widget.Toast.makeText(context, result.exceptionOrNull()?.message ?: "Export failed", android.widget.Toast.LENGTH_SHORT).show() + } + } + } + } + } + val editable: Boolean = playlist?.playlist?.isEditable == true LaunchedEffect(songs) { @@ -1407,6 +1430,19 @@ fun LocalPlaylistScreen( ) } } else if (!isSearching) { + IconButton( + onClick = { + val safeName = playlist?.playlist?.name?.replace(Regex("[\\\\/:*?\"<>|]"), "_")?.trim()?.ifEmpty { "playlist" } ?: "playlist" + exportPlaylistLauncher.launch("$safeName.txt") + }, + onLongClick = {} + ) { + Icon( + painter = painterResource(R.drawable.export), + contentDescription = stringResource(R.string.export_playlist) + ) + } + IconButton( onClick = { isSearching = true }, onLongClick = {} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/OnlinePlaylistScreen.kt index 5ab30059..ad2b7474 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -106,6 +106,9 @@ import com.darkxvenom.airbeats.db.entities.PlaylistEntity import com.darkxvenom.airbeats.db.entities.PlaylistSongMap import com.darkxvenom.airbeats.extensions.toMediaItem import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.utils.completedPlaylistPage import com.darkxvenom.airbeats.models.toMediaMetadata import com.darkxvenom.airbeats.playback.ExoDownloadService import com.darkxvenom.airbeats.playback.queues.ListQueue @@ -223,6 +226,36 @@ fun OnlinePlaylistScreen( var downloadState by remember { mutableStateOf(Download.STATE_STOPPED) } var showQualityDialog by remember { mutableStateOf(false) } + val exportPlaylistLauncher = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain") + ) { uri -> + if (uri != null && playlist != null) { + val playlistTitle = playlist!!.title + val safeSongs = songs + val playlistId = playlist!!.id + com.darkxvenom.airbeats.utils.SaveToStorageUtil.applicationScope.launch(Dispatchers.IO) { + val songsToExport = safeSongs.ifEmpty { + YouTube.playlist(playlistId).completedPlaylistPage().getOrNull()?.songs.orEmpty() + }.map { it.toMediaMetadata() } + + val result = com.darkxvenom.airbeats.utils.PlaylistFileHelper.exportPlaylistToUri( + context = context, + uri = uri, + playlistName = playlistTitle, + mediaList = songsToExport + ) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + android.widget.Toast.makeText(context, R.string.playlist_exported, android.widget.Toast.LENGTH_SHORT).show() + } else { + android.widget.Toast.makeText(context, result.exceptionOrNull()?.message ?: "Export failed", android.widget.Toast.LENGTH_SHORT).show() + } + } + } + } + } + LaunchedEffect(songs) { if (songs.isEmpty()) return@LaunchedEffect downloadUtil.downloads.collect { downloads -> @@ -1195,6 +1228,19 @@ fun OnlinePlaylistScreen( ) } } else if (!isSearching) { + IconButton( + onClick = { + val safeName = playlist?.title?.replace(Regex("[\\\\/:*?\"<>|]"), "_")?.trim()?.ifEmpty { "playlist" } ?: "playlist" + exportPlaylistLauncher.launch("$safeName.txt") + }, + onLongClick = {} + ) { + Icon( + painter = painterResource(R.drawable.export), + contentDescription = stringResource(R.string.export_playlist) + ) + } + IconButton( onClick = { isSearching = true }, onLongClick = {} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/PlaylistFileHelper.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/PlaylistFileHelper.kt new file mode 100644 index 00000000..b1f6bfde --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/PlaylistFileHelper.kt @@ -0,0 +1,264 @@ +package com.darkxvenom.airbeats.utils + +import android.content.Context +import android.net.Uri +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.db.entities.PlaylistSongMap +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.models.MediaMetadata +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.InputStream +import java.io.OutputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +object PlaylistFileHelper { + private const val TAG = "PlaylistFileHelper" + + /** + * Serializes a playlist and its songs (as MediaMetadata) to structured JSON text. + */ + fun exportPlaylistToJson(playlistName: String, songs: List): String { + val root = JSONObject().apply { + put("app", "AirBeats") + put("version", 1) + put("playlistName", playlistName) + put("exportedAt", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + put("songCount", songs.size) + + val songsArray = JSONArray() + for (song in songs) { + val songObj = JSONObject().apply { + put("id", song.id) + put("title", song.title) + put("duration", song.duration) + if (song.thumbnailUrl != null) { + put("thumbnailUrl", song.thumbnailUrl) + } + if (song.album != null) { + put("album", song.album.title) + } + val artistsArray = JSONArray() + for (artist in song.artists) { + artistsArray.put(artist.name) + } + put("artists", artistsArray) + } + songsArray.put(songObj) + } + put("songs", songsArray) + } + return root.toString(2) + } + + /** + * Overload for Room entity [Song] list. + */ + @JvmName("exportSongPlaylistToJson") + fun exportPlaylistToJson(playlistName: String, songs: List): String = + exportPlaylistToJson(playlistName, songs.map { song -> + MediaMetadata( + id = song.id, + title = song.title, + artists = song.artists.map { MediaMetadata.Artist(id = it.id, name = it.name) }, + duration = song.duration, + thumbnailUrl = song.thumbnailUrl, + album = song.album?.let { MediaMetadata.Album(id = it.id, title = it.title) } + ) + }) + + /** + * Writes playlist JSON text directly to an OutputStream for MediaMetadata list. + */ + fun exportPlaylistToUri( + context: Context, + uri: Uri, + playlistName: String, + mediaList: List + ): Result { + return runCatching { + val jsonText = exportPlaylistToJson(playlistName, mediaList) + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(jsonText.toByteArray(Charsets.UTF_8)) + outputStream.flush() + } ?: error("Failed to open output stream for uri: $uri") + } + } + + /** + * Writes playlist JSON text directly to an OutputStream for Song list. + */ + @JvmName("exportSongPlaylistToUri") + fun exportPlaylistToUri( + context: Context, + uri: Uri, + playlistName: String, + songs: List + ): Result { + return runCatching { + val jsonText = exportPlaylistToJson(playlistName, songs) + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(jsonText.toByteArray(Charsets.UTF_8)) + outputStream.flush() + } ?: error("Failed to open output stream for uri: $uri") + } + } + + /** + * Imports a playlist from a string (JSON or fallback line-based) and persists it into [database]. + * Returns a [Result] containing (Playlist Name, Songs Count). + */ + fun importPlaylistFromString(content: String, database: MusicDatabase): Result> { + return runCatching { + val trimmed = content.trim() + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + importFromJsonString(trimmed, database) + } else { + importFromTextLines(trimmed, database) + } + } + } + + /** + * Imports a playlist directly from a file Uri. + */ + fun importPlaylistFromUri(context: Context, uri: Uri, database: MusicDatabase): Result> { + return runCatching { + val content = context.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.bufferedReader(Charsets.UTF_8).readText() + } ?: error("Could not open input stream for uri: $uri") + + importPlaylistFromString(content, database).getOrThrow() + } + } + + private fun importFromJsonString(jsonStr: String, database: MusicDatabase): Pair { + var playlistName = "Imported Playlist" + val songsList = mutableListOf() + + if (jsonStr.startsWith("{")) { + val root = JSONObject(jsonStr) + playlistName = root.optString("playlistName").takeIf { it.isNotBlank() } ?: "Imported Playlist" + + val songsArray = root.optJSONArray("songs") + if (songsArray != null) { + parseSongsArray(songsArray, songsList) + } + } else if (jsonStr.startsWith("[")) { + val array = JSONArray(jsonStr) + parseSongsArray(array, songsList) + } + + if (songsList.isEmpty()) { + error("No songs found in the playlist file") + } + + return saveToDatabase(playlistName, songsList, database) + } + + private fun parseSongsArray(songsArray: JSONArray, destination: MutableList) { + for (i in 0 until songsArray.length()) { + val obj = songsArray.optJSONObject(i) ?: continue + val id = obj.optString("id").takeIf { it.isNotBlank() } ?: continue + val title = obj.optString("title").takeIf { it.isNotBlank() } ?: "Unknown Title" + val duration = obj.optInt("duration", 0) + val thumb = obj.optString("thumbnailUrl").takeIf { it.isNotBlank() } + + val artists = mutableListOf() + val artistsArray = obj.optJSONArray("artists") + if (artistsArray != null) { + for (j in 0 until artistsArray.length()) { + val name = artistsArray.optString(j) + if (name.isNotBlank()) { + artists.add(MediaMetadata.Artist(id = null, name = name)) + } + } + } + + destination.add( + MediaMetadata( + id = id, + title = title, + artists = artists, + duration = duration, + thumbnailUrl = thumb + ) + ) + } + } + + /** + * Fallback parser for plain text / M3U lists containing YouTube IDs or URLs. + */ + private fun importFromTextLines(text: String, database: MusicDatabase): Pair { + val lines = text.lines().map { it.trim() }.filter { it.isNotBlank() && !it.startsWith("#") } + val songIds = mutableListOf() + + for (line in lines) { + val id = extractYouTubeId(line) + if (id != null) { + songIds.add(id) + } + } + + if (songIds.isEmpty()) { + error("No valid song IDs found in the file") + } + + val songsList = songIds.distinct().map { id -> + MediaMetadata( + id = id, + title = "Track $id", + artists = emptyList(), + duration = 0, + thumbnailUrl = null + ) + } + + return saveToDatabase("Imported Playlist", songsList, database) + } + + private fun extractYouTubeId(text: String): String? { + val clean = text.trim() + if (clean.length == 11 && clean.matches(Regex("^[a-zA-Z0-9_-]{11}$"))) { + return clean + } + val match = Regex("(?:v=|youtu\\.be/|embed/|shorts/)([a-zA-Z0-9_-]{11})").find(clean) + return match?.groupValues?.getOrNull(1) + } + + private fun saveToDatabase( + playlistName: String, + songsList: List, + database: MusicDatabase + ): Pair { + val playlistId = PlaylistEntity.generatePlaylistId() + val newPlaylist = PlaylistEntity( + id = playlistId, + name = playlistName, + bookmarkedAt = LocalDateTime.now(), + isEditable = true, + lastUpdateTime = LocalDateTime.now() + ) + + // Insert synchronously so the playlist and songs exist immediately in Room DB + database.insert(newPlaylist) + + for ((index, media) in songsList.withIndex()) { + database.insert(media) + database.insert( + PlaylistSongMap( + songId = media.id, + playlistId = playlistId, + position = index + ) + ) + } + + Timber.tag(TAG).d("Successfully imported playlist '${newPlaylist.name}' with ${songsList.size} tracks into library") + return newPlaylist.name to songsList.size + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/SaveToStorageCancelReceiver.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/SaveToStorageCancelReceiver.kt new file mode 100644 index 00000000..4f38f4a0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/SaveToStorageCancelReceiver.kt @@ -0,0 +1,21 @@ +package com.darkxvenom.airbeats.utils + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.widget.Toast +import com.darkxvenom.airbeats.R +import timber.log.Timber + +class SaveToStorageCancelReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == SaveToStorageUtil.ACTION_CANCEL_SAVE) { + val notificationId = intent.getIntExtra(SaveToStorageUtil.EXTRA_NOTIFICATION_ID, -1) + Timber.tag("SaveToStorageCancelReceiver").d("Received cancel action for notificationId=$notificationId") + if (notificationId != -1) { + SaveToStorageUtil.cancelSave(context, notificationId) + Toast.makeText(context, R.string.download_canceled, Toast.LENGTH_SHORT).show() + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/SaveToStorageUtil.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/SaveToStorageUtil.kt index 21485aaa..5cf46bf3 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/utils/SaveToStorageUtil.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/SaveToStorageUtil.kt @@ -28,9 +28,11 @@ import com.darkxvenom.airbeats.utils.get import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -45,6 +47,19 @@ object SaveToStorageUtil { private const val CHANNEL_ID = "airbeats_storage_downloads" private const val CHANNEL_NAME = "Storage Downloads" + const val ACTION_CANCEL_SAVE = "com.darkxvenom.airbeats.action.CANCEL_SAVE_TO_STORAGE" + const val EXTRA_NOTIFICATION_ID = "notification_id" + + val activeSaveJobs = java.util.concurrent.ConcurrentHashMap() + val cancelledNotificationIds = java.util.concurrent.ConcurrentHashMap.newKeySet() + + fun cancelSave(context: Context, notificationId: Int) { + cancelledNotificationIds.add(notificationId) + activeSaveJobs.remove(notificationId)?.cancel() + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + notificationManager?.cancel(notificationId) + } + // Global application coroutine scope that survives Compose lifecycle / menu dismissals val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -177,6 +192,23 @@ object SaveToStorageUtil { builder.setSubText(subText) } + val cancelIntent = Intent(context, SaveToStorageCancelReceiver::class.java).apply { + action = ACTION_CANCEL_SAVE + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + val cancelPendingIntent = PendingIntent.getBroadcast( + context, + notificationId, + cancelIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + builder.addAction( + R.drawable.close, + context.getString(android.R.string.cancel), + cancelPendingIntent + ) + notificationManager.notify(notificationId, builder.build()) } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to update download progress notification") @@ -289,7 +321,7 @@ object SaveToStorageUtil { context: Context, playlistName: String, mediaList: List, - ): Result = withContext(Dispatchers.IO + NonCancellable) { + ): Result = withContext(Dispatchers.IO) { runCatching { val subFolder = playlistName.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().take(50) val relativeSubPath = if (subFolder.isNotEmpty()) "AirBeats/$subFolder" else "AirBeats" @@ -297,22 +329,32 @@ object SaveToStorageUtil { val total = mediaList.size mediaList.forEachIndexed { index, mediaMetadata -> + if (!currentCoroutineContext().isActive) { + throw CancellationException("Playlist download canceled") + } try { val subText = "${index + 1}/$total" val notificationId = 20000 + (mediaMetadata.id.hashCode() and 0x7FFF) - saveToFolder( + val result = saveToFolder( context = context.applicationContext, mediaMetadata = mediaMetadata, relativeFolder = relativeSubPath, notificationId = notificationId, subText = subText, - ).onSuccess { + ) + if (result.isSuccess) { savedCount++ + } else { + val error = result.exceptionOrNull() + if (error is CancellationException) { + throw error + } } } catch (e: Exception) { - if (e !is CancellationException) { - Timber.tag(TAG).e(e, "Error saving song ${mediaMetadata.title} in playlist") + if (e is CancellationException) { + throw e } + Timber.tag(TAG).e(e, "Error saving song ${mediaMetadata.title} in playlist") } } savedCount @@ -336,111 +378,133 @@ object SaveToStorageUtil { relativeFolder: String, notificationId: Int = 20000 + (mediaMetadata.id.hashCode() and 0x7FFF), subText: String? = null, - ): Result = withContext(Dispatchers.IO + NonCancellable) { - runCatching { - val appContext = context.applicationContext - Timber.tag(TAG).d("Starting save for: ${mediaMetadata.title} into $relativeFolder") - showProgressNotification(appContext, notificationId, mediaMetadata.title, 0, subText) - - var audioBytes: ByteArray? = null - var extension: String = "m4a" - - val desiredQuality = appContext.dataStore[DownloadQualityKey] - ?.let { runCatching { AudioQuality.valueOf(it) }.getOrNull() } ?: AudioQuality.HIGH - - // 1. Check if song is already cached locally at the desired quality (for 100% offline export) - val cachedData = getCachedAudioBytes(appContext, mediaMetadata.id) - ?.takeIf { cachedMatchesQuality(mediaMetadata.id, desiredQuality) } - if (cachedData != null) { - Timber.tag(TAG).d("Extracting song from local cache (offline mode) for: ${mediaMetadata.title}") - audioBytes = cachedData.first - extension = cachedData.second - showProgressNotification(appContext, notificationId, mediaMetadata.title, 50, subText) - } else { - // 2. Resolve stream URL and download if online - val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val playbackData = YTPlayerUtils.playerResponseForPlayback( - videoId = mediaMetadata.id, - playlistId = null, - audioQuality = desiredQuality, - connectivityManager = connectivityManager - ).getOrThrow() - - val format = playbackData.format - val streamUrl = playbackData.streamUrl - - Timber.tag(TAG).d("Stream URL resolved, format: ${format.mimeType}, bitrate: ${format.bitrate}") - - extension = when { - format.mimeType.contains("opus") || format.mimeType.contains("webm") -> "opus" - format.mimeType.contains("mp4") || format.mimeType.contains("m4a") -> "m4a" - else -> "m4a" - } + ): Result = withContext(Dispatchers.IO) { + if (cancelledNotificationIds.contains(notificationId)) { + cancelledNotificationIds.remove(notificationId) + return@withContext Result.failure(CancellationException("Download canceled")) + } + + val currentJob = currentCoroutineContext().job + activeSaveJobs[notificationId] = currentJob - // Add range parameter to bypass YouTube's bandwidth throttling for maximum download speed - val unthrottledStreamUrl = if (!streamUrl.contains("range=")) { - val length = format.contentLength ?: 15000000L - "${streamUrl}&range=0-$length" + try { + runCatching { + val appContext = context.applicationContext + Timber.tag(TAG).d("Starting save for: ${mediaMetadata.title} into $relativeFolder") + showProgressNotification(appContext, notificationId, mediaMetadata.title, 0, subText) + + var audioBytes: ByteArray? = null + var extension: String = "m4a" + + val desiredQuality = appContext.dataStore[DownloadQualityKey] + ?.let { runCatching { AudioQuality.valueOf(it) }.getOrNull() } ?: AudioQuality.HIGH + + // 1. Check if song is already cached locally at the desired quality (for 100% offline export) + val cachedData = getCachedAudioBytes(appContext, mediaMetadata.id) + ?.takeIf { cachedMatchesQuality(mediaMetadata.id, desiredQuality) } + if (cachedData != null) { + if (cancelledNotificationIds.contains(notificationId) || !currentCoroutineContext().isActive) { + throw CancellationException("Download canceled") + } + Timber.tag(TAG).d("Extracting song from local cache (offline mode) for: ${mediaMetadata.title}") + audioBytes = cachedData.first + extension = cachedData.second + showProgressNotification(appContext, notificationId, mediaMetadata.title, 50, subText) } else { - streamUrl - } + if (cancelledNotificationIds.contains(notificationId) || !currentCoroutineContext().isActive) { + throw CancellationException("Download canceled") + } + // 2. Resolve stream URL and download if online + val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val playbackData = YTPlayerUtils.playerResponseForPlayback( + videoId = mediaMetadata.id, + playlistId = null, + audioQuality = desiredQuality, + connectivityManager = connectivityManager + ).getOrThrow() + + val format = playbackData.format + val streamUrl = playbackData.streamUrl + + Timber.tag(TAG).d("Stream URL resolved, format: ${format.mimeType}, bitrate: ${format.bitrate}") + + extension = when { + format.mimeType.contains("opus") || format.mimeType.contains("webm") -> "opus" + format.mimeType.contains("mp4") || format.mimeType.contains("m4a") -> "m4a" + else -> "m4a" + } - val request = Request.Builder() - .url(unthrottledStreamUrl) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - .header("Accept", "*/*") - .header("Connection", "keep-alive") - .build() - - val response = httpClient.newCall(request).execute() - response.use { resp -> - if (!resp.isSuccessful) { - throw Exception("Download failed: HTTP ${resp.code}") + // Add range parameter to bypass YouTube's bandwidth throttling for maximum download speed + val unthrottledStreamUrl = if (!streamUrl.contains("range=")) { + val length = format.contentLength ?: 15000000L + "${streamUrl}&range=0-$length" + } else { + streamUrl } - val body = resp.body ?: throw Exception("Response body is null") - val contentLength = body.contentLength() - val inputStream = body.byteStream() - val outputBuffer = ByteArrayOutputStream( - if (contentLength > 0 && contentLength < Int.MAX_VALUE) contentLength.toInt() else 1024 * 1024 - ) - val buffer = ByteArray(65536) // 64 KB buffer for high-speed streaming - var bytesRead: Int - var totalBytesRead = 0L - var lastUpdateMs = 0L - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputBuffer.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - - val now = System.currentTimeMillis() - if (contentLength > 0 && (now - lastUpdateMs >= 150 || totalBytesRead == contentLength)) { - lastUpdateMs = now - val percent = ((totalBytesRead * 100) / contentLength).toInt().coerceIn(0, 100) - showProgressNotification( - context = appContext, - notificationId = notificationId, - title = mediaMetadata.title, - progress = percent, - subText = subText, - ) + val request = Request.Builder() + .url(unthrottledStreamUrl) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .header("Accept", "*/*") + .header("Connection", "keep-alive") + .build() + + val response = httpClient.newCall(request).execute() + response.use { resp -> + if (!resp.isSuccessful) { + throw Exception("Download failed: HTTP ${resp.code}") } + val body = resp.body ?: throw Exception("Response body is null") + val contentLength = body.contentLength() + val inputStream = body.byteStream() + val outputBuffer = ByteArrayOutputStream( + if (contentLength > 0 && contentLength < Int.MAX_VALUE) contentLength.toInt() else 1024 * 1024 + ) + + val buffer = ByteArray(65536) // 64 KB buffer for high-speed streaming + var bytesRead: Int + var totalBytesRead = 0L + var lastUpdateMs = 0L + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + if (cancelledNotificationIds.contains(notificationId) || !currentCoroutineContext().isActive) { + throw CancellationException("Download canceled") + } + outputBuffer.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + val now = System.currentTimeMillis() + if (contentLength > 0 && (now - lastUpdateMs >= 150 || totalBytesRead == contentLength)) { + lastUpdateMs = now + val percent = ((totalBytesRead * 100) / contentLength).toInt().coerceIn(0, 100) + showProgressNotification( + context = appContext, + notificationId = notificationId, + title = mediaMetadata.title, + progress = percent, + subText = subText, + ) + } + } + audioBytes = outputBuffer.toByteArray() } - audioBytes = outputBuffer.toByteArray() } - } - val finalAudioBytes = audioBytes ?: throw Exception("No audio data available") + if (cancelledNotificationIds.contains(notificationId) || !currentCoroutineContext().isActive) { + throw CancellationException("Download canceled") + } + + val finalAudioBytes = audioBytes ?: throw Exception("No audio data available") - // 3. Sanitise file name - val sanitisedTitle = mediaMetadata.title - .replace(Regex("[\\\\/:*?\"<>|]"), "_") - .take(200) - val artistName = mediaMetadata.artists.joinToString(", ") { it.name } - .replace(Regex("[\\\\/:*?\"<>|]"), "_") - .take(100) - val fileName = "${sanitisedTitle} - ${artistName}.$extension" - Timber.tag(TAG).d("Writing ${finalAudioBytes.size} bytes for $fileName") + // 3. Sanitise file name + val sanitisedTitle = mediaMetadata.title + .replace(Regex("[\\\\/:*?\"<>|]"), "_") + .take(200) + val artistName = mediaMetadata.artists.joinToString(", ") { it.name } + .replace(Regex("[\\\\/:*?\"<>|]"), "_") + .take(100) + val fileName = "${sanitisedTitle} - ${artistName}.$extension" + Timber.tag(TAG).d("Writing ${finalAudioBytes.size} bytes for $fileName") // 5. Write to Music folder val mimeType = when (extension) { @@ -509,36 +573,44 @@ object SaveToStorageUtil { Timber.tag(TAG).d("Saved via direct file write: ${outputFile.absolutePath}") } - val openFileIntent = PendingIntent.getActivity( - appContext, - notificationId, - Intent(Intent.ACTION_VIEW).apply { - setDataAndType(savedFileUri, mimeType) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - }, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) + val openFileIntent = PendingIntent.getActivity( + appContext, + notificationId, + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(savedFileUri, mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) - showCompleteNotification( - context = appContext, - notificationId = notificationId, - title = mediaMetadata.title, - success = true, - message = "${mediaMetadata.title} saved to Music/$relativeFolder", - openFileIntent = openFileIntent, - ) - fileName - }.onFailure { e -> - if (e !is CancellationException) { - Timber.tag(TAG).e(e, "Failed to save song to local storage") showCompleteNotification( - context = context.applicationContext, + context = appContext, notificationId = notificationId, title = mediaMetadata.title, - success = false, - message = "Failed to download: ${e.message}", + success = true, + message = "${mediaMetadata.title} saved to Music/$relativeFolder", + openFileIntent = openFileIntent, ) + fileName + }.onFailure { e -> + if (e !is CancellationException) { + Timber.tag(TAG).e(e, "Failed to save song to local storage") + showCompleteNotification( + context = context.applicationContext, + notificationId = notificationId, + title = mediaMetadata.title, + success = false, + message = "Failed to download: ${e.message}", + ) + } else { + Timber.tag(TAG).i("Download was canceled for ${mediaMetadata.title}") + val notificationManager = context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + notificationManager?.cancel(notificationId) + } } + } finally { + activeSaveJobs.remove(notificationId) + cancelledNotificationIds.remove(notificationId) } } } diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/BackupRestoreViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/BackupRestoreViewModel.kt index e878f629..c7415b93 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/BackupRestoreViewModel.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/BackupRestoreViewModel.kt @@ -705,16 +705,63 @@ class BackupRestoreViewModel @Inject constructor( } } - // Backup restored_cache_ids.json if present + // Determine actual cached song IDs from exoplayer_internal.db and restored_cache_ids.json + val actualCachedIds = mutableSetOf() + val exoDbPath = context.getDatabasePath("exoplayer_internal.db") + if (exoDbPath.exists() && exoDbPath.isFile) { + tryOrNull { + SQLiteDatabase.openDatabase(exoDbPath.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> + val indexTables = mutableListOf() + db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'ExoPlayerCacheIndex%'", + null + ).use { c -> + while (c.moveToNext()) { + indexTables.add(c.getString(0)) + } + } + for (tbl in indexTables) { + tryOrNull { + db.rawQuery("SELECT `key` FROM `$tbl`", null).use { c -> + val keyIdx = c.getColumnIndex("key") + if (keyIdx != -1) { + while (c.moveToNext()) { + val k = c.getString(keyIdx) + if (!k.isNullOrBlank()) { + actualCachedIds.add(k) + } + } + } + } + } + } + } + } + } + + // Also include any verified IDs from restored_cache_ids.json val restoredFile = context.filesDir.resolve("restored_cache_ids.json") if (restoredFile.exists() && restoredFile.isFile) { + tryOrNull { + val arr = JSONArray(restoredFile.readText()) + for (i in 0 until arr.length()) { + val rId = arr.getString(i) + if (!rId.isNullOrBlank()) { + actualCachedIds.add(rId) + } + } + } + } + + // Backup restored_cache_ids.json containing ONLY actual cached songs + if (actualCachedIds.isNotEmpty()) { tryOrNull { zipOut.putNextEntry(ZipEntry("restored_cache_ids.json")) - restoredFile.inputStream().buffered().use { it.copyTo(zipOut) } + zipOut.write(JSONArray(actualCachedIds.toList()).toString().toByteArray(Charsets.UTF_8)) } } - // 4. Backup cached song metadata and formats + // 4. Backup ONLY cached song metadata and formats (do NOT backup full playback history) tryOrNull { val formatsMap = mutableMapOf() tryOrNull { @@ -732,17 +779,19 @@ class BackupRestoreViewModel @Inject constructor( val urlCol = cursor.getColumnIndex("playbackUrl") while (cursor.moveToNext()) { val sId = cursor.getString(idCol) - formatsMap[sId] = FormatEntity( - id = sId, - itag = cursor.getInt(itagCol), - mimeType = cursor.getString(mimeCol), - codecs = cursor.getString(codecsCol), - bitrate = cursor.getInt(bitrateCol), - sampleRate = if (!cursor.isNull(sampleCol)) cursor.getInt(sampleCol) else null, - contentLength = cursor.getLong(lenCol), - loudnessDb = if (!cursor.isNull(loudCol)) cursor.getDouble(loudCol) else null, - playbackUrl = if (!cursor.isNull(urlCol)) cursor.getString(urlCol) else null - ) + if (sId in actualCachedIds) { + formatsMap[sId] = FormatEntity( + id = sId, + itag = cursor.getInt(itagCol), + mimeType = cursor.getString(mimeCol), + codecs = cursor.getString(codecsCol), + bitrate = cursor.getInt(bitrateCol), + sampleRate = if (!cursor.isNull(sampleCol)) cursor.getInt(sampleCol) else null, + contentLength = cursor.getLong(lenCol), + loudnessDb = if (!cursor.isNull(loudCol)) cursor.getDouble(loudCol) else null, + playbackUrl = if (!cursor.isNull(urlCol)) cursor.getString(urlCol) else null + ) + } } } } @@ -757,14 +806,17 @@ class BackupRestoreViewModel @Inject constructor( val durCol = cursor.getColumnIndex("duration") val thumbCol = cursor.getColumnIndex("thumbnailUrl") while (cursor.moveToNext()) { - songsList.add( - SongEntity( - id = cursor.getString(idCol), - title = cursor.getString(titleCol), - duration = cursor.getInt(durCol), - thumbnailUrl = if (!cursor.isNull(thumbCol)) cursor.getString(thumbCol) else null + val sId = cursor.getString(idCol) + if (sId in actualCachedIds) { + songsList.add( + SongEntity( + id = sId, + title = cursor.getString(titleCol), + duration = cursor.getInt(durCol), + thumbnailUrl = if (!cursor.isNull(thumbCol)) cursor.getString(thumbCol) else null + ) ) - ) + } } } } @@ -778,8 +830,10 @@ class BackupRestoreViewModel @Inject constructor( val nameCol = cursor.getColumnIndex("name") while (cursor.moveToNext()) { val sId = cursor.getString(songIdCol) - val name = cursor.getString(nameCol) - artistMap.getOrPut(sId) { mutableListOf() }.add(name) + if (sId in actualCachedIds) { + val name = cursor.getString(nameCol) + artistMap.getOrPut(sId) { mutableListOf() }.add(name) + } } } } diff --git a/app/src/main/res/drawable/export.xml b/app/src/main/res/drawable/export.xml new file mode 100644 index 00000000..4faaf5bf --- /dev/null +++ b/app/src/main/res/drawable/export.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 820c2756..a60fc944 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1057,5 +1057,15 @@ Restore All All excluded songs restored Recommendations + + + Export playlist + Playlist exported successfully + Playlist imported: %1$s (%2$d songs) + Failed to import playlist + Create playlist + Import playlist + Download canceled +