diff --git a/dependencies.gradle b/dependencies.gradle index 26b0bbb4..24bb2e52 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -43,6 +43,8 @@ ext.versions = [ timber : "4.7.1", fastScroll : "2.0.1", colorPicker : "2.2.3", + rxJava : "3.1.2", + rxAndroid : "3.0.0", ] ext.gradlePlugins = [ @@ -91,6 +93,8 @@ ext.other = [ fastScroll : "com.simplecityapps:recyclerview-fastscroll:$versions.fastScroll", sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView", colorPickerView : "com.github.skydoves:colorpickerview:$versions.colorPicker", + rxJava : "io.reactivex.rxjava3:rxjava:$versions.rxJava", + rxAndroid : "io.reactivex.rxjava3:rxandroid:$versions.rxAndroid", ] ext.testing = [ diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 7835f863..27d06bc3 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -106,6 +106,8 @@ dependencies { implementation other.fastScroll implementation other.sortListView implementation other.colorPickerView + implementation other.rxJava + implementation other.rxAndroid kapt androidSupport.room diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/NowPlayingFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/NowPlayingFragment.java deleted file mode 100644 index 4da43426..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/NowPlayingFragment.java +++ /dev/null @@ -1,194 +0,0 @@ -package org.moire.ultrasonic.fragment; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.navigation.Navigation; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.domain.MusicDirectory; -import org.moire.ultrasonic.domain.PlayerState; -import org.moire.ultrasonic.service.DownloadFile; -import org.moire.ultrasonic.service.MediaPlayerController; -import org.moire.ultrasonic.subsonic.ImageLoaderProvider; -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.NowPlayingEventDistributor; -import org.moire.ultrasonic.util.NowPlayingEventListener; -import org.moire.ultrasonic.util.Settings; -import org.moire.ultrasonic.util.Util; - -import kotlin.Lazy; -import timber.log.Timber; - -import static org.koin.java.KoinJavaComponent.inject; - - -/** - * Contains the mini-now playing information box displayed at the bottom of the screen - */ -public class NowPlayingFragment extends Fragment { - - private static final int MIN_DISTANCE = 30; - private float downX; - private float downY; - ImageView playButton; - ImageView nowPlayingAlbumArtImage; - TextView nowPlayingTrack; - TextView nowPlayingArtist; - - private final Lazy mediaPlayerControllerLazy = inject(MediaPlayerController.class); - private final Lazy imageLoader = inject(ImageLoaderProvider.class); - private final Lazy nowPlayingEventDistributor = inject(NowPlayingEventDistributor.class); - private NowPlayingEventListener nowPlayingEventListener; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - Util.applyTheme(this.getContext()); - super.onCreate(savedInstanceState); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - return inflater.inflate(R.layout.now_playing, container, false); - } - - @Override - public void onViewCreated(@NonNull final View view, @Nullable Bundle savedInstanceState) { - - playButton = view.findViewById(R.id.now_playing_control_play); - nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image); - nowPlayingTrack = view.findViewById(R.id.now_playing_trackname); - nowPlayingArtist = view.findViewById(R.id.now_playing_artist); - - nowPlayingEventListener = new NowPlayingEventListener() { - @Override - public void onDismissNowPlaying() { } - @Override - public void onHideNowPlaying() { } - @Override - public void onShowNowPlaying() { update(); } - }; - - nowPlayingEventDistributor.getValue().subscribe(nowPlayingEventListener); - } - - @Override - public void onResume() { - super.onResume(); - update(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - nowPlayingEventDistributor.getValue().unsubscribe(nowPlayingEventListener); - } - - private void update() { - try - { - PlayerState playerState = mediaPlayerControllerLazy.getValue().getPlayerState(); - if (playerState == PlayerState.PAUSED) { - playButton.setImageDrawable(Util.getDrawableFromAttribute(getContext(), R.attr.media_play)); - } else if (playerState == PlayerState.STARTED) { - playButton.setImageDrawable(Util.getDrawableFromAttribute(getContext(), R.attr.media_pause)); - } - - DownloadFile file = mediaPlayerControllerLazy.getValue().getCurrentPlaying(); - if (file != null) { - final MusicDirectory.Entry song = file.getSong(); - String title = song.getTitle(); - String artist = song.getArtist(); - - imageLoader.getValue().getImageLoader().loadImage(nowPlayingAlbumArtImage, song, false, Util.getNotificationImageSize(getContext())); - nowPlayingTrack.setText(title); - nowPlayingArtist.setText(artist); - - nowPlayingAlbumArtImage.setOnClickListener(v -> { - Bundle bundle = new Bundle(); - - if (Settings.getShouldUseId3Tags()) { - bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, true); - bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.getAlbumId()); - } else { - bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, false); - bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.getParent()); - } - - bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.getAlbum()); - bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.getAlbum()); - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(R.id.trackCollectionFragment, bundle); - }); - } - - getView().setOnTouchListener((v, event) -> handleOnTouch(event)); - - // This empty onClickListener is necessary for the onTouchListener to work - getView().setOnClickListener(v -> {}); - - playButton.setOnClickListener(v -> mediaPlayerControllerLazy.getValue().togglePlayPause()); - } - catch (Exception x) { - Timber.w(x, "Failed to get notification cover art"); - } - } - - private boolean handleOnTouch(MotionEvent event) { - switch (event.getAction()) - { - case MotionEvent.ACTION_DOWN: - { - downX = event.getX(); - downY = event.getY(); - return false; - } - case MotionEvent.ACTION_UP: - { - float upX = event.getX(); - float upY = event.getY(); - - float deltaX = downX - upX; - float deltaY = downY - upY; - - if (Math.abs(deltaX) > MIN_DISTANCE) - { - // left or right - if (deltaX < 0) - { - mediaPlayerControllerLazy.getValue().previous(); - return false; - } - if (deltaX > 0) - { - mediaPlayerControllerLazy.getValue().next(); - return false; - } - } - else if (Math.abs(deltaY) > MIN_DISTANCE) - { - if (deltaY < 0) - { - nowPlayingEventDistributor.getValue().raiseNowPlayingDismissedEvent(); - return false; - } - if (deltaY > 0) - { - return false; - } - } - Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(R.id.playerFragment); - return false; - } - } - return false; - } -} 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 a92bfcc4..62040423 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -31,6 +31,7 @@ import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager import com.google.android.material.button.MaterialButton import com.google.android.material.navigation.NavigationView +import io.reactivex.rxjava3.disposables.Disposable import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.R @@ -43,17 +44,14 @@ import org.moire.ultrasonic.provider.SearchSuggestionProvider import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport +import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil -import org.moire.ultrasonic.util.NowPlayingEventDistributor -import org.moire.ultrasonic.util.NowPlayingEventListener import org.moire.ultrasonic.util.PermissionUtil import org.moire.ultrasonic.util.ServerColor import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.SubsonicUncaughtExceptionHandler -import org.moire.ultrasonic.util.ThemeChangedEventDistributor -import org.moire.ultrasonic.util.ThemeChangedEventListener import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -74,15 +72,13 @@ class NavigationActivity : AppCompatActivity() { private var headerBackgroundImage: ImageView? = null private lateinit var appBarConfiguration: AppBarConfiguration - private lateinit var nowPlayingEventListener: NowPlayingEventListener - private lateinit var themeChangedEventListener: ThemeChangedEventListener + private var themeChangedEventSubscription: Disposable? = null + private var playerStateSubscription: Disposable? = null private val serverSettingsModel: ServerSettingsModel by viewModel() private val lifecycleSupport: MediaPlayerLifecycleSupport by inject() private val mediaPlayerController: MediaPlayerController by inject() private val imageLoaderProvider: ImageLoaderProvider by inject() - private val nowPlayingEventDistributor: NowPlayingEventDistributor by inject() - private val themeChangedEventDistributor: ThemeChangedEventDistributor by inject() private val permissionUtil: PermissionUtil by inject() private val activeServerProvider: ActiveServerProvider by inject() private val serverRepository: ServerSettingDao by inject() @@ -169,28 +165,22 @@ class NavigationActivity : AppCompatActivity() { showWelcomeDialog() } - nowPlayingEventListener = object : NowPlayingEventListener { - override fun onDismissNowPlaying() { - nowPlayingHidden = true - hideNowPlaying() - } + RxBus.dismissNowPlayingCommandObservable.subscribe { + nowPlayingHidden = true + hideNowPlaying() + } - override fun onHideNowPlaying() { - hideNowPlaying() - } - - override fun onShowNowPlaying() { + playerStateSubscription = RxBus.playerStateObservable.subscribe { + if (it.state === PlayerState.STARTED || it.state === PlayerState.PAUSED) showNowPlaying() - } + else + hideNowPlaying() } - themeChangedEventListener = object : ThemeChangedEventListener { - override fun onThemeChanged() { recreate() } + themeChangedEventSubscription = RxBus.themeChangedEventObservable.subscribe { + recreate() } - nowPlayingEventDistributor.subscribe(nowPlayingEventListener) - themeChangedEventDistributor.subscribe(themeChangedEventListener) - serverRepository.liveServerCount().observe( this, { count -> @@ -237,8 +227,8 @@ class NavigationActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() - nowPlayingEventDistributor.unsubscribe(nowPlayingEventListener) - themeChangedEventDistributor.unsubscribe(themeChangedEventListener) + themeChangedEventSubscription?.dispose() + playerStateSubscription?.dispose() imageLoaderProvider.clearImageLoader() permissionUtil.onForegroundApplicationStopped() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index e44e0774..cecdce6e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -4,11 +4,8 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider -import org.moire.ultrasonic.util.MediaSessionEventDistributor import org.moire.ultrasonic.util.MediaSessionHandler -import org.moire.ultrasonic.util.NowPlayingEventDistributor import org.moire.ultrasonic.util.PermissionUtil -import org.moire.ultrasonic.util.ThemeChangedEventDistributor /** * This Koin module contains the registration of general classes needed for Ultrasonic @@ -17,8 +14,5 @@ val applicationModule = module { single { ActiveServerProvider(get()) } single { ImageLoaderProvider(androidContext()) } single { PermissionUtil(androidContext()) } - single { NowPlayingEventDistributor() } - single { ThemeChangedEventDistributor() } - single { MediaSessionEventDistributor() } single { MediaSessionHandler() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIPlaylistConverter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIPlaylistConverter.kt index 7699acf3..57f7e29d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIPlaylistConverter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIPlaylistConverter.kt @@ -6,6 +6,7 @@ package org.moire.ultrasonic.domain import java.text.SimpleDateFormat import kotlin.LazyThreadSafetyMode.NONE import org.moire.ultrasonic.api.subsonic.models.Playlist as APIPlaylist +import org.moire.ultrasonic.util.Util.ifNotNull internal val playlistDateFormat by lazy(NONE) { SimpleDateFormat.getInstance() } @@ -17,7 +18,7 @@ fun APIPlaylist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory( fun APIPlaylist.toDomainEntity(): Playlist = Playlist( this.id, this.name, this.owner, this.comment, this.songCount.toString(), - this.created?.let { playlistDateFormat.format(it.time) } ?: "", + this.created.ifNotNull { playlistDateFormat.format(it.time) } ?: "", public ) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIShareConverter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIShareConverter.kt index 36486783..408f42f7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIShareConverter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIShareConverter.kt @@ -5,6 +5,7 @@ package org.moire.ultrasonic.domain import java.text.SimpleDateFormat import kotlin.LazyThreadSafetyMode.NONE import org.moire.ultrasonic.api.subsonic.models.Share as APIShare +import org.moire.ultrasonic.util.Util.ifNotNull internal val shareTimeFormat by lazy(NONE) { SimpleDateFormat.getInstance() } @@ -13,11 +14,11 @@ fun List.toDomainEntitiesList(): List = this.map { } fun APIShare.toDomainEntity(): Share = Share( - created = this@toDomainEntity.created?.let { shareTimeFormat.format(it.time) }, + created = this@toDomainEntity.created.ifNotNull { shareTimeFormat.format(it.time) }, description = this@toDomainEntity.description, - expires = this@toDomainEntity.expires?.let { shareTimeFormat.format(it.time) }, + expires = this@toDomainEntity.expires.ifNotNull { shareTimeFormat.format(it.time) }, id = this@toDomainEntity.id, - lastVisited = this@toDomainEntity.lastVisited?.let { shareTimeFormat.format(it.time) }, + lastVisited = this@toDomainEntity.lastVisited.ifNotNull { shareTimeFormat.format(it.time) }, url = this@toDomainEntity.url, username = this@toDomainEntity.username, visitCount = this@toDomainEntity.visitCount.toLong(), diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt index 54d26ef9..b9ca729a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt @@ -220,6 +220,6 @@ class DownloadListModel(application: Application) : GenericListModel(application private val downloader by inject() fun getList(): LiveData> { - return downloader.observableList + return downloader.observableDownloads } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt new file mode 100644 index 00000000..93b62077 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -0,0 +1,188 @@ +/* + * NowPlayingFragment.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.fragment + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.navigation.Navigation +import io.reactivex.rxjava3.disposables.Disposable +import java.lang.Exception +import kotlin.math.abs +import org.koin.android.ext.android.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.service.MediaPlayerController +import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.subsonic.ImageLoaderProvider +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util.applyTheme +import org.moire.ultrasonic.util.Util.getDrawableFromAttribute +import org.moire.ultrasonic.util.Util.getNotificationImageSize +import timber.log.Timber + +/** + * Contains the mini-now playing information box displayed at the bottom of the screen + */ +class NowPlayingFragment : Fragment() { + + private var downX = 0f + private var downY = 0f + + private var playButton: ImageView? = null + private var nowPlayingAlbumArtImage: ImageView? = null + private var nowPlayingTrack: TextView? = null + private var nowPlayingArtist: TextView? = null + + private var playerStateSubscription: Disposable? = null + private val mediaPlayerController: MediaPlayerController by inject() + private val imageLoader: ImageLoaderProvider by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + applyTheme(this.context) + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.now_playing, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + playButton = view.findViewById(R.id.now_playing_control_play) + nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image) + nowPlayingTrack = view.findViewById(R.id.now_playing_trackname) + nowPlayingArtist = view.findViewById(R.id.now_playing_artist) + playerStateSubscription = + RxBus.playerStateObservable.subscribe { update() } + } + + override fun onResume() { + super.onResume() + update() + } + + override fun onDestroy() { + super.onDestroy() + playerStateSubscription!!.dispose() + } + + @SuppressLint("ClickableViewAccessibility") + private fun update() { + try { + val playerState = mediaPlayerController.playerState + + if (playerState === PlayerState.PAUSED) { + playButton!!.setImageDrawable( + getDrawableFromAttribute( + context, R.attr.media_play + ) + ) + } else if (playerState === PlayerState.STARTED) { + playButton!!.setImageDrawable( + getDrawableFromAttribute( + context, R.attr.media_pause + ) + ) + } + + val file = mediaPlayerController.currentPlaying + + if (file != null) { + val song = file.song + val title = song.title + val artist = song.artist + + imageLoader.getImageLoader().loadImage( + nowPlayingAlbumArtImage, + song, + false, + getNotificationImageSize(requireContext()) + ) + + nowPlayingTrack!!.text = title + nowPlayingArtist!!.text = artist + + nowPlayingAlbumArtImage!!.setOnClickListener { + val bundle = Bundle() + + if (Settings.shouldUseId3Tags) { + bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, true) + bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.albumId) + } else { + bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, false) + bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.parent) + } + + bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.album) + bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.album) + + Navigation.findNavController(requireActivity(), R.id.nav_host_fragment) + .navigate(R.id.trackCollectionFragment, bundle) + } + } + requireView().setOnTouchListener { _: View?, event: MotionEvent -> + handleOnTouch(event) + } + + // This empty onClickListener is necessary for the onTouchListener to work + requireView().setOnClickListener { } + playButton!!.setOnClickListener { mediaPlayerController.togglePlayPause() } + } catch (all: Exception) { + Timber.w(all, "Failed to get notification cover art") + } + } + + private fun handleOnTouch(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + downX = event.x + downY = event.y + } + + MotionEvent.ACTION_UP -> { + val upX = event.x + val upY = event.y + val deltaX = downX - upX + val deltaY = downY - upY + + if (abs(deltaX) > MIN_DISTANCE) { + // left or right + if (deltaX < 0) { + mediaPlayerController.previous() + } + if (deltaX > 0) { + mediaPlayerController.next() + } + } else if (abs(deltaY) > MIN_DISTANCE) { + if (deltaY < 0) { + RxBus.dismissNowPlayingCommandPublisher.onNext(Unit) + } + } else { + Navigation.findNavController(requireActivity(), R.id.nav_host_fragment) + .navigate(R.id.playerFragment) + } + } + } + return false + } + + companion object { + private const val MIN_DISTANCE = 30 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 31c61233..14cbbdf5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -35,9 +35,11 @@ import android.widget.TextView import android.widget.ViewFlipper import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.Navigation import com.mobeta.android.dslv.DragSortListView import com.mobeta.android.dslv.DragSortListView.DragSortListener +import io.reactivex.rxjava3.disposables.Disposable import java.text.DateFormat import java.text.SimpleDateFormat import java.util.ArrayList @@ -49,6 +51,7 @@ import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import kotlin.math.abs import kotlin.math.max +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.core.component.KoinComponent import org.koin.core.component.get @@ -66,6 +69,7 @@ import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.LocalMediaPlayer import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.ShareHandler @@ -88,8 +92,6 @@ import timber.log.Timber */ @Suppress("LargeClass", "TooManyFunctions", "MagicNumber") class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinComponent { - // Settings - private var currentRevision: Long = 0 private var swipeDistance = 0 private var swipeVelocity = 0 private var jukeboxAvailable = false @@ -111,6 +113,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon private var currentPlaying: DownloadFile? = null private var currentSong: MusicDirectory.Entry? = null private var onProgressChangedTask: SilentBackgroundTask? = null + private var rxBusSubscription: Disposable? = null // Views and UI Elements private lateinit var visualizerViewLayout: LinearLayout @@ -419,13 +422,21 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon } } ) - Thread { + + // Observe playlist changes and update the UI + rxBusSubscription = RxBus.playlistObservable.subscribe { + onPlaylistChanged() + } + + // Query the Jukebox state off-thread + viewLifecycleOwner.lifecycleScope.launch { try { jukeboxAvailable = mediaPlayerController.isJukeboxAvailable } catch (all: Exception) { Timber.e(all) } - }.start() + } + view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) } } @@ -479,6 +490,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon } override fun onDestroyView() { + rxBusSubscription?.dispose() cancellationToken.cancel() super.onDestroyView() } @@ -797,9 +809,6 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon private fun update(cancel: CancellationToken?) { if (cancel!!.isCancellationRequested) return val mediaPlayerController = mediaPlayerController - if (currentRevision != mediaPlayerController.playListUpdateRevision) { - onPlaylistChanged() - } if (currentPlaying != mediaPlayerController.currentPlaying) { onCurrentChanged() } @@ -914,7 +923,6 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon emptyTextView.isVisible = list.isEmpty() - currentRevision = mediaPlayerController.playListUpdateRevision when (mediaPlayerController.repeatMode) { RepeatMode.OFF -> repeatButton.setImageDrawable( Util.getDrawableFromAttribute( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index 392c04a4..a2ee1531 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -35,6 +35,7 @@ import org.moire.ultrasonic.log.FileLoggerTree.Companion.plantToTimberForest import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest import org.moire.ultrasonic.provider.SearchSuggestionProvider import org.moire.ultrasonic.service.MediaPlayerController +import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil.defaultMusicDirectory import org.moire.ultrasonic.util.FileUtil.ensureDirectoryExistsAndIsReadWritable @@ -46,7 +47,6 @@ import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings.preferences import org.moire.ultrasonic.util.Settings.shareGreeting import org.moire.ultrasonic.util.Settings.shouldUseId3Tags -import org.moire.ultrasonic.util.ThemeChangedEventDistributor import org.moire.ultrasonic.util.TimeSpanPreference import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat import org.moire.ultrasonic.util.Util.toast @@ -92,12 +92,11 @@ class SettingsFragment : private val mediaPlayerControllerLazy = inject( MediaPlayerController::class.java ) + private val permissionUtil = inject( PermissionUtil::class.java ) - private val themeChangedEventDistributor = inject( - ThemeChangedEventDistributor::class.java - ) + private val mediaSessionHandler = inject( MediaSessionHandler::class.java ) @@ -201,7 +200,7 @@ class SettingsFragment : showArtistPicture!!.isEnabled = sharedPreferences.getBoolean(key, false) } Constants.PREFERENCES_KEY_THEME -> { - themeChangedEventDistributor.value.RaiseThemeChangedEvent() + RxBus.themeChangedEventPublisher.onNext(Unit) } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index c6681559..9d785a30 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -211,7 +211,7 @@ class TrackCollectionFragment : Fragment() { val handler = CoroutineExceptionHandler { _, exception -> Handler(Looper.getMainLooper()).post { - context?.let { CommunicationErrorHandler.handleError(exception, it) } + CommunicationErrorHandler.handleError(exception, context) } refreshAlbumListView!!.isRefreshing = false } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index b8f55481..173c5806 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -11,9 +11,9 @@ import android.os.Bundle import android.os.Handler import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.session.MediaSessionCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants +import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -25,8 +25,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchResult -import org.moire.ultrasonic.util.MediaSessionEventDistributor -import org.moire.ultrasonic.util.MediaSessionEventListener import org.moire.ultrasonic.util.MediaSessionHandler import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util @@ -73,8 +71,6 @@ private const val SEARCH_LIMIT = 10 @Suppress("TooManyFunctions", "LargeClass") class AutoMediaBrowserService : MediaBrowserServiceCompat() { - private lateinit var mediaSessionEventListener: MediaSessionEventListener - private val mediaSessionEventDistributor by inject() private val lifecycleSupport by inject() private val mediaSessionHandler by inject() private val mediaPlayerController by inject() @@ -93,75 +89,24 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private val useId3Tags get() = Settings.shouldUseId3Tags private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId + private var rxBusSubscription: CompositeDisposable = CompositeDisposable() + @Suppress("MagicNumber") override fun onCreate() { super.onCreate() - mediaSessionEventListener = object : MediaSessionEventListener { - override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { - if (sessionToken == null) { - sessionToken = token - } - } - - override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) { - Timber.d( - "AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", - mediaId - ) - - if (mediaId == null) return - val mediaIdParts = mediaId.split('|') - - when (mediaIdParts.first()) { - MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2]) - MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( - mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] - ) - MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2]) - MEDIA_ALBUM_SONG_ITEM -> playAlbumSong( - mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] - ) - MEDIA_SONG_STARRED_ID -> playStarredSongs() - MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1]) - MEDIA_SONG_RANDOM_ID -> playRandomSongs() - MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1]) - MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1]) - MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2]) - MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1]) - MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1]) - MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode( - mediaIdParts[1], mediaIdParts[2] - ) - MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) - } - } - - override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) { - Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query) - if (query.isNullOrBlank()) playRandomSongs() - - serviceScope.launch { - val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT) - val searchResult = callWithErrorHandling { musicService.search(criteria) } - - // Try to find the best match - if (searchResult != null) { - val song = searchResult.songs - .asSequence() - .sortedByDescending { song -> song.starred } - .sortedByDescending { song -> song.averageRating } - .sortedByDescending { song -> song.userRating } - .sortedByDescending { song -> song.closeness } - .firstOrNull() - - if (song != null) playSong(song) - } - } - } + rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe { + if (sessionToken == null) sessionToken = it + } + + rxBusSubscription += RxBus.playFromMediaIdCommandObservable.subscribe { + playFromMediaId(it.first) + } + + rxBusSubscription += RxBus.playFromSearchCommandObservable.subscribe { + playFromSearchCommand(it.first) } - mediaSessionEventDistributor.subscribe(mediaSessionEventListener) mediaSessionHandler.initialize() val handler = Handler() @@ -180,9 +125,66 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { Timber.i("AutoMediaBrowserService onCreate finished") } + @Suppress("MagicNumber", "ComplexMethod") + private fun playFromMediaId(mediaId: String?) { + Timber.d( + "AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", + mediaId + ) + + if (mediaId == null) return + val mediaIdParts = mediaId.split('|') + + when (mediaIdParts.first()) { + MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2]) + MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( + mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] + ) + MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2]) + MEDIA_ALBUM_SONG_ITEM -> playAlbumSong( + mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] + ) + MEDIA_SONG_STARRED_ID -> playStarredSongs() + MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1]) + MEDIA_SONG_RANDOM_ID -> playRandomSongs() + MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1]) + MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1]) + MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2]) + MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1]) + MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1]) + MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode( + mediaIdParts[1], mediaIdParts[2] + ) + MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) + } + } + + private fun playFromSearchCommand(query: String?) { + Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query) + if (query.isNullOrBlank()) playRandomSongs() + + serviceScope.launch { + val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT) + val searchResult = callWithErrorHandling { musicService.search(criteria) } + + // Try to find the best match + if (searchResult != null) { + val song = searchResult.songs + .asSequence() + .sortedByDescending { song -> song.starred } + .sortedByDescending { song -> song.averageRating } + .sortedByDescending { song -> song.userRating } + .sortedByDescending { song -> song.closeness } + .firstOrNull() + + if (song != null) playSong(song) + } + } + } + override fun onDestroy() { super.onDestroy() - mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) + rxBusSubscription.dispose() mediaSessionHandler.release() serviceJob.cancel() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CommunicationErrorHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CommunicationErrorHandler.kt index 1b876fb5..bfda4aab 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CommunicationErrorHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CommunicationErrorHandler.kt @@ -39,9 +39,11 @@ import timber.log.Timber */ class CommunicationErrorHandler { companion object { - fun handleError(error: Throwable?, context: Context) { + fun handleError(error: Throwable?, context: Context?) { Timber.w(error) + if (context == null) return + AlertDialog.Builder(context) .setIcon(android.R.drawable.ic_dialog_alert) .setTitle(R.string.error_label) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt index 3f27c85d..a1aa4bec 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -30,13 +30,17 @@ class Downloader( private val externalStorageMonitor: ExternalStorageMonitor, private val localMediaPlayer: LocalMediaPlayer ) : KoinComponent { - val playlist: MutableList = ArrayList() + + private val playlist = mutableListOf() + var started: Boolean = false - private val downloadQueue: PriorityQueue = PriorityQueue() - private val activelyDownloading: MutableList = ArrayList() + private val downloadQueue = PriorityQueue() + private val activelyDownloading = mutableListOf() - val observableList: MutableLiveData> = MutableLiveData>() + // TODO: The playlist is now published with RX, while the observableDownloads is using LiveData. + // Use the same for both + val observableDownloads = MutableLiveData>() private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() @@ -45,8 +49,11 @@ class Downloader( private var executorService: ScheduledExecutorService? = null private var wifiLock: WifiManager.WifiLock? = null - var playlistUpdateRevision: Long = 0 - private set + private var playlistUpdateRevision: Long = 0 + private set(value) { + field = value + RxBus.playlistPublisher.onNext(playlist) + } val downloadChecker = Runnable { try { @@ -61,7 +68,7 @@ class Downloader( stop() clearPlaylist() clearBackground() - observableList.value = listOf() + observableDownloads.value = listOf() Timber.i("Downloader destroyed") } @@ -179,7 +186,7 @@ class Downloader( } private fun updateLiveData() { - observableList.postValue(downloads) + observableDownloads.postValue(downloads) } private fun startDownloadOnService(task: DownloadFile) { @@ -264,6 +271,10 @@ class Downloader( return temp.distinct().sorted() } + // Public facing playlist (immutable) + @Synchronized + fun getPlaylist(): List = playlist + @Synchronized fun clearPlaylist() { playlist.clear() @@ -349,6 +360,20 @@ class Downloader( checkDownloads() } + @Synchronized + fun clearIncomplete() { + val iterator = playlist.iterator() + var changedPlaylist = false + while (iterator.hasNext()) { + val downloadFile = iterator.next() + if (!downloadFile.isCompleteFileAvailable) { + iterator.remove() + changedPlaylist = true + } + } + if (changedPlaylist) playlistUpdateRevision++ + } + @Synchronized fun downloadBackground(songs: List, save: Boolean) { @@ -429,18 +454,21 @@ class Downloader( playlistUpdateRevision++ } } + if (revisionBefore != playlistUpdateRevision) { jukeboxMediaPlayer.updatePlaylist() } + if (wasEmpty && playlist.isNotEmpty()) { if (jukeboxMediaPlayer.isEnabled) { jukeboxMediaPlayer.skip(0, 0) - localMediaPlayer.setPlayerState(PlayerState.STARTED) + localMediaPlayer.setPlayerState(PlayerState.STARTED, playlist[0]) } else { localMediaPlayer.play(playlist[0]) } } } + companion object { const val PARALLEL_DOWNLOADS = 3 const val CHECK_INTERVAL = 5L diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index 0b0a1b42..f63d7aea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -32,7 +32,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.MediaSessionHandler import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.StreamProxy import org.moire.ultrasonic.util.Util @@ -46,17 +45,10 @@ class LocalMediaPlayer : KoinComponent { private val audioFocusHandler by inject() private val context by inject() - private val mediaSessionHandler by inject() - - @JvmField - var onCurrentPlayingChanged: ((DownloadFile?) -> Unit?)? = null @JvmField var onSongCompleted: ((DownloadFile?) -> Unit?)? = null - @JvmField - var onPlayerStateChanged: ((PlayerState, DownloadFile?) -> Unit?)? = null - @JvmField var onPrepared: (() -> Any?)? = null @@ -64,6 +56,7 @@ class LocalMediaPlayer : KoinComponent { var onNextSongRequested: Runnable? = null @JvmField + @Volatile var playerState = PlayerState.IDLE @JvmField @@ -132,7 +125,6 @@ class LocalMediaPlayer : KoinComponent { // Calling reset() will result in changing this player's state. If we allow // the onPlayerStateChanged callback, then the state change will cause this // to resurrect the media session which has just been destroyed. - onPlayerStateChanged = null reset() try { val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) @@ -164,21 +156,17 @@ class LocalMediaPlayer : KoinComponent { } @Synchronized - fun setPlayerState(playerState: PlayerState) { - Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, currentPlaying) - this.playerState = playerState + fun setPlayerState(playerState: PlayerState, track: DownloadFile?) { + Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, track) + synchronized(playerState) { + this.playerState = playerState + } if (playerState === PlayerState.STARTED) { audioFocusHandler.requestAudioFocus() } - if (onPlayerStateChanged != null) { - val mainHandler = Handler(context.mainLooper) + RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, track)) - val myRunnable = Runnable { - onPlayerStateChanged?.invoke(playerState, currentPlaying) - } - mainHandler.post(myRunnable) - } if (playerState === PlayerState.STARTED && positionCache == null) { positionCache = PositionCache() val thread = Thread(positionCache) @@ -194,14 +182,10 @@ class LocalMediaPlayer : KoinComponent { */ @Synchronized fun setCurrentPlaying(currentPlaying: DownloadFile?) { - Timber.v("setCurrentPlaying %s", currentPlaying) + // In some cases this function is called twice + if (this.currentPlaying == currentPlaying) return this.currentPlaying = currentPlaying - - if (onCurrentPlayingChanged != null) { - val mainHandler = Handler(context.mainLooper) - val myRunnable = Runnable { onCurrentPlayingChanged!!(currentPlaying) } - mainHandler.post(myRunnable) - } + RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, currentPlaying)) } /* @@ -262,7 +246,7 @@ class LocalMediaPlayer : KoinComponent { mediaPlayer = nextMediaPlayer!! setCurrentPlaying(nextPlaying) - setPlayerState(PlayerState.STARTED) + setPlayerState(PlayerState.STARTED, currentPlaying) attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false) @@ -343,7 +327,7 @@ class LocalMediaPlayer : KoinComponent { @Synchronized private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) { - if (playerState !== PlayerState.PREPARED) { + if (playerState !== PlayerState.PREPARED && !fileToPlay.isWorkDone) { reset() bufferTask = BufferTask(fileToPlay, position, autoStart) bufferTask!!.start() @@ -354,6 +338,7 @@ class LocalMediaPlayer : KoinComponent { @Synchronized private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) { + setPlayerState(PlayerState.IDLE, downloadFile) // In many cases we will be resetting the mediaPlayer a second time here. // figure out if we can remove this call... @@ -368,7 +353,6 @@ class LocalMediaPlayer : KoinComponent { downloadFile.updateModificationDate() mediaPlayer.setOnCompletionListener(null) - setPlayerState(PlayerState.IDLE) setAudioAttributes(mediaPlayer) var dataSource = file.path @@ -394,7 +378,7 @@ class LocalMediaPlayer : KoinComponent { Timber.i("Preparing media player") mediaPlayer.setDataSource(dataSource) - setPlayerState(PlayerState.PREPARING) + setPlayerState(PlayerState.PREPARING, downloadFile) mediaPlayer.setOnBufferingUpdateListener { mp, percent -> val song = downloadFile.song @@ -412,7 +396,7 @@ class LocalMediaPlayer : KoinComponent { mediaPlayer.setOnPreparedListener { Timber.i("Media player prepared") - setPlayerState(PlayerState.PREPARED) + setPlayerState(PlayerState.PREPARED, downloadFile) // Populate seek bar secondary progress if we have a complete file for consistency if (downloadFile.isWorkDone) { @@ -427,9 +411,9 @@ class LocalMediaPlayer : KoinComponent { cachedPosition = position if (start) { mediaPlayer.start() - setPlayerState(PlayerState.STARTED) + setPlayerState(PlayerState.STARTED, downloadFile) } else { - setPlayerState(PlayerState.PAUSED) + setPlayerState(PlayerState.PAUSED, downloadFile) } } @@ -437,6 +421,7 @@ class LocalMediaPlayer : KoinComponent { onPrepared } } + attachHandlersToPlayer(mediaPlayer, downloadFile, partial) mediaPlayer.prepareAsync() } catch (x: Exception) { @@ -527,7 +512,7 @@ class LocalMediaPlayer : KoinComponent { Timber.i("Ending position %d of %d", pos, duration) if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) { - setPlayerState(PlayerState.COMPLETED) + setPlayerState(PlayerState.COMPLETED, downloadFile) if (Settings.gaplessPlayback && nextPlaying != null && nextPlayerState === PlayerState.PREPARED @@ -574,7 +559,7 @@ class LocalMediaPlayer : KoinComponent { resetMediaPlayer() try { - setPlayerState(PlayerState.IDLE) + setPlayerState(PlayerState.IDLE, currentPlaying) mediaPlayer.setOnErrorListener(null) mediaPlayer.setOnCompletionListener(null) } catch (x: Exception) { @@ -603,7 +588,7 @@ class LocalMediaPlayer : KoinComponent { private val partialFile: File = downloadFile.partialFile override fun execute() { - setPlayerState(PlayerState.DOWNLOADING) + setPlayerState(PlayerState.DOWNLOADING, downloadFile) while (!bufferComplete() && !isOffline()) { Util.sleepQuietly(1000L) if (isCancelled) { @@ -702,10 +687,12 @@ class LocalMediaPlayer : KoinComponent { while (isRunning) { try { if (playerState === PlayerState.STARTED) { - cachedPosition = mediaPlayer.currentPosition - mediaSessionHandler.updateMediaSessionPlaybackPosition( - cachedPosition.toLong() - ) + synchronized(playerState) { + if (playerState === PlayerState.STARTED) { + cachedPosition = mediaPlayer.currentPosition + } + } + RxBus.playbackPositionPublisher.onNext(cachedPosition) } Util.sleepQuietly(100L) } catch (e: Exception) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index c24e9e88..05e80330 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -180,7 +180,7 @@ class MediaPlayerController( downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist) jukeboxMediaPlayer.updatePlaylist() if (shuffle) shuffle() - val isLastTrack = (downloader.playlist.size - 1 == downloader.currentPlayingIndex) + val isLastTrack = (downloader.getPlaylist().size - 1 == downloader.currentPlayingIndex) if (!playNext && !autoPlay && isLastTrack) { val mediaPlayerService = runningInstance @@ -190,15 +190,15 @@ class MediaPlayerController( if (autoPlay) { play(0) } else { - if (localMediaPlayer.currentPlaying == null && downloader.playlist.size > 0) { - localMediaPlayer.currentPlaying = downloader.playlist[0] - downloader.playlist[0].setPlaying(true) + if (localMediaPlayer.currentPlaying == null && downloader.getPlaylist().isNotEmpty()) { + localMediaPlayer.currentPlaying = downloader.getPlaylist()[0] + downloader.getPlaylist()[0].setPlaying(true) } downloader.checkDownloads() } playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) @@ -210,7 +210,7 @@ class MediaPlayerController( val filteredSongs = songs.filterNotNull() downloader.downloadBackground(filteredSongs, save) playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) @@ -241,7 +241,7 @@ class MediaPlayerController( fun shuffle() { downloader.shuffle() playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) @@ -270,7 +270,7 @@ class MediaPlayerController( downloader.clearPlaylist() if (serialize) { playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) } @@ -281,16 +281,11 @@ class MediaPlayerController( @Synchronized fun clearIncomplete() { reset() - val iterator = downloader.playlist.iterator() - while (iterator.hasNext()) { - val downloadFile = iterator.next() - if (!downloadFile.isCompleteFileAvailable) { - iterator.remove() - } - } + + downloader.clearIncomplete() playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) @@ -307,7 +302,7 @@ class MediaPlayerController( downloader.removeFromPlaylist(downloadFile) playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) @@ -359,12 +354,12 @@ class MediaPlayerController( when (repeatMode) { RepeatMode.SINGLE, RepeatMode.OFF -> { // Play next if exists - if (index + 1 >= 0 && index + 1 < downloader.playlist.size) { + if (index + 1 >= 0 && index + 1 < downloader.getPlaylist().size) { play(index + 1) } } RepeatMode.ALL -> { - play((index + 1) % downloader.playlist.size) + play((index + 1) % downloader.getPlaylist().size) } else -> { } @@ -397,7 +392,8 @@ class MediaPlayerController( get() = localMediaPlayer.playerState set(state) { val mediaPlayerService = runningInstance - if (mediaPlayerService != null) localMediaPlayer.setPlayerState(state) + if (mediaPlayerService != null) + localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying) } @set:Synchronized @@ -479,6 +475,7 @@ class MediaPlayerController( Timber.e(e) } }.start() + // TODO this would be better handled with a Rx command updateNotification() } @@ -490,16 +487,13 @@ class MediaPlayerController( } val playlistSize: Int - get() = downloader.playlist.size + get() = downloader.getPlaylist().size val currentPlayingNumberOnPlaylist: Int get() = downloader.currentPlayingIndex val playList: List - get() = downloader.playlist - - val playListUpdateRevision: Long - get() = downloader.playlistUpdateRevision + get() = downloader.getPlaylist() val playListDuration: Long get() = downloader.downloadListDuration diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt index c69b820b..379b6d60 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -13,6 +13,7 @@ import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import android.view.KeyEvent +import io.reactivex.rxjava3.disposables.Disposable import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R @@ -20,9 +21,8 @@ import org.moire.ultrasonic.app.UApp.Companion.applicationContext import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.MediaSessionEventDistributor -import org.moire.ultrasonic.util.MediaSessionEventListener import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util.ifNotNull import timber.log.Timber /** @@ -34,11 +34,10 @@ class MediaPlayerLifecycleSupport : KoinComponent { private val playbackStateSerializer by inject() private val mediaPlayerController by inject() private val downloader by inject() - private val mediaSessionEventDistributor by inject() private var created = false private var headsetEventReceiver: BroadcastReceiver? = null - private lateinit var mediaSessionEventListener: MediaSessionEventListener + private var mediaButtonEventSubscription: Disposable? = null fun onCreate() { onCreate(false, null) @@ -51,13 +50,10 @@ class MediaPlayerLifecycleSupport : KoinComponent { return } - mediaSessionEventListener = object : MediaSessionEventListener { - override fun onMediaButtonEvent(keyEvent: KeyEvent?) { - if (keyEvent != null) handleKeyEvent(keyEvent) - } + mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe { + handleKeyEvent(it) } - mediaSessionEventDistributor.subscribe(mediaSessionEventListener) registerHeadsetReceiver() mediaPlayerController.onCreate() if (autoPlay) mediaPlayerController.preload() @@ -75,7 +71,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { // Work-around: Serialize again, as the restore() method creates a // serialization without current playing info. playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, mediaPlayerController.playerPosition ) @@ -92,14 +88,13 @@ class MediaPlayerLifecycleSupport : KoinComponent { if (!created) return playbackStateSerializer.serializeNow( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, mediaPlayerController.playerPosition ) - mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) - mediaPlayerController.clear(false) + mediaButtonEventSubscription?.dispose() applicationContext().unregisterReceiver(headsetEventReceiver) mediaPlayerController.onDestroy() @@ -119,7 +114,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { if (intentAction == Constants.CMD_PROCESS_KEYCODE) { if (intent.extras != null) { val event = intent.extras!![Intent.EXTRA_KEY_EVENT] as KeyEvent? - event?.let { handleKeyEvent(it) } + event.ifNotNull { handleKeyEvent(it) } } } else { handleUltrasonicIntent(intentAction) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 21a8e5f8..e503b36f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -22,6 +22,7 @@ import android.support.v4.media.session.MediaSessionCompat import android.view.KeyEvent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlin.collections.ArrayList import org.koin.android.ext.android.inject import org.moire.ultrasonic.R @@ -37,10 +38,7 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.MediaSessionEventDistributor -import org.moire.ultrasonic.util.MediaSessionEventListener import org.moire.ultrasonic.util.MediaSessionHandler -import org.moire.ultrasonic.util.NowPlayingEventDistributor import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.ShufflePlayBuffer import org.moire.ultrasonic.util.SimpleServiceBinder @@ -64,18 +62,16 @@ class MediaPlayerService : Service() { private val shufflePlayBuffer by inject() private val downloader by inject() private val localMediaPlayer by inject() - private val nowPlayingEventDistributor by inject() - private val mediaSessionEventDistributor by inject() private val mediaSessionHandler by inject() private var mediaSession: MediaSessionCompat? = null private var mediaSessionToken: MediaSessionCompat.Token? = null private var isInForeground = false private var notificationBuilder: NotificationCompat.Builder? = null - private lateinit var mediaSessionEventListener: MediaSessionEventListener + private var rxBusSubscription: CompositeDisposable = CompositeDisposable() - private val repeatMode: RepeatMode - get() = Settings.repeatMode + private var currentPlayerState: PlayerState? = null + private var currentTrack: DownloadFile? = null override fun onBind(intent: Intent): IBinder { return binder @@ -87,13 +83,11 @@ class MediaPlayerService : Service() { shufflePlayBuffer.onCreate() localMediaPlayer.init() - setupOnCurrentPlayingChangedHandler() - setupOnPlayerStateChangedHandler() setupOnSongCompletedHandler() localMediaPlayer.onPrepared = { playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) @@ -102,25 +96,28 @@ class MediaPlayerService : Service() { localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } - mediaSessionEventListener = object : MediaSessionEventListener { - override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { - mediaSessionToken = token - } - - override fun onSkipToQueueItemRequested(id: Long) { - play(id.toInt()) - } - } - - mediaSessionEventDistributor.subscribe(mediaSessionEventListener) - mediaSessionHandler.initialize() - // Create Notification Channel createNotificationChannel() // Update notification early. It is better to show an empty one temporarily // than waiting too long and letting Android kill the app updateNotification(PlayerState.IDLE, null) + + // Subscribing should be after updateNotification to avoid concurrency + rxBusSubscription += RxBus.playerStateObservable.subscribe { + playerStateChangedHandler(it.state, it.track) + } + + rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe { + mediaSessionToken = it + } + + rxBusSubscription += RxBus.skipToQueueItemCommandObservable.subscribe { + play(it.toInt()) + } + + mediaSessionHandler.initialize() + instance = this Timber.i("MediaPlayerService created") } @@ -134,8 +131,8 @@ class MediaPlayerService : Service() { super.onDestroy() instance = null try { - mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) mediaSessionHandler.release() + rxBusSubscription.dispose() localMediaPlayer.release() downloader.stop() @@ -201,16 +198,14 @@ class MediaPlayerService : Service() { @Synchronized fun setCurrentPlaying(currentPlayingIndex: Int) { try { - localMediaPlayer.setCurrentPlaying(downloader.playlist[currentPlayingIndex]) + localMediaPlayer.setCurrentPlaying(downloader.getPlaylist()[currentPlayingIndex]) } catch (ignored: IndexOutOfBoundsException) { } } @Synchronized fun setNextPlaying() { - val gaplessPlayback = Settings.gaplessPlayback - - if (!gaplessPlayback) { + if (!Settings.gaplessPlayback) { localMediaPlayer.clearNextPlaying(true) return } @@ -218,9 +213,9 @@ class MediaPlayerService : Service() { var index = downloader.currentPlayingIndex if (index != -1) { - when (repeatMode) { + when (Settings.repeatMode) { RepeatMode.OFF -> index += 1 - RepeatMode.ALL -> index = (index + 1) % downloader.playlist.size + RepeatMode.ALL -> index = (index + 1) % downloader.getPlaylist().size RepeatMode.SINGLE -> { } else -> { @@ -229,8 +224,8 @@ class MediaPlayerService : Service() { } localMediaPlayer.clearNextPlaying(false) - if (index < downloader.playlist.size && index != -1) { - localMediaPlayer.setNextPlaying(downloader.playlist[index]) + if (index < downloader.getPlaylist().size && index != -1) { + localMediaPlayer.setNextPlaying(downloader.getPlaylist()[index]) } else { localMediaPlayer.clearNextPlaying(true) } @@ -283,16 +278,15 @@ class MediaPlayerService : Service() { @Synchronized fun play(index: Int, start: Boolean) { Timber.v("play requested for %d", index) - if (index < 0 || index >= downloader.playlist.size) { + if (index < 0 || index >= downloader.getPlaylist().size) { resetPlayback() } else { setCurrentPlaying(index) if (start) { if (jukeboxMediaPlayer.isEnabled) { jukeboxMediaPlayer.skip(index, 0) - localMediaPlayer.setPlayerState(PlayerState.STARTED) } else { - localMediaPlayer.play(downloader.playlist[index]) + localMediaPlayer.play(downloader.getPlaylist()[index]) } } downloader.checkDownloads() @@ -305,7 +299,7 @@ class MediaPlayerService : Service() { localMediaPlayer.reset() localMediaPlayer.setCurrentPlaying(null) playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) } @@ -318,7 +312,7 @@ class MediaPlayerService : Service() { } else { localMediaPlayer.pause() } - localMediaPlayer.setPlayerState(PlayerState.PAUSED) + localMediaPlayer.setPlayerState(PlayerState.PAUSED, localMediaPlayer.currentPlaying) } } @@ -331,7 +325,7 @@ class MediaPlayerService : Service() { localMediaPlayer.pause() } } - localMediaPlayer.setPlayerState(PlayerState.STOPPED) + localMediaPlayer.setPlayerState(PlayerState.STOPPED, null) } @Synchronized @@ -341,7 +335,7 @@ class MediaPlayerService : Service() { } else { localMediaPlayer.start() } - localMediaPlayer.setPlayerState(PlayerState.STARTED) + localMediaPlayer.setPlayerState(PlayerState.STARTED, localMediaPlayer.currentPlaying) } private fun updateWidget(playerState: PlayerState, song: MusicDirectory.Entry?) { @@ -354,100 +348,73 @@ class MediaPlayerService : Service() { UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false) } - private fun setupOnCurrentPlayingChangedHandler() { - localMediaPlayer.onCurrentPlayingChanged = { currentPlaying: DownloadFile? -> + private fun playerStateChangedHandler( + playerState: PlayerState, + currentPlaying: DownloadFile? + ) { + val context = this@MediaPlayerService + // AVRCP handles these separately so we must differentiate between the cases + val isStateChanged = playerState != currentPlayerState + val isTrackChanged = currentPlaying != currentTrack + if (!isStateChanged && !isTrackChanged) return - if (currentPlaying != null) { - Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying.song) - Util.broadcastA2dpMetaDataChange( - this@MediaPlayerService, playerPosition, currentPlaying, - downloader.all.size, downloader.currentPlayingIndex + 1 - ) - } else { - Util.broadcastNewTrackInfo(this@MediaPlayerService, null) - Util.broadcastA2dpMetaDataChange( - this@MediaPlayerService, playerPosition, null, - downloader.all.size, downloader.currentPlayingIndex + 1 - ) + val showWhenPaused = playerState !== PlayerState.STOPPED && + Settings.isNotificationAlwaysEnabled + + val show = playerState === PlayerState.STARTED || showWhenPaused + val song = currentPlaying?.song + + if (isStateChanged) { + when { + playerState === PlayerState.PAUSED -> { + playbackStateSerializer.serialize( + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition + ) + } + playerState === PlayerState.STARTED -> { + scrobbler.scrobble(currentPlaying, false) + } + playerState === PlayerState.COMPLETED -> { + scrobbler.scrobble(currentPlaying, true) + } } - // Update widget - val playerState = localMediaPlayer.playerState - val song = currentPlaying?.song - - updateWidget(playerState, song) - - if (currentPlaying != null) { - updateNotification(localMediaPlayer.playerState, currentPlaying) - nowPlayingEventDistributor.raiseShowNowPlayingEvent() - } else { - nowPlayingEventDistributor.raiseHideNowPlayingEvent() - stopForeground(true) - isInForeground = false - stopIfIdle() - } - null - } - } - - private fun setupOnPlayerStateChangedHandler() { - localMediaPlayer.onPlayerStateChanged = { - playerState: PlayerState, - currentPlaying: DownloadFile? - -> - - val context = this@MediaPlayerService - - // Notify MediaSession - mediaSessionHandler.updateMediaSession( - currentPlaying, - downloader.currentPlayingIndex.toLong(), - playerState - ) - - if (playerState === PlayerState.PAUSED) { - playbackStateSerializer.serialize( - downloader.playlist, downloader.currentPlayingIndex, playerPosition - ) - } - - val showWhenPaused = playerState !== PlayerState.STOPPED && - Settings.isNotificationAlwaysEnabled - - val show = playerState === PlayerState.STARTED || showWhenPaused - val song = currentPlaying?.song - Util.broadcastPlaybackStatusChange(context, playerState) Util.broadcastA2dpPlayStatusChange( context, playerState, song, - downloader.playlist.size, - downloader.playlist.indexOf(currentPlaying) + 1, playerPosition + downloader.getPlaylist().size, + downloader.getPlaylist().indexOf(currentPlaying) + 1, playerPosition + ) + } else { + // State didn't change, only the track + Util.broadcastA2dpMetaDataChange( + this@MediaPlayerService, playerPosition, currentPlaying, + downloader.all.size, downloader.currentPlayingIndex + 1 ) - - // Update widget - updateWidget(playerState, song) - - if (show) { - // Only update notification if player state is one that will change the icon - if (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) { - updateNotification(playerState, currentPlaying) - nowPlayingEventDistributor.raiseShowNowPlayingEvent() - } - } else { - nowPlayingEventDistributor.raiseHideNowPlayingEvent() - stopForeground(true) - isInForeground = false - stopIfIdle() - } - - if (playerState === PlayerState.STARTED) { - scrobbler.scrobble(currentPlaying, false) - } else if (playerState === PlayerState.COMPLETED) { - scrobbler.scrobble(currentPlaying, true) - } - - null } + + if (isTrackChanged) { + Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying?.song) + } + + // Update widget + updateWidget(playerState, song) + + if (show) { + // Only update notification if player state is one that will change the icon + if (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) { + updateNotification(playerState, currentPlaying) + } + } else { + stopForeground(true) + isInForeground = false + stopIfIdle() + } + + currentPlayerState = playerState + currentTrack = currentPlaying + + Timber.d("Processed player state change") } private fun setupOnSongCompletedHandler() { @@ -465,9 +432,9 @@ class MediaPlayerService : Service() { } } if (index != -1) { - when (repeatMode) { + when (Settings.repeatMode) { RepeatMode.OFF -> { - if (index + 1 < 0 || index + 1 >= downloader.playlist.size) { + if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) { if (Settings.shouldClearPlaylist) { clear(true) jukeboxMediaPlayer.updatePlaylist() @@ -478,7 +445,7 @@ class MediaPlayerService : Service() { } } RepeatMode.ALL -> { - play((index + 1) % downloader.playlist.size) + play((index + 1) % downloader.getPlaylist().size) } RepeatMode.SINGLE -> play(index) else -> { @@ -497,7 +464,7 @@ class MediaPlayerService : Service() { setNextPlaying() if (serialize) { playbackStateSerializer.serialize( - downloader.playlist, + downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition ) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt index 4e3c0692..df9e4390 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt @@ -19,7 +19,6 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil -import org.moire.ultrasonic.util.MediaSessionHandler import timber.log.Timber /** @@ -30,9 +29,8 @@ import timber.log.Timber class PlaybackStateSerializer : KoinComponent { private val context by inject() - private val mediaSessionHandler by inject() - val lock: Lock = ReentrantLock() + private val lock: Lock = ReentrantLock() private val setup = AtomicBoolean(false) private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -76,9 +74,6 @@ class PlaybackStateSerializer : KoinComponent { ) FileUtil.serialize(context, state, Constants.FILENAME_PLAYLIST_SER) - - // This is called here because the queue is usually serialized after a change - mediaSessionHandler.updateMediaSessionQueue(state.songs) } fun deserialize(afterDeserialized: (State?) -> Unit?) { @@ -106,7 +101,6 @@ class PlaybackStateSerializer : KoinComponent { state.currentPlayingPosition ) - mediaSessionHandler.updateMediaSessionQueue(state.songs) afterDeserialized(state) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt new file mode 100644 index 00000000..eeca3ffc --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -0,0 +1,86 @@ +package org.moire.ultrasonic.service + +import android.os.Bundle +import android.support.v4.media.session.MediaSessionCompat +import android.view.KeyEvent +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +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.domain.PlayerState + +class RxBus { + companion object { + var mediaSessionTokenPublisher: PublishSubject = + PublishSubject.create() + val mediaSessionTokenObservable: Observable = + mediaSessionTokenPublisher.observeOn(AndroidSchedulers.mainThread()) + .replay(1) + .autoConnect(0) + + val mediaButtonEventPublisher: PublishSubject = + PublishSubject.create() + val mediaButtonEventObservable: Observable = + mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread()) + + val themeChangedEventPublisher: PublishSubject = + PublishSubject.create() + val themeChangedEventObservable: Observable = + themeChangedEventPublisher.observeOn(AndroidSchedulers.mainThread()) + + val playerStatePublisher: PublishSubject = + PublishSubject.create() + val playerStateObservable: Observable = + playerStatePublisher.observeOn(AndroidSchedulers.mainThread()) + .replay(1) + .autoConnect(0) + + val playlistPublisher: PublishSubject> = + PublishSubject.create() + val playlistObservable: Observable> = + playlistPublisher.observeOn(AndroidSchedulers.mainThread()) + .replay(1) + .autoConnect(0) + + val playbackPositionPublisher: PublishSubject = + PublishSubject.create() + val playbackPositionObservable: Observable = + playbackPositionPublisher.observeOn(AndroidSchedulers.mainThread()) + .throttleFirst(1, TimeUnit.SECONDS) + .replay(1) + .autoConnect(0) + + // Commands + val dismissNowPlayingCommandPublisher: PublishSubject = + PublishSubject.create() + val dismissNowPlayingCommandObservable: Observable = + dismissNowPlayingCommandPublisher.observeOn(AndroidSchedulers.mainThread()) + + val playFromMediaIdCommandPublisher: PublishSubject> = + PublishSubject.create() + val playFromMediaIdCommandObservable: Observable> = + playFromMediaIdCommandPublisher.observeOn(AndroidSchedulers.mainThread()) + + val playFromSearchCommandPublisher: PublishSubject> = + PublishSubject.create() + val playFromSearchCommandObservable: Observable> = + playFromSearchCommandPublisher.observeOn(AndroidSchedulers.mainThread()) + + val skipToQueueItemCommandPublisher: PublishSubject = + PublishSubject.create() + val skipToQueueItemCommandObservable: Observable = + skipToQueueItemCommandPublisher.observeOn(AndroidSchedulers.mainThread()) + + fun releaseMediaSessionToken() { + mediaSessionTokenPublisher = PublishSubject.create() + } + } + + data class StateWithTrack(val state: PlayerState, val track: DownloadFile?) +} + +operator fun CompositeDisposable.plusAssign(disposable: Disposable) { + this.add(disposable) +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt index 883aeeac..13f9922f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt @@ -26,6 +26,7 @@ import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.ShareDetails import org.moire.ultrasonic.util.TimeSpan import org.moire.ultrasonic.util.TimeSpanPicker +import org.moire.ultrasonic.util.Util.ifNotNull /** * This class handles sharing items in the media library @@ -79,7 +80,7 @@ class ShareHandler(val context: Context) { if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1) return null if (shareDetails.Entries.isEmpty()) { - fragment.arguments?.getString(Constants.INTENT_EXTRA_NAME_ID)?.let { + fragment.arguments?.getString(Constants.INTENT_EXTRA_NAME_ID).ifNotNull { ids.add(it) } } else { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt deleted file mode 100644 index a9ecade8..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * MediaSessionEventDistributor.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.util - -import android.os.Bundle -import android.support.v4.media.session.MediaSessionCompat -import android.view.KeyEvent - -/** - * This class distributes MediaSession related events to its subscribers. - * It is a primitive implementation of a pub-sub event bus - */ -class MediaSessionEventDistributor { - var eventListenerList: MutableList = - listOf().toMutableList() - - var cachedToken: MediaSessionCompat.Token? = null - - fun subscribe(listener: MediaSessionEventListener) { - eventListenerList.add(listener) - - synchronized(this) { - if (cachedToken != null) - listener.onMediaSessionTokenCreated(cachedToken!!) - } - } - - fun unsubscribe(listener: MediaSessionEventListener) { - eventListenerList.remove(listener) - } - - fun releaseCachedMediaSessionToken() { - synchronized(this) { - cachedToken = null - } - } - - fun raiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) { - synchronized(this) { - cachedToken = token - eventListenerList.forEach { listener -> listener.onMediaSessionTokenCreated(token) } - } - } - - fun raisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) { - eventListenerList.forEach { - listener -> - listener.onPlayFromMediaIdRequested(mediaId, extras) - } - } - - fun raisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) { - eventListenerList.forEach { listener -> listener.onPlayFromSearchRequested(query, extras) } - } - - fun raiseSkipToQueueItemRequestedEvent(id: Long) { - eventListenerList.forEach { listener -> listener.onSkipToQueueItemRequested(id) } - } - - fun raiseMediaButtonEvent(keyEvent: KeyEvent?) { - eventListenerList.forEach { listener -> listener.onMediaButtonEvent(keyEvent) } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt deleted file mode 100644 index e4075248..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * MediaSessionEventListener.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.util - -import android.os.Bundle -import android.support.v4.media.session.MediaSessionCompat -import android.view.KeyEvent - -/** - * Callback interface for MediaSession related event subscribers - */ -interface MediaSessionEventListener { - fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {} - fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {} - fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {} - fun onSkipToQueueItemRequested(id: Long) {} - fun onMediaButtonEvent(keyEvent: KeyEvent?) {} -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt index d7ebf424..f7bdf220 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt @@ -17,18 +17,21 @@ import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN import android.view.KeyEvent +import io.reactivex.rxjava3.disposables.CompositeDisposable +import kotlin.Pair import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R -import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.imageloader.BitmapUtils import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver import org.moire.ultrasonic.service.DownloadFile +import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.service.plusAssign +import org.moire.ultrasonic.util.Util.ifNotNull import timber.log.Timber private const val INTENT_CODE_MEDIA_BUTTON = 161 -private const val CALL_DIVIDE = 10 /** * Central place to handle the state of the MediaSession */ @@ -39,21 +42,22 @@ class MediaSessionHandler : KoinComponent { private var playbackActions: Long? = null private var cachedPlayingIndex: Long? = null - private val mediaSessionEventDistributor by inject() private val applicationContext by inject() private var referenceCount: Int = 0 - private var cachedPlaylist: List? = null - private var playbackPositionDelayCount: Int = 0 + private var cachedPlaylist: List? = null private var cachedPosition: Long = 0 + private val rxBusSubscription: CompositeDisposable = CompositeDisposable() + fun release() { if (referenceCount > 0) referenceCount-- if (referenceCount > 0) return mediaSession?.isActive = false - mediaSessionEventDistributor.releaseCachedMediaSessionToken() + RxBus.releaseMediaSessionToken() + rxBusSubscription.dispose() mediaSession?.release() mediaSession = null @@ -72,7 +76,7 @@ class MediaSessionHandler : KoinComponent { mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") val mediaSessionToken = mediaSession?.sessionToken ?: return - mediaSessionEventDistributor.raiseMediaSessionTokenCreatedEvent(mediaSessionToken) + RxBus.mediaSessionTokenPublisher.onNext(mediaSessionToken) updateMediaButtonReceiver() @@ -93,14 +97,14 @@ class MediaSessionHandler : KoinComponent { super.onPlayFromMediaId(mediaId, extras) Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId) - mediaSessionEventDistributor.raisePlayFromMediaIdRequestedEvent(mediaId, extras) + RxBus.playFromMediaIdCommandPublisher.onNext(Pair(mediaId, extras)) } override fun onPlayFromSearch(query: String?, extras: Bundle?) { super.onPlayFromSearch(query, extras) Timber.d("Media Session Callback: onPlayFromSearch %s", query) - mediaSessionEventDistributor.raisePlayFromSearchRequestedEvent(query, extras) + RxBus.playFromSearchCommandPublisher.onNext(Pair(query, extras)) } override fun onPause() { @@ -147,28 +151,36 @@ class MediaSessionHandler : KoinComponent { // This probably won't be necessary once we implement more // of the modern media APIs, like the MediaController etc. val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent? - mediaSessionEventDistributor.raiseMediaButtonEvent(event) + event.ifNotNull { RxBus.mediaButtonEventPublisher.onNext(it) } return true } override fun onSkipToQueueItem(id: Long) { super.onSkipToQueueItem(id) - mediaSessionEventDistributor.raiseSkipToQueueItemRequestedEvent(id) + RxBus.skipToQueueItemCommandPublisher.onNext(id) } } ) // It seems to be the best practice to set this to true for the lifetime of the session mediaSession?.isActive = true - if (cachedPlaylist != null) setMediaSessionQueue(cachedPlaylist) + rxBusSubscription += RxBus.playbackPositionObservable.subscribe { + updateMediaSessionPlaybackPosition(it) + } + rxBusSubscription += RxBus.playlistObservable.subscribe { + updateMediaSessionQueue(it) + } + rxBusSubscription += RxBus.playerStateObservable.subscribe { + updateMediaSession(it.state, it.track) + } + Timber.i("MediaSessionHandler.initialize Media Session created") } - @Suppress("TooGenericExceptionCaught", "LongMethod") - fun updateMediaSession( - currentPlaying: DownloadFile?, - currentPlayingIndex: Long?, - playerState: PlayerState + @Suppress("LongMethod", "ComplexMethod") + private fun updateMediaSession( + playerState: PlayerState, + currentPlaying: DownloadFile? ) { Timber.d("Updating the MediaSession") @@ -187,8 +199,8 @@ class MediaSessionHandler : KoinComponent { metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album) metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title) metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover) - } catch (e: Exception) { - Timber.e(e, "Error setting the metadata") + } catch (all: Exception) { + Timber.e(all, "Error setting the metadata") } } @@ -244,59 +256,46 @@ class MediaSessionHandler : KoinComponent { // Set actions playbackStateBuilder.setActions(playbackActions!!) - cachedPlayingIndex = currentPlayingIndex - setMediaSessionQueue(cachedPlaylist) - if ( - currentPlayingIndex != null && cachedPlaylist != null && - !Settings.shouldDisableNowPlayingListSending - ) - playbackStateBuilder.setActiveQueueItemId(currentPlayingIndex) + val index = cachedPlaylist?.indexOf(currentPlaying) + cachedPlayingIndex = if (index == null || index < 0) null else index.toLong() + cachedPlaylist.ifNotNull { setMediaSessionQueue(it) } + + if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending) + cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) } // Save the playback state mediaSession?.setPlaybackState(playbackStateBuilder.build()) } - fun updateMediaSessionQueue(playlist: Iterable) { - // This call is cached because Downloader may initialize earlier than the MediaSession - cachedPlaylist = playlist.mapIndexed { id, song -> - MediaSessionCompat.QueueItem( - Util.getMediaDescriptionForEntry(song), - id.toLong() - ) - } - setMediaSessionQueue(cachedPlaylist) + private fun updateMediaSessionQueue(playlist: List) { + cachedPlaylist = playlist + setMediaSessionQueue(playlist) } - private fun setMediaSessionQueue(queue: List?) { + private fun setMediaSessionQueue(playlist: List) { if (mediaSession == null) return if (Settings.shouldDisableNowPlayingListSending) return + val queue = playlist.mapIndexed { id, file -> + MediaSessionCompat.QueueItem( + Util.getMediaDescriptionForEntry(file.song), + id.toLong() + ) + } mediaSession?.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing)) mediaSession?.setQueue(queue) } - fun updateMediaSessionPlaybackPosition(playbackPosition: Long) { - - cachedPosition = playbackPosition - if (mediaSession == null) return - + private fun updateMediaSessionPlaybackPosition(playbackPosition: Int) { + cachedPosition = playbackPosition.toLong() if (playbackState == null || playbackActions == null) return - // Playback position is updated too frequently in the player. - // This counter makes sure that the MediaSession is updated ~ at every second - playbackPositionDelayCount++ - if (playbackPositionDelayCount < CALL_DIVIDE) return - - playbackPositionDelayCount = 0 val playbackStateBuilder = PlaybackStateCompat.Builder() - playbackStateBuilder.setState(playbackState!!, playbackPosition, 1.0f) + playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f) playbackStateBuilder.setActions(playbackActions!!) - if ( - cachedPlayingIndex != null && cachedPlaylist != null && - !Settings.shouldDisableNowPlayingListSending - ) - playbackStateBuilder.setActiveQueueItemId(cachedPlayingIndex!!) + if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending) + cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) } mediaSession?.setPlaybackState(playbackStateBuilder.build()) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/NowPlayingEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/NowPlayingEventDistributor.kt deleted file mode 100644 index 0d19903e..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/NowPlayingEventDistributor.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.moire.ultrasonic.util - -/** - * This class distributes Now Playing related events to its subscribers. - * It is a primitive implementation of a pub-sub event bus - */ -class NowPlayingEventDistributor { - private var eventListenerList: MutableList = - listOf().toMutableList() - - fun subscribe(listener: NowPlayingEventListener) { - eventListenerList.add(listener) - } - - fun unsubscribe(listener: NowPlayingEventListener) { - eventListenerList.remove(listener) - } - - fun raiseShowNowPlayingEvent() { - eventListenerList.forEach { listener -> listener.onShowNowPlaying() } - } - - fun raiseHideNowPlayingEvent() { - eventListenerList.forEach { listener -> listener.onHideNowPlaying() } - } - - fun raiseNowPlayingDismissedEvent() { - eventListenerList.forEach { listener -> listener.onDismissNowPlaying() } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/NowPlayingEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/NowPlayingEventListener.kt deleted file mode 100644 index 3f4bd75e..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/NowPlayingEventListener.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.moire.ultrasonic.util - -/** - * Callback interface for Now Playing event subscribers - */ -interface NowPlayingEventListener { - fun onDismissNowPlaying() - fun onHideNowPlaying() - fun onShowNowPlaying() -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ThemeChangedEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ThemeChangedEventDistributor.kt deleted file mode 100644 index bdce05ab..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ThemeChangedEventDistributor.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.moire.ultrasonic.util - -/** - * This class distributes Theme change related events to its subscribers. - * It is a primitive implementation of a pub-sub event bus - */ -class ThemeChangedEventDistributor { - var eventListenerList: MutableList = - listOf().toMutableList() - - fun subscribe(listener: ThemeChangedEventListener) { - eventListenerList.add(listener) - } - - fun unsubscribe(listener: ThemeChangedEventListener) { - eventListenerList.remove(listener) - } - - fun RaiseThemeChangedEvent() { - eventListenerList.forEach { listener -> listener.onThemeChanged() } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ThemeChangedEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ThemeChangedEventListener.kt deleted file mode 100644 index 5656f1d4..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ThemeChangedEventListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.moire.ultrasonic.util - -/** - * Callback interface for Theme change event subscribers - */ -interface ThemeChangedEventListener { - fun onThemeChanged() -} 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 d8366c5e..baf983a4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -538,7 +538,6 @@ object Util { } /** - * * Broadcasts the given song info as the new song being played. */ fun broadcastNewTrackInfo(context: Context, song: MusicDirectory.Entry?) { @@ -957,6 +956,14 @@ object Util { return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } + /** + * Executes the given block if this is not null. + * @return: the return of the block, or null if this is null + */ + fun T?.ifNotNull(block: (T) -> R): R? { + return this?.let(block) + } + data class NetworkInfo( var connected: Boolean = false, var unmetered: Boolean = false