diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc92efbe..10b5bbff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ koin = "4.0.4" picasso = "2.8" junit4 = "4.13.2" -junit5 = "5.12.1" +junit5 = "5.12.2" mockito = "5.17.0" mockitoKotlin = "5.4.0" kluent = "1.73" diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index fdc46f0b..dd529bf1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -435,12 +435,15 @@ class NavigationActivity : ScopeActivity() { } override fun onOptionsItemSelected(item: MenuItem): Boolean { - // Skip android.R.id.home so the drawer button doesn't get wrongly routed - if (item.itemId == android.R.id.home) { - return super.onOptionsItemSelected(item) - } - return item.onNavDestinationSelected(findNavController(R.id.nav_host_fragment)) || + val navController = findNavController(R.id.nav_host_fragment) + // Check if this item ID exists in the nav graph + val destinationExists = navController.graph.findNode(item.itemId) != null + return if (destinationExists) { + item.onNavDestinationSelected(navController) || super.onOptionsItemSelected(item) + } else { + // Let the fragments handle their own menu items super.onOptionsItemSelected(item) + } } override fun onSupportNavigateUp(): Boolean { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 436f8d23..d39e9f1f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -83,6 +83,31 @@ class TrackViewHolder(val view: View) : fun setSong(song: Track, checkable: Boolean, draggable: Boolean, isSelected: Boolean = false) { entry = song + // Create new Disposable for the new Subscriptions + rxBusSubscription = CompositeDisposable() + rxBusSubscription!! += RxBus.playerStateObservable.subscribe { + setPlayIcon(it.track?.id == song.id && it.index == bindingAdapterPosition) + } + + rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe { + if (it.id != song.id) return@subscribe + updateStatus(it.state, it.progress) + } + + // Listen for rating updates + rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe { + launch(Dispatchers.Main) { + // Ignore updates which are not for the current song + if (it.id != song.id) return@launch + + if (it.rating is HeartRating) { + updateRatingDisplay(song.userRating, it.rating.isHeart) + } else if (it.rating is StarRating) { + updateRatingDisplay(it.rating.starRating.toInt(), song.starred) + } + } + } + val entryDescription = Util.readableEntryDescription(song) artist.text = entryDescription.artist @@ -121,31 +146,6 @@ class TrackViewHolder(val view: View) : artist.isGone = true progressIndicator.isGone = true } - - // Create new Disposable for the new Subscriptions - rxBusSubscription = CompositeDisposable() - rxBusSubscription!! += RxBus.playerStateObservable.subscribe { - setPlayIcon(it.track?.id == song.id && it.index == bindingAdapterPosition) - } - - rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe { - if (it.id != song.id) return@subscribe - updateStatus(it.state, it.progress) - } - - // Listen for rating updates - rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe { - launch(Dispatchers.Main) { - // Ignore updates which are not for the current song - if (it.id != song.id) return@launch - - if (it.rating is HeartRating) { - updateRatingDisplay(song.userRating, it.rating.isHeart) - } else if (it.rating is StarRating) { - updateRatingDisplay(it.rating.starRating.toInt(), song.starred) - } - } - } } // This is called when the Holder is recycled and receives a new Song diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt index b41f630d..4fe5ca66 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt @@ -159,7 +159,7 @@ class ActiveServerProvider( METADATA_DB + serverId ) .addMigrations(META_MIGRATION_2_3) - .fallbackToDestructiveMigrationOnDowngrade() + .fallbackToDestructiveMigrationOnDowngrade(true) .build() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt index 9495544b..4d1d9cb3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt @@ -2,7 +2,7 @@ package org.moire.ultrasonic.di import androidx.room.Room import org.koin.android.ext.koin.androidContext -import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module import org.moire.ultrasonic.data.AppDatabase diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt index d4b873f3..9b7e9c7d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt @@ -70,7 +70,7 @@ class ArtistListFragment : EntryListFragment() { id = item.id, name = item.name, parentId = item.id, - isArtist = (item is Artist) + isArtist = false ) } else { NavigationGraphDirections.toAlbumList( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt index 94fac688..ad8fde7f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -312,11 +312,11 @@ class DownloadService : Service(), KoinComponent { completeFile?.let { postState(track, DownloadState.DONE) - false + return@filter false } pinnedFile?.let { postState(track, DownloadState.PINNED) - false + return@filter false } true } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt index 8ff93482..a5f989a5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt @@ -23,6 +23,7 @@ import org.moire.ultrasonic.data.MetaDatabase import org.moire.ultrasonic.domain.Album import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.service.DownloadState.Companion.isFinalState import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil.copyWithProgress @@ -48,11 +49,17 @@ class DownloadTask( private var inputStream: InputStream? = null private var outputStream: OutputStream? = null private var lastPostTime: Long = 0 + private var state: DownloadState = DownloadState.UNKNOWN + + private fun setState(state: DownloadState, progress: Int?) { + this.state = state + stateChangedCallback(downloadTrack, state, progress) + } private fun checkIfExists(): Boolean { if (Storage.isPathExists(downloadTrack.pinnedFile)) { Timber.i("%s already exists. Skipping.", downloadTrack.pinnedFile) - stateChangedCallback(downloadTrack, DownloadState.PINNED, null) + setState(DownloadState.PINNED, null) return true } @@ -77,7 +84,7 @@ class DownloadTask( } catch (ignore: Exception) { Timber.w(ignore) } - stateChangedCallback(downloadTrack, newStatus, null) + setState(newStatus, null) return true } @@ -85,7 +92,7 @@ class DownloadTask( } fun download() { - stateChangedCallback(downloadTrack, DownloadState.DOWNLOADING, null) + setState(DownloadState.DOWNLOADING, null) val fileLength = Storage.getFromPath(downloadTrack.partialFile)?.length ?: 0 @@ -136,11 +143,7 @@ class DownloadTask( (totalBytesCopied * 100 / (size)).toInt() } - stateChangedCallback( - downloadTrack, - DownloadState.DOWNLOADING, - progress - ) + setState(DownloadState.DOWNLOADING, progress) } } @@ -157,7 +160,7 @@ class DownloadTask( downloadTrack.pinnedFile ) Timber.i("Renamed file to ${downloadTrack.pinnedFile}") - stateChangedCallback(downloadTrack, DownloadState.PINNED, null) + setState(DownloadState.PINNED, null) Util.scanMedia(downloadTrack.pinnedFile) } else { Storage.rename( @@ -165,20 +168,22 @@ class DownloadTask( downloadTrack.completeFile ) Timber.i("Renamed file to ${downloadTrack.completeFile}") - stateChangedCallback(downloadTrack, DownloadState.DONE, null) + setState(DownloadState.DONE, null) } } private fun onCompletion(e: Throwable?) { if (e is CancellationException) { - Timber.w(e, "CompletionHandler ${downloadTrack.pinnedFile}") - stateChangedCallback(downloadTrack, DownloadState.CANCELLED, null) + if (!state.isFinalState()) { + Timber.w(e, "CompletionHandler ${downloadTrack.pinnedFile}") + setState(DownloadState.CANCELLED, null) + } } else if (e != null) { Timber.w(e, "CompletionHandler ${downloadTrack.pinnedFile}") if (downloadTrack.tryCount < MAX_RETRIES) { - stateChangedCallback(downloadTrack, DownloadState.RETRYING, null) + setState(DownloadState.RETRYING, null) } else { - stateChangedCallback(downloadTrack, DownloadState.FAILED, null) + setState(DownloadState.FAILED, null) } } inputStream.safeClose() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt index 6f395b5d..91d85d35 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -725,7 +725,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { } override fun getTrackSelectionParameters(): TrackSelectionParameters { - return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + return TrackSelectionParameters.DEFAULT } override fun getMaxSeekToPreviousPosition(): Long { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt index 9772dfa2..0f372053 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt @@ -19,6 +19,7 @@ import androidx.media3.common.C import androidx.media3.common.C.USAGE_MEDIA import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.media3.common.TrackSelectionParameters import androidx.media3.datasource.DataSource import androidx.media3.datasource.ResolvingDataSource @@ -292,6 +293,11 @@ class PlaybackService : cacheNextSongs() } + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + // Handles playlist changes, e.g. when tracks are reordered or deleted + cacheNextSongs() + } + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { // Since we cannot update the metadata of the media item after creation, // we cannot set change the rating on it diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index 6f460fe3..cf2c5380 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -192,6 +192,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo val files: MutableList = ArrayList() val dirs: MutableList = ArrayList() + Timber.i("CacheCleaner backgroundSpaceCleanup running...") findCandidatesForDeletion(musicDirectory, files, dirs) val bytesToDelete = getMinimumDelete(files) @@ -235,7 +236,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo val filesToNotDelete: MutableSet = HashSet(5) // We just take the last published playlist from RX - val playlist = RxBus.playlistObservable.blockingLast() + val playlist = RxBus.playlistObservable.firstElement().blockingGet() + ?: return filesToNotDelete for (track in playlist) { filesToNotDelete.add(track.getPartialFile()) filesToNotDelete.add(track.getCompleteFile()) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index 6bba5740..8a6a832f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -740,7 +740,7 @@ object Util { timeline: Timeline?, isShuffled: Boolean, firstIndex: Int? = null, - count: Int? = null + count: Int = Int.MAX_VALUE ): List { if (timeline == null) return emptyList() if (timeline.windowCount < 1) return emptyList() @@ -749,7 +749,7 @@ object Util { var i = firstIndex ?: timeline.getFirstWindowIndex(isShuffled) if (i == C.INDEX_UNSET) return emptyList() - while (i != C.INDEX_UNSET && (count != playlist.count())) { + while (i != C.INDEX_UNSET && (count >= playlist.count())) { val window = timeline.getWindow(i, Timeline.Window()) playlist.add(window.mediaItem) i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, isShuffled)