Compare commits

...

32 Commits

Author SHA1 Message Date
birdbird
842cb36ecb Merge branch 'RevertJackson' into 'develop'
Revert Jackson to 2.13.5 for compatibility with older APIs

See merge request ultrasonic/ultrasonic!993
2023-05-07 15:24:16 +00:00
birdbird
e06b8bc22e Merge branch 'FixExceptions' into 'develop'
Fix a bunch of Exceptions collected through Play Store reporting

See merge request ultrasonic/ultrasonic!994
2023-05-07 15:23:57 +00:00
birdbird
82fb45bd55 Fix a bunch of Exceptions collected through Play Store reporting 2023-05-07 15:23:57 +00:00
tzugen
751b946092
Revert Jackson to 2.13.5 for compatibility with older APIs 2023-05-07 12:57:15 +02:00
birdbird
39085f68b1 Merge branch 'renovate/kotlinxguava' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-guava to v1.7.0

See merge request ultrasonic/ultrasonic!992
2023-05-07 10:51:05 +00:00
Renovate Bot
1beb67c497 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-guava to v1.7.0 2023-05-07 10:32:39 +00:00
birdbird
2ba001894a Merge branch 'weblate-ultrasonic-app' into 'develop'
Translations update from Hosted Weblate

See merge request ultrasonic/ultrasonic!985
2023-05-07 10:21:22 +00:00
Kaiyang Wu
0650ce0bba
Translated using Weblate (Chinese (Traditional))
Currently translated at 54.2% (231 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/zh_Hant/
2023-05-07 11:56:24 +02:00
Kaiyang Wu
218f144848
Translated using Weblate (Chinese (Traditional))
Currently translated at 53.0% (226 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/zh_Hant/
2023-05-07 11:56:24 +02:00
Kaiyang Wu
83c9c188e9
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (426 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/zh_Hans/
2023-05-07 11:56:24 +02:00
birdbird
a4e8a7f94d Merge branch 'renovate/mockito-monorepo' into 'develop'
Update dependency org.mockito:mockito-core to v5.3.1

See merge request ultrasonic/ultrasonic!956
2023-05-07 09:56:19 +00:00
Renovate Bot
4f5d503ceb Update dependency org.mockito:mockito-core to v5.3.1 2023-05-07 09:56:19 +00:00
birdbird
381e2e4b86 Merge branch 'renovate/kotlinxcoroutines' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.7.0

See merge request ultrasonic/ultrasonic!991
2023-05-07 09:49:19 +00:00
birdbird
2a90fe4aab Merge branch 'master' into 'develop'
Merge changes from master back to dev. (4.3.3 release)

See merge request ultrasonic/ultrasonic!989
2023-05-07 09:47:24 +00:00
birdbird
f37301e738 Merge changes from master back to dev. (4.3.3 release) 2023-05-07 09:47:24 +00:00
birdbird
fca5ffaa0c Merge branch 'correctDefaults' into 'develop'
Correctly enable Artists pictures by default (was enabled in Settings mit not in Code)

See merge request ultrasonic/ultrasonic!988
2023-05-07 09:33:54 +00:00
Renovate Bot
a0314a865c Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.7.0 2023-05-07 09:32:30 +00:00
birdbird
5da9a2819c Merge branch 'ratingManager' into 'develop'
Introduce a RatingManager that takes care of receiving and passing ratings...

See merge request ultrasonic/ultrasonic!981
2023-05-07 09:27:24 +00:00
birdbird
2a02c94c8f Introduce a RatingManager that takes care of receiving and passing ratings... 2023-05-07 09:27:24 +00:00
tzugen
96073125ca
Correctly enable Artists pictures by default (was enabled in Settings mit not in Code) 2023-05-07 11:19:01 +02:00
birdbird
58bd663ac0 Merge branch 'renovate/junit5-monorepo' into 'develop'
Update dependency org.junit.vintage:junit-vintage-engine to v5.9.3

See merge request ultrasonic/ultrasonic!983
2023-05-07 09:16:04 +00:00
birdbird
e689193df1 Merge branch 'renovate/robolectric' into 'develop'
Update dependency org.robolectric:robolectric to v4.10.2

See merge request ultrasonic/ultrasonic!987
2023-05-07 09:15:26 +00:00
birdbird
1aa388d48f Merge branch 'renovate/kotlin-monorepo' into 'develop'
Update kotlin monorepo to v1.8.21

See merge request ultrasonic/ultrasonic!980
2023-05-07 09:15:13 +00:00
birdbird
8f84020cfa Merge branch 'renovate/kluent' into 'develop'
Update kluent to v1.73

See merge request ultrasonic/ultrasonic!984
2023-05-07 09:14:39 +00:00
birdbird
db88ff8431 Merge branch 'renovate/ktlintgradle' into 'develop'
Update dependency org.jlleitschuh.gradle:ktlint-gradle to v11.3.2

See merge request ultrasonic/ultrasonic!982
2023-05-07 09:14:12 +00:00
birdbird
2d1642170a Merge branch 'renovate/gradleplugin' into 'develop'
Update dependency com.android.tools.build:gradle to v8.0.1

See merge request ultrasonic/ultrasonic!986
2023-05-07 09:14:03 +00:00
Renovate Bot
0cb7952943 Update dependency org.robolectric:robolectric to v4.10.2 2023-05-04 19:32:12 +00:00
Renovate Bot
d750c84606 Update dependency com.android.tools.build:gradle to v8.0.1 2023-05-01 16:32:12 +00:00
Renovate Bot
7abca537c9 Update kluent to v1.73 2023-04-29 14:32:22 +00:00
Renovate Bot
ca2c5483c0 Update dependency org.junit.vintage:junit-vintage-engine to v5.9.3 2023-04-26 07:32:20 +00:00
Renovate Bot
7b414a3a23 Update dependency org.jlleitschuh.gradle:ktlint-gradle to v11.3.2 2023-04-25 14:32:15 +00:00
Renovate Bot
10767d2d5b Update kotlin monorepo to v1.8.21 2023-04-25 05:32:25 +00:00
27 changed files with 397 additions and 223 deletions

View File

@ -13,7 +13,6 @@ dependencies {
testImplementation libs.kotlinJunit
testImplementation libs.mockito
testImplementation libs.mockitoInline
testImplementation libs.mockitoKotlin
testImplementation libs.kluent
testImplementation libs.mockWebServer

View File

@ -0,0 +1,8 @@
Bug fixes
- Fix various crashes
Changes since 4.2.0
- #827: Make app full compliant Android Auto to publish in Play Store.
- #878: "Play shuffled" option for playlists always begins with the first track.
- #891: Dump config to log file when logging is enabled.
- #854: Remove Videos menu option for servers which don't support it.

View File

@ -3,10 +3,10 @@
gradle = "7.6"
navigation = "2.5.3"
gradlePlugin = "8.0.0"
gradlePlugin = "8.0.1"
androidxcore = "1.10.0"
ktlint = "0.43.2"
ktlintGradle = "11.3.1"
ktlintGradle = "11.3.2"
detekt = "1.22.0"
preferences = "1.2.0"
media3 = "1.0.1"
@ -16,25 +16,26 @@ materialDesign = "1.8.0"
constraintLayout = "2.1.4"
multidex = "2.0.1"
room = "2.5.1"
kotlin = "1.8.20"
kotlinxCoroutines = "1.6.4"
kotlinxGuava = "1.6.4"
kotlin = "1.8.21"
kotlinxCoroutines = "1.7.0"
kotlinxGuava = "1.7.0"
viewModelKtx = "2.6.1"
swipeRefresh = "1.1.0"
retrofit = "2.9.0"
jackson = "2.14.2"
## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
jackson = "2.13.5"
okhttp = "4.10.0"
koin = "3.3.2"
picasso = "2.8"
junit4 = "4.13.2"
junit5 = "5.9.2"
mockito = "5.2.0"
junit5 = "5.9.3"
mockito = "5.3.1"
mockitoKotlin = "4.1.0"
kluent = "1.72"
kluent = "1.73"
apacheCodecs = "1.15"
robolectric = "4.10"
robolectric = "4.10.2"
timber = "5.0.1"
fastScroll = "2.0.1"
colorPicker = "2.2.4"
@ -95,7 +96,6 @@ junitVintage = { module = "org.junit.vintage:junit-vintage-engine", v
kotlinJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
mockitoKotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" }
mockitoInline = { module = "org.mockito:mockito-inline", version.ref = "mockito" }
kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" }
kluentAndroid = { module = "org.amshove.kluent:kluent-android", version.ref = "kluent" }
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }

View File

@ -9,8 +9,8 @@ android {
defaultConfig {
applicationId "org.moire.ultrasonic"
versionCode 115
versionName "4.3.2"
versionCode 116
versionName "4.3.3"
minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk
@ -137,7 +137,6 @@ dependencies {
testImplementation libs.kotlinJunit
testImplementation libs.kluent
testImplementation libs.mockito
testImplementation libs.mockitoInline
testImplementation libs.mockitoKotlin
testImplementation libs.robolectric

View File

@ -46,7 +46,7 @@ public class GenreAdapter extends ArrayAdapter<Genre> implements SectionIndexer
private final Object[] sections;
private final Integer[] positions;
public GenreAdapter(Context context, List<Genre> genres)
public GenreAdapter(@NonNull Context context, List<Genre> genres)
{
super(context, R.layout.list_item_generic, genres);

View File

@ -15,17 +15,17 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.media3.common.HeartRating
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewDelegate
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.LayoutType
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
import timber.log.Timber
/**
* Creates a Row in a RecyclerView which contains the details of an Album
@ -112,28 +112,14 @@ open class AlbumRowDelegate(
private fun onStarClick(entry: Album, star: ImageView) {
entry.starred = !entry.starred
star.setImageResource(if (entry.starred) starDrawable else starHollowDrawable)
val musicService = getMusicService()
Thread {
val useId3 = shouldUseId3Tags
try {
if (entry.starred) {
musicService.star(
if (!useId3) entry.id else null,
if (useId3) entry.id else null,
null
RxBus.ratingSubmitter.onNext(
RatingUpdate(
entry.id,
HeartRating(entry.starred)
)
} else {
musicService.unstar(
if (!useId3) entry.id else null,
if (useId3) entry.id else null,
null
)
}
} catch (all: Exception) {
Timber.e(all)
}
}.start()
}
override fun onCreateViewHolder(context: Context, parent: ViewGroup): ListViewHolder {
return when (layoutType) {

View File

@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.MutableLiveData
import androidx.media3.common.HeartRating
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.progressindicator.CircularProgressIndicator
import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -19,10 +20,10 @@ import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.DownloadState
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.util.Settings
@ -81,7 +82,6 @@ class TrackViewHolder(val view: View) :
draggable: Boolean,
isSelected: Boolean = false
) {
// Timber.v("Setting song")
val useFiveStarRating = Settings.useFiveStarRating
entry = song
@ -118,9 +118,9 @@ class TrackViewHolder(val view: View) :
}
if (useFiveStarRating) {
setFiveStars(entry?.userRating ?: 0)
updateFiveStars(entry?.userRating ?: 0)
} else {
setSingleStar(entry!!.starred)
updateSingleStar(entry!!.starred)
}
if (song.isVideo) {
@ -165,48 +165,32 @@ class TrackViewHolder(val view: View) :
}
}
private fun setupStarButtons(song: Track, useFiveStarRating: Boolean) {
private fun setupStarButtons(track: Track, useFiveStarRating: Boolean) {
if (useFiveStarRating) {
// Hide single star
star.isGone = true
rating.isVisible = true
val rating = if (song.userRating == null) 0 else song.userRating!!
setFiveStars(rating)
val rating = if (track.userRating == null) 0 else track.userRating!!
updateFiveStars(rating)
// Five star rating has no click handler because in the
// track view theres not enough space
} else {
star.isVisible = true
rating.isGone = true
setSingleStar(song.starred)
updateSingleStar(track.starred)
star.setOnClickListener {
val isStarred = song.starred
val id = song.id
if (!isStarred) {
star.setImageResource(R.drawable.ic_star_full)
song.starred = true
} else {
star.setImageResource(R.drawable.ic_star_hollow)
song.starred = false
}
// Should this be done here ?
Thread {
val musicService = MusicServiceFactory.getMusicService()
try {
if (!isStarred) {
musicService.star(id, null, null)
} else {
musicService.unstar(id, null, null)
}
} catch (all: Exception) {
Timber.e(all)
}
}.start()
track.starred = !track.starred
updateSingleStar(track.starred)
RxBus.ratingSubmitter.onNext(
RatingUpdate(track.id, HeartRating(track.starred))
)
}
}
}
@Suppress("MagicNumber")
private fun setFiveStars(rating: Int) {
private fun updateFiveStars(rating: Int) {
fiveStar1.setImageResource(
if (rating > 0) R.drawable.ic_star_full else R.drawable.ic_star_hollow
)
@ -224,7 +208,7 @@ class TrackViewHolder(val view: View) :
)
}
private fun setSingleStar(starred: Boolean) {
private fun updateSingleStar(starred: Boolean) {
if (starred) {
star.setImageResource(R.drawable.ic_star_full)
} else {

View File

@ -0,0 +1,16 @@
/*
* RatingUpdate.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.data
import androidx.media3.common.Rating
data class RatingUpdate(
val id: String,
val rating: Rating,
val success: Boolean? = null
)

View File

@ -35,11 +35,15 @@ import android.widget.TextView
import android.widget.Toast
import android.widget.ViewFlipper
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.SessionResult
import androidx.media3.common.StarRating
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
@ -49,8 +53,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.text.DateFormat
import java.text.SimpleDateFormat
@ -76,6 +78,7 @@ import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
@ -98,7 +101,7 @@ import timber.log.Timber
/**
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
* TODO: Add timeline lister -> updateProgressBar().
*
*/
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
class PlayerFragment :
@ -132,7 +135,6 @@ class PlayerFragment :
// Views and UI Elements
private lateinit var playlistNameView: EditText
private lateinit var starMenuItem: MenuItem
private lateinit var fiveStar1ImageView: ImageView
private lateinit var fiveStar2ImageView: ImageView
private lateinit var fiveStar3ImageView: ImageView
@ -230,7 +232,13 @@ class PlayerFragment :
height = size.y
}
setHasOptionsMenu(true)
// Register our options menu
(requireActivity() as MenuHost).addMenuProvider(
menuProvider,
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
useFiveStarRating = Settings.useFiveStarRating
swipeDistance = (width + height) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100
swipeVelocity = swipeDistance
@ -467,23 +475,55 @@ class PlayerFragment :
super.onDestroyView()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.nowplaying, menu)
super.onCreateOptionsMenu(menu, inflater)
private val menuProvider: MenuProvider = object : MenuProvider {
override fun onPrepareMenu(menu: Menu) {
setupOptionsMenu(menu)
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.nowplaying, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return menuItemSelected(menuItem.itemId, currentSong)
}
}
@Suppress("ComplexMethod", "LongMethod", "NestedBlockDepth")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
fun setupOptionsMenu(menu: Menu) {
// Seems there is nothing like ViewBinding for Menus
val screenOption = menu.findItem(R.id.menu_item_screen_on_off)
val goToAlbum = menu.findItem(R.id.menu_show_album)
val goToArtist = menu.findItem(R.id.menu_show_artist)
val jukeboxOption = menu.findItem(R.id.menu_item_jukebox)
val equalizerMenuItem = menu.findItem(R.id.menu_item_equalizer)
val shareMenuItem = menu.findItem(R.id.menu_item_share)
val shareSongMenuItem = menu.findItem(R.id.menu_item_share_song)
starMenuItem = menu.findItem(R.id.menu_item_star)
val starMenuItem = menu.findItem(R.id.menu_item_star)
val bookmarkMenuItem = menu.findItem(R.id.menu_item_bookmark_set)
val bookmarkRemoveMenuItem = menu.findItem(R.id.menu_item_bookmark_delete)
// Listen to rating changes and update the UI
rxBusSubscription += RxBus.ratingPublishedObservable.subscribe { update ->
// Ignore updates which are not for the current song
if (update.id != currentSong?.id) return@subscribe
// Ensure UI thread
launch {
if (update.success == true && update.rating is HeartRating) {
if (update.rating.isHeart) {
starMenuItem.setIcon(fullStar)
} else {
starMenuItem.setIcon(hollowStar)
}
} else if (update.success == false) {
Toast.makeText(context, "Setting rating failed", Toast.LENGTH_SHORT)
.show()
}
}
}
if (isOffline()) {
if (shareMenuItem != null) {
shareMenuItem.isVisible = false
@ -500,6 +540,7 @@ class PlayerFragment :
equalizerMenuItem.isEnabled = isEqualizerAvailable
equalizerMenuItem.isVisible = isEqualizerAvailable
}
val mediaPlayerController = mediaPlayerController
val track = mediaPlayerController.currentMediaItem?.toTrack()
@ -512,9 +553,13 @@ class PlayerFragment :
if (currentSong != null) {
starMenuItem.setIcon(if (currentSong!!.starred) fullStar else hollowStar)
shareSongMenuItem.isVisible = true
goToAlbum.isVisible = true
goToArtist.isVisible = true
} else {
starMenuItem.setIcon(hollowStar)
shareSongMenuItem.isVisible = false
goToAlbum.isVisible = false
goToArtist.isVisible = false
}
if (mediaPlayerController.keepScreenOn) {
@ -555,10 +600,6 @@ class PlayerFragment :
return popup
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return menuItemSelected(item.itemId, currentSong) || super.onOptionsItemSelected(item)
}
private fun onContextMenuItemSelected(
menuItem: MenuItem,
item: MusicDirectory.Child
@ -655,31 +696,11 @@ class PlayerFragment :
}
R.id.menu_item_star -> {
if (track == null) return true
track.starred = !track.starred
val isStarred = track.starred
mediaPlayerController.toggleSongStarred()?.let {
Futures.addCallback(
it,
object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult?) {
if (isStarred) {
starMenuItem.setIcon(hollowStar)
track.starred = false
} else {
starMenuItem.setIcon(fullStar)
track.starred = true
}
}
override fun onFailure(t: Throwable) {
Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT)
.show()
}
},
this.executorService
RxBus.ratingSubmitter.onNext(
RatingUpdate(track.id, HeartRating(track.starred))
)
}
return true
}
@ -1072,8 +1093,6 @@ class PlayerFragment :
}
}
// TODO: It would be a lot nicer if MediaPlayerController would send an event
// when this is necessary instead of updating every time
updateSongRating()
nextButton.isEnabled = mediaPlayerController.canSeekToNext()
@ -1082,7 +1101,6 @@ class PlayerFragment :
@Synchronized
private fun updateSeekBar() {
Timber.i("Calling updateSeekBar")
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
val duration: Int = mediaPlayerController.playerDuration
@ -1233,11 +1251,7 @@ class PlayerFragment :
}
private fun updateSongRating() {
var rating = 0
if (currentSong?.userRating != null) {
rating = currentSong!!.userRating!!
}
val rating = currentSong?.userRating ?: 0
fiveStar1ImageView.setImageResource(if (rating > 0) fullStar else hollowStar)
fiveStar2ImageView.setImageResource(if (rating > 1) fullStar else hollowStar)
@ -1248,8 +1262,15 @@ class PlayerFragment :
private fun setSongRating(rating: Int) {
if (currentSong == null) return
currentSong?.userRating = rating
updateSongRating()
mediaPlayerController.setSongRating(rating)
RxBus.ratingSubmitter.onNext(
RatingUpdate(
currentSong!!.id,
StarRating(5, rating.toFloat())
)
)
}
@SuppressLint("InflateParams")

View File

@ -401,6 +401,8 @@ open class TrackCollectionFragment(
) {
// We are coming back from unknown context
// and need to ensure Main Thread in order to manipulate the UI
// If view is null, our view was disposed in the meantime
if (view == null) return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
val multipleSelection = viewAdapter.hasMultipleSelection()

View File

@ -102,7 +102,9 @@ class SelectGenreFragment : Fragment() {
override fun done(result: List<Genre>) {
emptyView!!.isVisible = result.isEmpty()
genreListView!!.adapter = GenreAdapter(context, result)
if (context != null) {
genreListView!!.adapter = GenreAdapter(context!!, result)
}
}
}
task.execute()

View File

@ -240,6 +240,8 @@ class ImageLoader(
} finally {
inputStream.safeClose()
}
} catch (all: Exception) {
Timber.w(all)
} finally {
cacheInProgress.remove(file)?.countDown()
}

View File

@ -26,8 +26,6 @@ import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import androidx.media3.session.SessionResult.RESULT_ERROR_BAD_VALUE
import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN
import androidx.media3.session.SessionResult.RESULT_SUCCESS
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.FutureCallback
@ -44,12 +42,14 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RatingManager
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
@ -306,22 +306,19 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
rating: Rating
): ListenableFuture<SessionResult> {
return serviceScope.future {
if (rating is HeartRating) {
try {
if (rating.isHeart) {
musicService.star(mediaId, null, null)
} else {
musicService.unstar(mediaId, null, null)
}
} catch (all: Exception) {
Timber.e(all)
// TODO: Better handle exception
return@future SessionResult(RESULT_ERROR_UNKNOWN)
}
Timber.i(controller.packageName)
// This function even though its declared in AutoMediaBrowserCallback.kt is
// actually called every time we set the rating on an MediaItem.
// To avoid an event loop it does not emit a RatingUpdate event,
// but calls the Manager directly
RatingManager.instance.submitRating(
RatingUpdate(
id = mediaId,
rating = rating
)
)
return@future SessionResult(RESULT_SUCCESS)
}
return@future SessionResult(RESULT_ERROR_BAD_VALUE)
}
}
/*

View File

@ -10,7 +10,6 @@ import android.content.ComponentName
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.annotation.IntRange
import androidx.media3.common.C
import androidx.media3.common.HeartRating
@ -21,28 +20,26 @@ import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT
import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
import androidx.media3.common.Player.REPEAT_MODE_OFF
import androidx.media3.common.Rating
import androidx.media3.common.StarRating
import androidx.media3.common.Timeline
import androidx.media3.session.MediaController
import androidx.media3.session.SessionResult
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.playback.PlaybackService
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.setPin
@ -231,11 +228,21 @@ class MediaPlayerController(
clear(false)
onDestroy()
}
rxBusSubscription += RxBus.stopServiceCommandObservable.subscribe {
clear(false)
onDestroy()
}
rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe {
// Ensure correct thread
mainScope.launch {
// This deals only with the current track!
if (it.id != currentMediaItem?.toTrack()?.id) return@launch
setRating(it.rating)
}
}
created = true
Timber.i("MediaPlayerController started")
}
@ -701,52 +708,49 @@ class MediaPlayerController(
controller?.volume = volume
}
fun toggleSongStarred(): ListenableFuture<SessionResult>? {
if (currentMediaItem == null) return null
val song = currentMediaItem!!.toTrack()
return (controller as? MediaController)?.setRating(
HeartRating(!song.starred)
)?.let {
Futures.addCallback(
it,
object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult?) {
// Trigger an update
// TODO Update Metadata of MediaItem...
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
song.starred = !song.starred
}
override fun onFailure(t: Throwable) {
Toast.makeText(
context,
"There was an error updating the rating",
Toast.LENGTH_SHORT
).show()
}
},
MainThreadExecutor()
)
it
/*
* Sets the rating of the current track
*/
fun setRating(rating: Rating) {
if (controller is MediaController) {
(controller as MediaController).setRating(rating)
}
}
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
fun setSongRating(rating: Int) {
if (!Settings.useFiveStarRating) return
/*
* This legacy function simply emits a rating update,
* which will then be processed by both the RatingManager as well as the controller
*/
fun legacyToggleStar() {
if (currentMediaItem == null) return
val song = currentMediaItem!!.toTrack()
song.userRating = rating
mainScope.launch {
withContext(Dispatchers.IO) {
try {
getMusicService().setRating(song.id, rating)
} catch (e: Exception) {
Timber.e(e)
}
}
val track = currentMediaItem!!.toTrack()
track.starred = !track.starred
val rating = HeartRating(track.starred)
RxBus.ratingSubmitter.onNext(
RatingUpdate(
track.id,
rating
)
)
}
/*
* This legacy function simply emits a rating update,
* which will then be processed by both the RatingManager as well as the controller
*/
fun legacySetRating(num: Int) {
if (currentMediaItem == null) return
val track = currentMediaItem!!.toTrack()
track.userRating = num
val rating = StarRating(5, num.toFloat())
RxBus.ratingSubmitter.onNext(
RatingUpdate(
track.id,
rating
)
)
}
val currentMediaItem: MediaItem?
@ -764,7 +768,6 @@ class MediaPlayerController(
* Loops over the timeline windows to find the entry which matches the given closure.
*
* @param searchClosure Determines the condition which the searched for window needs to match.
* @param timeline the timeline to search in.
* @return the index of the window that satisfies the search condition,
* or [C.INDEX_UNSET] if not found.
*/

View File

@ -30,6 +30,7 @@ import timber.log.Timber
* This class is responsible for handling received events for the Media Player implementation
*/
class MediaPlayerLifecycleSupport : KoinComponent {
private lateinit var ratingManager: RatingManager
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
private val mediaPlayerController by inject<MediaPlayerController>()
private val imageLoaderProvider: ImageLoaderProvider by inject()
@ -71,6 +72,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
CacheCleaner().clean()
created = true
ratingManager = RatingManager.instance
Timber.i("LifecycleSupport created")
}
@ -187,12 +189,12 @@ class MediaPlayerLifecycleSupport : KoinComponent {
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
KeyEvent.KEYCODE_3 -> mediaPlayerController.setSongRating(3)
KeyEvent.KEYCODE_4 -> mediaPlayerController.setSongRating(4)
KeyEvent.KEYCODE_5 -> mediaPlayerController.setSongRating(5)
KeyEvent.KEYCODE_STAR -> mediaPlayerController.toggleSongStarred()
KeyEvent.KEYCODE_1 -> mediaPlayerController.legacySetRating(1)
KeyEvent.KEYCODE_2 -> mediaPlayerController.legacySetRating(2)
KeyEvent.KEYCODE_3 -> mediaPlayerController.legacySetRating(3)
KeyEvent.KEYCODE_4 -> mediaPlayerController.legacySetRating(4)
KeyEvent.KEYCODE_5 -> mediaPlayerController.legacySetRating(5)
KeyEvent.KEYCODE_STAR -> mediaPlayerController.legacyToggleStar()
else -> {
}
}

View File

@ -39,10 +39,10 @@ interface MusicService {
fun getGenres(refresh: Boolean): List<Genre>
@Throws(Exception::class)
fun star(id: String?, albumId: String?, artistId: String?)
fun star(id: String?, albumId: String? = null, artistId: String? = null)
@Throws(Exception::class)
fun unstar(id: String?, albumId: String?, artistId: String?)
fun unstar(id: String?, albumId: String? = null, artistId: String? = null)
@Throws(Exception::class)
fun setRating(id: String, rating: Int)

View File

@ -0,0 +1,87 @@
/*
* RatingManager.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import androidx.media3.common.HeartRating
import androidx.media3.common.StarRating
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import timber.log.Timber
/*
* This class subscribes to RatingEvents and submits them to the server.
* In the future it could be extended to store the ratings when offline
* and submit them when back online.
* Only the manager should publish RatingSubmitted events
*/
class RatingManager : CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
var lastUpdate: RatingUpdate? = null
init {
rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe {
submitRating(it)
}
}
internal fun submitRating(update: RatingUpdate) {
// Don't submit the same rating twice
if (update.id == lastUpdate?.id && update.rating == lastUpdate?.rating) return
val service = getMusicService()
val id = update.id
Timber.i("Submitting rating to server: ${update.rating} for $id")
if (update.rating is HeartRating) {
launch {
var success = false
withContext(Dispatchers.IO) {
try {
if (update.rating.isHeart) service.star(id)
else service.unstar(id)
success = true
} catch (all: Exception) {
Timber.e(all)
}
}
RxBus.ratingPublished.onNext(
update.copy(success = success)
)
}
} else if (update.rating is StarRating) {
launch {
var success = false
withContext(Dispatchers.IO) {
try {
getMusicService().setRating(id, update.rating.starRating.toInt())
success = true
} catch (all: Exception) {
Timber.e(all)
}
}
RxBus.ratingPublished.onNext(
update.copy(success = success)
)
}
}
lastUpdate = update
}
companion object {
val instance: RatingManager by lazy {
RatingManager()
}
}
}

View File

@ -7,6 +7,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import java.util.concurrent.TimeUnit
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Track
class RxBus {
@ -75,6 +76,18 @@ class RxBus {
val trackDownloadStateObservable: Observable<TrackDownloadState> =
trackDownloadStatePublisher.observeOn(mainThread())
// Sends a RatingUpdate which was just triggered by the user
val ratingSubmitter: PublishSubject<RatingUpdate> =
PublishSubject.create()
val ratingSubmitterObservable: Observable<RatingUpdate> =
ratingSubmitter
// Sends a RatingUpdate which was successfully submitted to the server or database
val ratingPublished: PublishSubject<RatingUpdate> =
PublishSubject.create()
val ratingPublishedObservable: Observable<RatingUpdate> =
ratingPublished
// Commands
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
PublishSubject.create()

View File

@ -7,11 +7,14 @@
package org.moire.ultrasonic.subsonic
import android.os.Handler
import android.os.Looper
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import java.util.Collections
import java.util.LinkedList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -28,6 +31,7 @@ import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* Retrieves a list of songs and adds them to the now playing list
@ -39,6 +43,16 @@ class DownloadHandler(
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private val maxSongs = 500
/**
* Exception Handler for Coroutines
*/
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
Handler(Looper.getMainLooper()).post {
Timber.w(exception)
}
}
// TODO: Use coroutine here (with proper exception handler)
fun download(
fragment: Fragment,
append: Boolean,
@ -210,7 +224,7 @@ class DownloadHandler(
isArtist: Boolean
) {
// Launch the Job
val job = launch {
val job = launch(exceptionHandler) {
val songs: MutableList<Track> =
getTracksFromServer(isArtist, id, isDirectory, name, isShare)

View File

@ -10,7 +10,9 @@ package org.moire.ultrasonic.util
import android.app.Activity
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.lang.ref.WeakReference
import org.moire.ultrasonic.R
import timber.log.Timber
/*
* InfoDialog can be used to show some information to the user. Typically it cannot be cancelled,
@ -19,24 +21,30 @@ import org.moire.ultrasonic.R
open class InfoDialog(
context: Context,
message: CharSequence?,
private val activity: Activity? = null,
activity: Activity? = null,
private val finishActivityOnClose: Boolean = false
) {
open var builder: MaterialAlertDialogBuilder = Builder(activity ?: context, message)
private val activityRef: WeakReference<Activity?> = WeakReference(activity)
open var builder: MaterialAlertDialogBuilder = Builder(activityRef.get() ?: context, message)
fun show() {
builder.setOnCancelListener {
if (finishActivityOnClose) {
activity!!.finish()
activityRef.get()?.finish()
}
}
builder.setPositiveButton(R.string.common_ok) { _, _ ->
if (finishActivityOnClose) {
activity!!.finish()
activityRef.get()?.finish()
}
}
// If the app was put into the background in the meantime this would fail
try {
builder.create().show()
} catch (all: Exception) {
Timber.w(all, "Failed to create dialog")
}
}
class Builder(context: Context) : MaterialAlertDialogBuilder(context) {
@ -93,7 +101,6 @@ class ConfirmationDialog(
activity: Activity? = null,
finishActivityOnClose: Boolean = false
) : InfoDialog(context, message, activity, finishActivityOnClose) {
override var builder: MaterialAlertDialogBuilder = Builder(activity ?: context, message)
class Builder(context: Context) : MaterialAlertDialogBuilder(context) {

View File

@ -181,7 +181,7 @@ object Settings {
var firstRunExecuted by BooleanSetting(getKey(R.string.setting_key_first_run_executed), false)
val shouldShowArtistPicture
by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), false)
by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), true)
@JvmStatic
var chatRefreshInterval by StringIntSetting(

View File

@ -273,7 +273,7 @@ class StorageFile(
}
private fun getStorageFileForParentDirectory(path: String): StorageFile? {
val parentPath = FileUtil.getParentPath(path)!!
val parentPath = FileUtil.getParentPath(path) ?: return null
if (storageFilePathDictionary.containsKey(parentPath))
return storageFilePathDictionary[parentPath]!!
if (notExistingPathDictionary.contains(parentPath)) return null

View File

@ -133,6 +133,8 @@ object Util {
@JvmStatic
@SuppressLint("ShowToast") // Invalid warning
fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) {
// If called after doing some background processing, our context might have expired!
try {
if (toast == null) {
toast = Toast.makeText(
context,
@ -146,6 +148,9 @@ object Util {
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
}
toast!!.show()
} catch (_: Exception) {
// Ignore
}
}
/**

View File

@ -15,6 +15,13 @@
app:showAsAction="ifRoom|withText"
a:title="@string/download.menu_star"/>
<item
a:id="@+id/menu_show_artist"
a:title="@string/download.menu_show_artist"/>
<item
a:id="@+id/menu_show_album"
a:title="@string/download.menu_show_album"/>
<item
a:id="@+id/menu_item_share_song"
a:icon="@drawable/ic_menu_share"

View File

@ -199,7 +199,7 @@
<string name="settings.disc_sort_summary">按光盘编号和曲目编号对歌曲列表进行排序</string>
<string name="settings.display_bitrate">展示比特率和文件后缀</string>
<string name="settings.display_bitrate_summary">在艺术家姓名后追加比特率和文件后缀</string>
<string name="settings.download_transition">正在播放</string>
<string name="settings.download_transition">播放时显示正在播放界面</string>
<string name="settings.hide_media_summary">隐藏来自其他应用的音乐。</string>
<string name="settings.hide_media_title">隐藏其他来源</string>
<string name="settings.hide_media_toast">在安卓系统下次扫描音乐时生效。</string>

View File

@ -213,4 +213,24 @@
<string name="music_library.label_offline">離線媒體</string>
<string name="playlist.update_info">更新資訊</string>
<string name="download.jukebox_not_authorized">不允許遠端控制。請在您在 Subsonic 伺服器上的 <b>使用者 &gt; 設定</b> 中啟用點唱機模式。</string>
<string name="settings.directory_cache_time">目錄快取時間</string>
<string name="settings.disc_sort_summary">依光碟編號和曲目編號對歌曲清單進行排序</string>
<string name="settings.clear_search_history">清空搜尋記錄</string>
<string name="settings.chat_refresh">聊天訊息刷新時間間隔</string>
<string name="settings.display_bitrate_summary">在藝術家名稱後附加位元速率和檔案後綴</string>
<string name="settings.appearance_title">外觀</string>
<string name="settings.clear_bookmark_summary">歌曲播放完畢後清除書籤</string>
<string name="settings.disc_sort">依光碟排序歌曲</string>
<string name="select_album.no_sdcard">錯誤:無可用的 SD 卡。</string>
<string name="select_album.no_network">警告:目前沒有可用的網路。
\n 如果您要使用行動數據,您需要在設定中允許使用計量付費網路連線下載。</string>
<string name="select_album.play_all">播放全部</string>
<string name="select_artist.all_folders">所有資料夾</string>
<string name="select_artist.folder">選擇資料夾</string>
<string name="select_playlist.empty">伺服器上沒有已保存的播放清單</string>
<string name="settings.hide_media_title">隱藏其他來源</string>
<string name="settings.hide_media_summary">隱藏來自其他應用程式的音樂檔案。</string>
<string name="settings.hide_media_toast">在 Android 系統下次掃描裝置內音樂時生效。</string>
<string name="settings.download_transition">播放時顯示正在播放介面</string>
<string name="settings.download_transition_summary">在媒體庫介面開始播放後切換到正在播放介面</string>
</resources>

View File

@ -71,7 +71,7 @@
<string name="download.menu_save">Save Playlist</string>
<string name="download.menu_screen_off">Screen Off</string>
<string name="download.menu_screen_on">Screen On</string>
<string name="download.menu_show_album">Show Album</string>
<string name="download.menu_show_album">Go to Album</string>
<string name="download.menu_shuffle">Shuffle</string>
<string name="download.menu_shuffle_on">Shuffle mode enabled</string>
<string name="download.menu_shuffle_off">Shuffle mode disabled</string>
@ -367,7 +367,7 @@
<string name="share_default_greeting">Check out this music I shared from %s</string>
<string name="share_via">Share songs via</string>
<string name="menu.share">Share</string>
<string name="download.menu_show_artist">Show Artist</string>
<string name="download.menu_show_artist">Go to Artist</string>
<string name="albumArt">Album artwork</string>
<string name="common_multiple_years">Multiple Years</string>
<string name="settings.show_confirmation_dialog">Show confirmation dialog</string>