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
+