Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@
android:resource="@xml/widget_info" />
</receiver>

<receiver
android:name=".utils.SaveToStorageCancelReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.darkxvenom.airbeats.action.CANCEL_SAVE_TO_STORAGE" />
</intent-filter>
</receiver>

<uses-library
android:name="org.apache.http.legacy"
android:required="false" />
Expand Down
21 changes: 13 additions & 8 deletions app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2839,7 +2845,6 @@ class MusicService :
)
}
if (jsStreamUrl != null) {
tryOrNull { playerCache.removeResource(mediaId) }
database.query {
upsert(
FormatEntity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 92 additions & 22 deletions app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Song> = 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) {
Expand Down Expand Up @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,36 @@ fun YouTubePlaylistMenu(
mutableStateOf(mutableListOf<MediaMetadata>())
}

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 ->
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading