Merge branch '463' into 'master'

Release candidate 4.6.3

See merge request ultrasonic/ultrasonic!1081
This commit is contained in:
birdbird 2023-07-30 14:02:12 +00:00
commit 37935a5f86
12 changed files with 170 additions and 83 deletions

View File

@ -0,0 +1,15 @@
Features:
- Search is accessible through a new icon on the main screen
- Modernize Back Handling
- Reenable R8 Code minification
- Add a "Play Random Songs" shortcut
Bug fixes:
- Fix a few crashes
- Avoid triggering a bug in Supysonic
- Readd the "Star" button to the Now Playing screen
- Fix a rare crash when shuffling playlists with duplicate entries
- Fix a crash when choosing "Play next" on an empty playlist.
- Tracks buttons flash a scrollbar sometimes in Android 13
- Fix EndlessScrolling in genre listing
- Couldn't delete a track when shuffle was active

View File

@ -9,8 +9,8 @@ android {
defaultConfig { defaultConfig {
applicationId "org.moire.ultrasonic" applicationId "org.moire.ultrasonic"
versionCode 125 versionCode 126
versionName "4.6.2" versionName "4.6.3"
minSdkVersion versions.minSdk minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk targetSdkVersion versions.targetSdk

View File

@ -306,7 +306,7 @@ class NavigationActivity : AppCompatActivity() {
Storage.reset() Storage.reset()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Storage.ensureRootIsAvailable() Storage.checkForErrorsWithCustomRoot()
} }
setMenuForServerCapabilities() setMenuForServerCapabilities()

View File

@ -140,7 +140,11 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
private fun downloadBackground(save: Boolean, songs: List<Track?>) { private fun downloadBackground(save: Boolean, songs: List<Track?>) {
val onValid = Runnable { val onValid = Runnable {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
DownloadService.download(songs.filterNotNull(), save) DownloadService.download(
songs.filterNotNull(),
save = save,
updateSaveFlag = true
)
} }
onValid.run() onValid.run()
} }

View File

@ -344,7 +344,7 @@ class SettingsFragment :
// Clear download queue. // Clear download queue.
mediaPlayerManager.clear() mediaPlayerManager.clear()
Storage.reset() Storage.reset()
Storage.ensureRootIsAvailable() Storage.checkForErrorsWithCustomRoot()
} }
private fun setDebugLogToFile(writeLog: Boolean) { private fun setDebugLogToFile(writeLog: Boolean) {

View File

@ -328,7 +328,7 @@ class PlaybackService :
).map { it.toTrack() } ).map { it.toTrack() }
launch { launch {
DownloadService.download(nextSongs, save = false, isHighPriority = true) DownloadService.download(nextSongs, isHighPriority = true)
} }
} }

View File

@ -274,46 +274,26 @@ class DownloadService : Service(), KoinComponent {
@Synchronized @Synchronized
fun download( fun download(
tracks: List<Track>, tracks: List<Track>,
save: Boolean, save: Boolean = false,
isHighPriority: Boolean = false isHighPriority: Boolean = false,
updateSaveFlag: Boolean = false
) { ) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
// First handle and filter out those tracks that are already completed // Remove tracks which are already downloaded and update the save flag
var filteredTracks: List<Track> // if needed
if (save) { var filteredTracks = if (updateSaveFlag) {
tracks.filter { Storage.isPathExists(it.getCompleteFile()) }.forEach { track -> setSaveFlagForTracks(save, tracks)
Storage.getFromPath(track.getCompleteFile())?.let {
Storage.renameOrDeleteIfAlreadyExists(it, track.getPinnedFile())
postState(track, DownloadState.PINNED)
}
}
filteredTracks = tracks.filter { !Storage.isPathExists(it.getPinnedFile()) }
} else { } else {
tracks.filter { Storage.isPathExists(it.getPinnedFile()) }.forEach { track -> removeDownloadedTracksFromList(tracks)
Storage.getFromPath(track.getPinnedFile())?.let {
Storage.renameOrDeleteIfAlreadyExists(it, track.getCompleteFile())
postState(track, DownloadState.DONE)
}
}
filteredTracks = tracks.filter { !Storage.isPathExists(it.getCompleteFile()) }
}
// Update Pinned flag of items in progress
downloadQueue.filter { item -> tracks.any { it.id == item.id } }
.forEach { it.pinned = save }
tracks.forEach {
activeDownloads[it.id]?.downloadTrack?.pinned = save
}
tracks.forEach {
failedList[it.id]?.pinned = save
} }
// Remove tracks which are currently downloading
filteredTracks = filteredTracks.filter { filteredTracks = filteredTracks.filter {
!downloadQueue.any { i -> i.id == it.id } && !activeDownloads.containsKey(it.id) !downloadQueue.any { i -> i.id == it.id } && !activeDownloads.containsKey(it.id)
} }
// The remainder tracks should be added to the download queue // The remaining tracks should be added to the download queue
// By using the counter we ensure that the songs are added in the correct order // By using the counter we ensure that the songs are added in the correct order
var priority = 0 var priority = 0
val tracksToDownload = val tracksToDownload =
@ -334,6 +314,69 @@ class DownloadService : Service(), KoinComponent {
} }
} }
private fun removeDownloadedTracksFromList(tracks: List<Track>): List<Track> {
return tracks.filter { track ->
val pinnedFile = Storage.getFromPath(track.getPinnedFile())
val completeFile = Storage.getFromPath(track.getCompleteFile())
completeFile?.let {
postState(track, DownloadState.DONE)
false
}
pinnedFile?.let {
postState(track, DownloadState.PINNED)
false
}
true
}
}
private fun setSaveFlagForTracks(
shouldPin: Boolean,
tracks: List<Track>
): List<Track> {
// Walk through the tracks. If a track is pinned or complete and needs to be changed
// to the other state, rename it, but don't return it, thereby excluding it from
// further processing.
// If it is neither pinned nor saved, return it, so that it can be processed.
val filteredTracks: List<Track> = tracks.map { track ->
val pinnedFile = Storage.getFromPath(track.getPinnedFile())
val completeFile = Storage.getFromPath(track.getCompleteFile())
if (shouldPin) {
pinnedFile?.let {
null
}
completeFile?.let {
Storage.renameOrDeleteIfAlreadyExists(it, track.getPinnedFile())
postState(track, DownloadState.PINNED)
null
}
} else {
completeFile?.let {
null
}
pinnedFile?.let {
Storage.renameOrDeleteIfAlreadyExists(it, track.getCompleteFile())
postState(track, DownloadState.DONE)
null
}
}
track
}
// Update Pinned flag of items in progress
downloadQueue.filter { item -> tracks.any { it.id == item.id } }
.forEach { it.pinned = shouldPin }
tracks.forEach {
activeDownloads[it.id]?.downloadTrack?.pinned = shouldPin
}
tracks.forEach {
failedList[it.id]?.pinned = shouldPin
}
return filteredTracks
}
fun requestStop() { fun requestStop() {
val context = UApp.applicationContext() val context = UApp.applicationContext()
val intent = Intent(context, DownloadService::class.java) val intent = Intent(context, DownloadService::class.java)

View File

@ -181,19 +181,22 @@ class MediaPlayerManager(
createMediaController(onCreated) createMediaController(onCreated)
rxBusSubscription += RxBus.activeServerChangingObservable.subscribe { oldServer -> rxBusSubscription += RxBus.activeServerChangingObservable
if (oldServer != OFFLINE_DB_ID) { // All interaction with the Media3 needs to happen on the main thread
// When the server changes, the playlist can retain the downloaded songs. .subscribeOn(RxBus.mainThread())
// Incomplete songs should be removed as the new server won't recognise them. .subscribe { oldServer ->
removeIncompleteTracksFromPlaylist() if (oldServer != OFFLINE_DB_ID) {
DownloadService.requestStop() // When the server changes, the playlist can retain the downloaded songs.
// Incomplete songs should be removed as the new server won't recognise them.
removeIncompleteTracksFromPlaylist()
DownloadService.requestStop()
}
if (controller is JukeboxMediaPlayer) {
// When the server changes, the Jukebox should be released.
// The new server won't understand the jukebox requests of the old one.
switchToLocalPlayer()
}
} }
if (controller is JukeboxMediaPlayer) {
// When the server changes, the Jukebox should be released.
// The new server won't understand the jukebox requests of the old one.
switchToLocalPlayer()
}
}
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe { rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
val jukebox = activeServerProvider.getActiveServer().jukeboxByDefault val jukebox = activeServerProvider.getActiveServer().jukeboxByDefault
@ -204,19 +207,19 @@ class MediaPlayerManager(
isJukeboxEnabled = jukebox isJukeboxEnabled = jukebox
} }
rxBusSubscription += RxBus.throttledPlaylistObservable.subscribe { rxBusSubscription += RxBus.throttledPlaylistObservable
// Even though Rx should launch on the main thread it doesn't always :( // All interaction with the Media3 needs to happen on the main thread
mainScope.launch { .subscribeOn(RxBus.mainThread())
.subscribe {
serializeCurrentSession() serializeCurrentSession()
} }
}
rxBusSubscription += RxBus.throttledPlayerStateObservable.subscribe { rxBusSubscription += RxBus.throttledPlayerStateObservable
// Even though Rx should launch on the main thread it doesn't always :( // All interaction with the Media3 needs to happen on the main thread
mainScope.launch { .subscribeOn(RxBus.mainThread())
.subscribe {
serializeCurrentSession() serializeCurrentSession()
} }
}
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe { rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
clear(false) clear(false)
@ -245,7 +248,10 @@ class MediaPlayerManager(
mediaControllerFuture = MediaController.Builder( mediaControllerFuture = MediaController.Builder(
context, context,
sessionToken sessionToken
).buildAsync() )
// Specify mainThread explicitely
.setApplicationLooper(Looper.getMainLooper())
.buildAsync()
mediaControllerFuture?.addListener({ mediaControllerFuture?.addListener({
controller = mediaControllerFuture?.get() controller = mediaControllerFuture?.get()

View File

@ -1,8 +1,8 @@
package org.moire.ultrasonic.service package org.moire.ultrasonic.service
import android.os.Looper
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.PublishSubject
@ -12,14 +12,14 @@ import org.moire.ultrasonic.domain.Track
class RxBus { class RxBus {
/* /**
* TODO: mainThread() seems to be not equal to the "normal" main Thread, so it causes * IMPORTANT: methods like .delay() or .throttle() will implicitly change the thread to the
* a lot of often unnecessary thread switching. It looks like observeOn can actually * RxComputationScheduler. Always use the function call with the additional arguments of the
* be removed in many cases * desired scheduler
*/ **/
companion object { companion object {
private fun mainThread() = AndroidSchedulers.from(Looper.getMainLooper()) fun mainThread(): Scheduler = AndroidSchedulers.mainThread()
val shufflePlayPublisher: PublishSubject<Boolean> = val shufflePlayPublisher: PublishSubject<Boolean> =
PublishSubject.create() PublishSubject.create()
@ -57,7 +57,8 @@ class RxBus {
playerStatePublisher playerStatePublisher
.replay(1) .replay(1)
.autoConnect(0) .autoConnect(0)
.throttleLatest(300, TimeUnit.MILLISECONDS) // Need to specify thread, see comment at beginning
.throttleLatest(300, TimeUnit.MILLISECONDS, mainThread())
val playlistPublisher: PublishSubject<List<Track>> = val playlistPublisher: PublishSubject<List<Track>> =
PublishSubject.create() PublishSubject.create()
@ -69,7 +70,8 @@ class RxBus {
playlistPublisher playlistPublisher
.replay(1) .replay(1)
.autoConnect(0) .autoConnect(0)
.throttleLatest(300, TimeUnit.MILLISECONDS) // Need to specify thread, see comment at beginning
.throttleLatest(300, TimeUnit.MILLISECONDS, mainThread())
val trackDownloadStatePublisher: PublishSubject<TrackDownloadState> = val trackDownloadStatePublisher: PublishSubject<TrackDownloadState> =
PublishSubject.create() PublishSubject.create()

View File

@ -53,8 +53,16 @@ class DownloadHandler(
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// If we are just downloading tracks we don't need to add them to the controller // If we are just downloading tracks we don't need to add them to the controller
when (action) { when (action) {
DownloadAction.DOWNLOAD -> DownloadService.download(tracksToDownload, false) DownloadAction.DOWNLOAD -> DownloadService.download(
DownloadAction.PIN -> DownloadService.download(tracksToDownload, true) tracksToDownload,
save = false,
updateSaveFlag = true
)
DownloadAction.PIN -> DownloadService.download(
tracksToDownload,
save = true,
updateSaveFlag = true
)
DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload) DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload)
DownloadAction.DELETE -> DownloadService.delete(tracksToDownload) DownloadAction.DELETE -> DownloadService.delete(tracksToDownload)
} }

View File

@ -235,11 +235,9 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo
private fun findFilesToNotDelete(): Set<String> { private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5) val filesToNotDelete: MutableSet<String> = HashSet(5)
val mediaController = inject<MediaPlayerManager>( val mediaPlayerManager: MediaPlayerManager by inject()
MediaPlayerManager::class.java
)
val playlist = mainScope.future { mediaController.value.playlist }.get() val playlist = mainScope.future { mediaPlayerManager.playlist }.get()
for (item in playlist) { for (item in playlist) {
val track = item.toTrack() val track = item.toTrack()
filesToNotDelete.add(track.getPartialFile()) filesToNotDelete.add(track.getPartialFile())

View File

@ -22,19 +22,23 @@ import timber.log.Timber
object Storage { object Storage {
val mediaRoot: ResettableLazy<AbstractFile> = ResettableLazy { val mediaRoot: ResettableLazy<AbstractFile> = ResettableLazy {
getRoot()!! val ret = getRoot()
rootNotFoundError = ret.second
ret.first
} }
var rootNotFoundError: Boolean = false
fun reset() { fun reset() {
StorageFile.storageFilePathDictionary.clear() StorageFile.storageFilePathDictionary.clear()
StorageFile.notExistingPathDictionary.clear() StorageFile.notExistingPathDictionary.clear()
mediaRoot.reset() mediaRoot.reset()
rootNotFoundError = false
Timber.i("StorageFile caches were reset") Timber.i("StorageFile caches were reset")
} }
fun ensureRootIsAvailable() { fun checkForErrorsWithCustomRoot() {
val root = getRoot() if (rootNotFoundError) {
if (root == null) {
Settings.customCacheLocation = false Settings.customCacheLocation = false
Settings.cacheLocationUri = "" Settings.cacheLocationUri = ""
Util.toast(UApp.applicationContext(), R.string.settings_cache_location_error) Util.toast(UApp.applicationContext(), R.string.settings_cache_location_error)
@ -98,18 +102,25 @@ object Storage {
return success return success
} }
private fun getRoot(): AbstractFile? { private fun getRoot(): Pair<AbstractFile, Boolean> {
return if (Settings.customCacheLocation) { return if (Settings.customCacheLocation) {
if (Settings.cacheLocationUri.isBlank()) return null if (Settings.cacheLocationUri.isBlank()) return Pair(getDefaultRoot(), true)
val documentFile = DocumentFile.fromTreeUri( val documentFile = DocumentFile.fromTreeUri(
UApp.applicationContext(), UApp.applicationContext(),
Uri.parse(Settings.cacheLocationUri) Uri.parse(Settings.cacheLocationUri)
) ?: return null ) ?: return Pair(getDefaultRoot(), true)
if (!documentFile.exists()) return null if (!documentFile.exists()) return Pair(getDefaultRoot(), true)
StorageFile(null, documentFile.uri, documentFile.name!!, documentFile.isDirectory) Pair(
StorageFile(null, documentFile.uri, documentFile.name!!, documentFile.isDirectory),
false
)
} else { } else {
val file = File(FileUtil.defaultMusicDirectory.path) Pair(getDefaultRoot(), false)
JavaFile(null, file)
} }
} }
private fun getDefaultRoot(): JavaFile {
val file = File(FileUtil.defaultMusicDirectory.path)
return JavaFile(null, file)
}
} }