diff --git a/dependencies.gradle b/dependencies.gradle index 42ae817e..4aae7cf4 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -42,7 +42,9 @@ ext.versions = [ timber : "4.7.1", fastScroll : "2.0.1", colorPicker : "2.2.3", - fsaf : "1.1" + fsaf : "1.1", + rxJava : "3.1.2", + rxAndroid : "3.0.0", ] ext.gradlePlugins = [ @@ -91,6 +93,8 @@ ext.other = [ sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView", colorPickerView : "com.github.skydoves:colorpickerview:$versions.colorPicker", fsaf : "com.github.K1rakishou:Fuck-Storage-Access-Framework:$versions.fsaf", + rxJava : "io.reactivex.rxjava3:rxjava:$versions.rxJava", + rxAndroid : "io.reactivex.rxjava3:rxandroid:$versions.rxAndroid", ] ext.testing = [ diff --git a/detekt-baseline.xml b/detekt-baseline.xml index 8adb941d..d983b21f 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -3,11 +3,12 @@ ComplexCondition:DownloadHandler.kt$DownloadHandler.<no name provided>$!append && !playNext && !unpin && !background + ComplexCondition:FilePickerAdapter.kt$FilePickerAdapter$currentDirectory.absolutePath == "/" || currentDirectory.absolutePath == "/storage" || currentDirectory.absolutePath == "/storage/emulated" || currentDirectory.absolutePath == "/mnt" ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute() + ComplexMethod:FilePickerAdapter.kt$FilePickerAdapter$private fun fileLister(currentDirectory: File) ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean) ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons() ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory) - FunctionNaming:ThemeChangedEventDistributor.kt$ThemeChangedEventDistributor$fun RaiseThemeChangedEvent() ImplicitDefaultLocale:DownloadFile.kt$DownloadFile$String.format("DownloadFile (%s)", song) ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("Download of '%s' was cancelled", song) ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("DownloadTask (%s)", song) @@ -49,7 +50,6 @@ NestedBlockDepth:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute() NestedBlockDepth:DownloadHandler.kt$DownloadHandler$private fun downloadRecursively( fragment: Fragment, id: String, name: String?, isShare: Boolean, isDirectory: Boolean, save: Boolean, append: Boolean, autoPlay: Boolean, shuffle: Boolean, background: Boolean, playNext: Boolean, unpin: Boolean, isArtist: Boolean ) NestedBlockDepth:MediaPlayerService.kt$MediaPlayerService$private fun setupOnSongCompletedHandler() - ReturnCount:CommunicationErrorHandler.kt$CommunicationErrorHandler.Companion$fun getErrorMessage(error: Throwable, context: Context): String ReturnCount:ServerRowAdapter.kt$ServerRowAdapter$ private fun popupMenuItemClick(menuItem: MenuItem, position: Int): Boolean ReturnCount:TrackCollectionFragment.kt$TrackCollectionFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean TooGenericExceptionCaught:DownloadFile.kt$DownloadFile$e: Exception @@ -59,12 +59,10 @@ TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$x: Exception TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer.PositionCache$e: Exception TooGenericExceptionCaught:SongView.kt$SongView$e: Exception - TooGenericExceptionCaught:SubsonicUncaughtExceptionHandler.kt$SubsonicUncaughtExceptionHandler$x: Throwable TooGenericExceptionThrown:DownloadFile.kt$DownloadFile.DownloadTask$throw Exception(String.format("Download of '%s' was cancelled", song)) TooManyFunctions:MediaPlayerService.kt$MediaPlayerService : Service TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService TooManyFunctions:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment - UtilityClassWithPublicConstructor:CommunicationErrorHandler.kt$CommunicationErrorHandler UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 017fa798..2895de49 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -107,6 +107,8 @@ dependencies { implementation other.sortListView implementation other.colorPickerView implementation other.fsaf + 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/java/org/moire/ultrasonic/util/BackgroundTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java index 3bd56c61..8ae51dee 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java @@ -20,7 +20,6 @@ package org.moire.ultrasonic.util; import android.app.Activity; import android.os.Handler; -import org.moire.ultrasonic.service.CommunicationErrorHandler; /** * @author Sindre Mehus @@ -54,12 +53,12 @@ public abstract class BackgroundTask implements ProgressListener protected void error(Throwable error) { - CommunicationErrorHandler.Companion.handleError(error, activity); + CommunicationError.handleError(error, activity); } protected String getErrorMessage(Throwable error) { - return CommunicationErrorHandler.Companion.getErrorMessage(error, activity); + return CommunicationError.getErrorMessage(error, activity); } @Override diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/MyViewFlipper.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/MyViewFlipper.java deleted file mode 100644 index 3d33e442..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/MyViewFlipper.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.ViewFlipper; - -/** - * Work-around for Android Issue 6191 (http://code.google.com/p/android/issues/detail?id=6191) - * - * @author Sindre Mehus - * @version $Id$ - */ -public class MyViewFlipper extends ViewFlipper -{ - - public MyViewFlipper(Context context) - { - super(context); - } - - public MyViewFlipper(Context context, AttributeSet attrs) - { - super(context, attrs); - } - - @Override - protected void onDetachedFromWindow() - { - try - { - super.onDetachedFromWindow(); - } - catch (IllegalArgumentException e) - { - // Call stopFlipping() in order to kick off updateRunning() - stopFlipping(); - } - } -} - 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 1b5a92e9..55d52083 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,15 +44,12 @@ 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.NowPlayingEventDistributor -import org.moire.ultrasonic.util.NowPlayingEventListener 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.UncaughtExceptionHandler import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -73,15 +71,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 activeServerProvider: ActiveServerProvider by inject() private val serverRepository: ServerSettingDao by inject() @@ -166,28 +162,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 -> @@ -234,8 +224,8 @@ class NavigationActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() - nowPlayingEventDistributor.unsubscribe(nowPlayingEventListener) - themeChangedEventDistributor.unsubscribe(themeChangedEventListener) + themeChangedEventSubscription?.dispose() + playerStateSubscription?.dispose() imageLoaderProvider.clearImageLoader() } @@ -382,8 +372,8 @@ class NavigationActivity : AppCompatActivity() { private fun setUncaughtExceptionHandler() { val handler = Thread.getDefaultUncaughtExceptionHandler() - if (handler !is SubsonicUncaughtExceptionHandler) { - Thread.setDefaultUncaughtExceptionHandler(SubsonicUncaughtExceptionHandler(this)) + if (handler !is UncaughtExceptionHandler) { + Thread.setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler(this)) } } 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 044235b0..3d0298e5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -4,10 +4,7 @@ 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.ThemeChangedEventDistributor /** * This Koin module contains the registration of general classes needed for Ultrasonic @@ -15,8 +12,5 @@ import org.moire.ultrasonic.util.ThemeChangedEventDistributor val applicationModule = module { single { ActiveServerProvider(get()) } single { ImageLoaderProvider(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/GenericListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt index 693923ea..5ec1db0e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt @@ -18,9 +18,9 @@ import org.koin.core.component.inject import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.domain.MusicFolder -import org.moire.ultrasonic.service.CommunicationErrorHandler import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicServiceFactory +import org.moire.ultrasonic.util.CommunicationError import org.moire.ultrasonic.util.Settings /** @@ -94,7 +94,7 @@ open class GenericListModel(application: Application) : private fun handleException(exception: Exception, context: Context) { Handler(Looper.getMainLooper()).post { - CommunicationErrorHandler.handleError(exception, context) + CommunicationError.handleError(exception, context) } } 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..7556d148 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -38,17 +38,22 @@ import androidx.fragment.app.Fragment 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 import java.util.Date -import java.util.LinkedList import java.util.Locale +import java.util.concurrent.CancellationException import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import kotlin.math.abs import kotlin.math.max +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.core.component.KoinComponent import org.koin.core.component.get @@ -66,13 +71,14 @@ 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 import org.moire.ultrasonic.util.CancellationToken +import org.moire.ultrasonic.util.CommunicationError import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.SilentBackgroundTask import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.view.AutoRepeatButton import org.moire.ultrasonic.view.SongListAdapter @@ -81,15 +87,13 @@ import timber.log.Timber /** * Contains the Music Player screen of Ultrasonic with playback controls and the playlist - * - * TODO: This class was more or less straight converted from Java legacy code. - * There are many places where further cleanup would be nice. - * The usage of threads and SilentBackgroundTask can be replaced with Coroutines. */ @Suppress("LargeClass", "TooManyFunctions", "MagicNumber") -class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinComponent { - // Settings - private var currentRevision: Long = 0 +class PlayerFragment : + Fragment(), + GestureDetector.OnGestureListener, + KoinComponent, + CoroutineScope by CoroutineScope(Dispatchers.Main) { private var swipeDistance = 0 private var swipeVelocity = 0 private var jukeboxAvailable = false @@ -110,7 +114,8 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon private lateinit var executorService: ScheduledExecutorService private var currentPlaying: DownloadFile? = null private var currentSong: MusicDirectory.Entry? = null - private var onProgressChangedTask: SilentBackgroundTask? = null + private var rxBusSubscription: Disposable? = null + private var ioScope = CoroutineScope(Dispatchers.IO) // Views and UI Elements private lateinit var visualizerViewLayout: LinearLayout @@ -230,17 +235,11 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon previousButton.setOnClickListener { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - object : SilentBackgroundTask(activity) { - override fun doInBackground(): Void? { - mediaPlayerController.previous() - return null - } - - override fun done(result: Void?) { - onCurrentChanged() - onSliderProgressChanged() - } - }.execute() + launch(CommunicationError.getHandler(context)) { + mediaPlayerController.previous() + onCurrentChanged() + onSliderProgressChanged() + } } previousButton.setOnRepeatListener { @@ -250,65 +249,43 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon nextButton.setOnClickListener { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - object : SilentBackgroundTask(activity) { - override fun doInBackground(): Boolean { - mediaPlayerController.next() - return true - } - - override fun done(result: Boolean?) { - if (result == true) { - onCurrentChanged() - onSliderProgressChanged() - } - } - }.execute() + launch(CommunicationError.getHandler(context)) { + mediaPlayerController.next() + onCurrentChanged() + onSliderProgressChanged() + } } nextButton.setOnRepeatListener { val incrementTime = Settings.incrementTime changeProgress(incrementTime) } + pauseButton.setOnClickListener { - object : SilentBackgroundTask(activity) { - override fun doInBackground(): Void? { - mediaPlayerController.pause() - return null - } - - override fun done(result: Void?) { - onCurrentChanged() - onSliderProgressChanged() - } - }.execute() + launch(CommunicationError.getHandler(context)) { + mediaPlayerController.pause() + onCurrentChanged() + onSliderProgressChanged() + } } + stopButton.setOnClickListener { - object : SilentBackgroundTask(activity) { - override fun doInBackground(): Void? { - mediaPlayerController.reset() - return null - } - - override fun done(result: Void?) { - onCurrentChanged() - onSliderProgressChanged() - } - }.execute() + launch(CommunicationError.getHandler(context)) { + mediaPlayerController.reset() + onCurrentChanged() + onSliderProgressChanged() + } } + startButton.setOnClickListener { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - object : SilentBackgroundTask(activity) { - override fun doInBackground(): Void? { - start() - return null - } - - override fun done(result: Void?) { - onCurrentChanged() - onSliderProgressChanged() - } - }.execute() + launch(CommunicationError.getHandler(context)) { + start() + onCurrentChanged() + onSliderProgressChanged() + } } + shuffleButton.setOnClickListener { mediaPlayerController.shuffle() Util.toast(activity, R.string.download_menu_shuffle_notification) @@ -335,16 +312,10 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { override fun onStopTrackingTouch(seekBar: SeekBar) { - object : SilentBackgroundTask(activity) { - override fun doInBackground(): Void? { - mediaPlayerController.seekTo(progressBar.progress) - return null - } - - override fun done(result: Void?) { - onSliderProgressChanged() - } - }.execute() + launch(CommunicationError.getHandler(context)) { + mediaPlayerController.seekTo(progressBar.progress) + onSliderProgressChanged() + } } override fun onStartTrackingTouch(seekBar: SeekBar) {} @@ -353,18 +324,13 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon playlistView.setOnItemClickListener { _, _, position, _ -> networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - object : SilentBackgroundTask(activity) { - override fun doInBackground(): Void? { - mediaPlayerController.play(position) - return null - } - - override fun done(result: Void?) { - onCurrentChanged() - onSliderProgressChanged() - } - }.execute() + launch(CommunicationError.getHandler(context)) { + mediaPlayerController.play(position) + onCurrentChanged() + onSliderProgressChanged() + } } + registerForContextMenu(playlistView) if (arguments != null && requireArguments().getBoolean( @@ -419,13 +385,21 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon } } ) - Thread { + + // Observe playlist changes and update the UI + rxBusSubscription = RxBus.playlistObservable.subscribe { + onPlaylistChanged() + } + + // Query the Jukebox state in an IO Context + ioScope.launch(CommunicationError.getHandler(context)) { try { jukeboxAvailable = mediaPlayerController.isJukeboxAvailable } catch (all: Exception) { Timber.e(all) } - }.start() + } + view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) } } @@ -479,6 +453,8 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon } override fun onDestroyView() { + rxBusSubscription?.dispose() + cancel("CoroutineScope cancelled because the view was destroyed") cancellationToken.cancel() super.onDestroyView() } @@ -797,9 +773,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() } @@ -810,33 +783,28 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon private fun savePlaylistInBackground(playlistName: String) { Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName)) mediaPlayerController.suggestedPlaylistName = playlistName - object : SilentBackgroundTask(activity) { - @Throws(Throwable::class) - override fun doInBackground(): Void? { - val entries: MutableList = LinkedList() - for (downloadFile in mediaPlayerController.playList) { - entries.add(downloadFile.song) - } - val musicService = getMusicService() - musicService.createPlaylist(null, playlistName, entries) - return null - } - override fun done(result: Void?) { + ioScope.launch { + + val entries = mediaPlayerController.playList.map { + it.song + } + val musicService = getMusicService() + musicService.createPlaylist(null, playlistName, entries) + }.invokeOnCompletion { + if (it == null || it is CancellationException) { Util.toast(context, R.string.download_playlist_done) - } - - override fun error(error: Throwable) { - Timber.e(error, "Exception has occurred in savePlaylistInBackground") + } else { + Timber.e(it, "Exception has occurred in savePlaylistInBackground") val msg = String.format( Locale.ROOT, "%s %s", resources.getString(R.string.download_playlist_error), - getErrorMessage(error) + CommunicationError.getErrorMessage(it, context) ) Util.toast(context, msg) } - }.execute() + } } private fun toggleFullScreenAlbumArt() { @@ -914,7 +882,6 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon emptyTextView.isVisible = list.isEmpty() - currentRevision = mediaPlayerController.playListUpdateRevision when (mediaPlayerController.repeatMode) { RepeatMode.OFF -> repeatButton.setImageDrawable( Util.getDrawableFromAttribute( @@ -967,120 +934,95 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon } } + @Suppress("LongMethod", "ComplexMethod") + @Synchronized private fun onSliderProgressChanged() { - if (onProgressChangedTask != null) { - return + + val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled + val millisPlayed: Int = max(0, mediaPlayerController.playerPosition) + val duration: Int = mediaPlayerController.playerDuration + val playerState: PlayerState = mediaPlayerController.playerState + + if (cancellationToken.isCancellationRequested) return + if (currentPlaying != null) { + positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true) + durationTextView.text = Util.formatTotalDuration(duration.toLong(), true) + progressBar.max = + if (duration == 0) 100 else duration // Work-around for apparent bug. + progressBar.progress = millisPlayed + progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled + } else { + positionTextView.setText(R.string.util_zero_time) + durationTextView.setText(R.string.util_no_time) + progressBar.progress = 0 + progressBar.max = 0 + progressBar.isEnabled = false } - onProgressChangedTask = object : SilentBackgroundTask(activity) { - var isJukeboxEnabled = false - var millisPlayed = 0 - var duration: Int? = null - var playerState: PlayerState? = null - override fun doInBackground(): Void? { - isJukeboxEnabled = mediaPlayerController.isJukeboxEnabled - millisPlayed = max(0, mediaPlayerController.playerPosition) - duration = mediaPlayerController.playerDuration - playerState = mediaPlayerController.playerState - return null + + when (playerState) { + PlayerState.DOWNLOADING -> { + val progress = + if (currentPlaying != null) currentPlaying!!.progress.value!! else 0 + val downloadStatus = resources.getString( + R.string.download_playerstate_downloading, + Util.formatPercentage(progress) + ) + setTitle(this@PlayerFragment, downloadStatus) } - - @Suppress("LongMethod") - override fun done(result: Void?) { - if (cancellationToken.isCancellationRequested) return - if (currentPlaying != null) { - val millisTotal = if (duration == null) 0 else duration!! - positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true) - durationTextView.text = Util.formatTotalDuration(millisTotal.toLong(), true) - progressBar.max = - if (millisTotal == 0) 100 else millisTotal // Work-around for apparent bug. - progressBar.progress = millisPlayed - progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled - } else { - positionTextView.setText(R.string.util_zero_time) - durationTextView.setText(R.string.util_no_time) - progressBar.progress = 0 - progressBar.max = 0 - progressBar.isEnabled = false - } - - when (playerState) { - PlayerState.DOWNLOADING -> { - val progress = - if (currentPlaying != null) currentPlaying!!.progress.value!! else 0 - val downloadStatus = resources.getString( - R.string.download_playerstate_downloading, - Util.formatPercentage(progress) - ) - setTitle(this@PlayerFragment, downloadStatus) - } - PlayerState.PREPARING -> setTitle( + PlayerState.PREPARING -> setTitle( + this@PlayerFragment, + R.string.download_playerstate_buffering + ) + PlayerState.STARTED -> { + if (mediaPlayerController.isShufflePlayEnabled) { + setTitle( this@PlayerFragment, - R.string.download_playerstate_buffering + R.string.download_playerstate_playing_shuffle ) - PlayerState.STARTED -> { - if (mediaPlayerController.isShufflePlayEnabled) { - setTitle( - this@PlayerFragment, - R.string.download_playerstate_playing_shuffle - ) - } else { - setTitle(this@PlayerFragment, R.string.common_appname) - } - } - PlayerState.IDLE, - PlayerState.PREPARED, - PlayerState.STOPPED, - PlayerState.PAUSED, - PlayerState.COMPLETED -> { - } - else -> setTitle(this@PlayerFragment, R.string.common_appname) + } else { + setTitle(this@PlayerFragment, R.string.common_appname) } + } + PlayerState.IDLE, + PlayerState.PREPARED, + PlayerState.STOPPED, + PlayerState.PAUSED, + PlayerState.COMPLETED -> { + } + else -> setTitle(this@PlayerFragment, R.string.common_appname) + } - when (playerState) { - PlayerState.STARTED -> { - pauseButton.isVisible = true - stopButton.isVisible = false - startButton.isVisible = false - } - PlayerState.DOWNLOADING, PlayerState.PREPARING -> { - pauseButton.isVisible = false - stopButton.isVisible = true - startButton.isVisible = false - } - else -> { - pauseButton.isVisible = false - stopButton.isVisible = false - startButton.isVisible = true - } - } - - // TODO: It would be a lot nicer if MediaPlayerController would send an event - // when this is necessary instead of updating every time - displaySongRating() - onProgressChangedTask = null + when (playerState) { + PlayerState.STARTED -> { + pauseButton.isVisible = true + stopButton.isVisible = false + startButton.isVisible = false + } + PlayerState.DOWNLOADING, PlayerState.PREPARING -> { + pauseButton.isVisible = false + stopButton.isVisible = true + startButton.isVisible = false + } + else -> { + pauseButton.isVisible = false + stopButton.isVisible = false + startButton.isVisible = true } } - onProgressChangedTask!!.execute() + + // TODO: It would be a lot nicer if MediaPlayerController would send an event + // when this is necessary instead of updating every time + displaySongRating() } private fun changeProgress(ms: Int) { - object : SilentBackgroundTask(activity) { - var msPlayed = 0 - var duration: Int? = null - var seekTo = 0 - override fun doInBackground(): Void? { - msPlayed = max(0, mediaPlayerController.playerPosition) - duration = mediaPlayerController.playerDuration - val msTotal = duration!! - seekTo = (msPlayed + ms).coerceAtMost(msTotal) - mediaPlayerController.seekTo(seekTo) - return null - } - - override fun done(result: Void?) { - progressBar.progress = seekTo - } - }.execute() + launch(CommunicationError.getHandler(context)) { + val msPlayed: Int = max(0, mediaPlayerController.playerPosition) + val duration = mediaPlayerController.playerDuration + val seekTo = (msPlayed + ms).coerceAtMost(duration) + mediaPlayerController.seekTo(seekTo) + progressBar.progress = seekTo + } } override fun onDown(me: MotionEvent): Boolean { 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 6a338301..a6d421cb 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -37,6 +37,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.ultrasonicDirectory @@ -45,7 +46,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 @@ -93,9 +93,6 @@ class SettingsFragment : private val mediaPlayerControllerLazy = inject( MediaPlayerController::class.java ) - private val themeChangedEventDistributor = inject( - ThemeChangedEventDistributor::class.java - ) private val mediaSessionHandler = inject( MediaSessionHandler::class.java ) @@ -225,7 +222,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..6394b08a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -38,7 +38,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.fragment.FragmentTitle.Companion.getTitle import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle -import org.moire.ultrasonic.service.CommunicationErrorHandler import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.subsonic.ImageLoaderProvider @@ -47,6 +46,7 @@ import org.moire.ultrasonic.subsonic.ShareHandler import org.moire.ultrasonic.subsonic.VideoPlayer import org.moire.ultrasonic.util.AlbumHeader import org.moire.ultrasonic.util.CancellationToken +import org.moire.ultrasonic.util.CommunicationError import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.Settings @@ -211,7 +211,7 @@ class TrackCollectionFragment : Fragment() { val handler = CoroutineExceptionHandler { _, exception -> Handler(Looper.getMainLooper()).post { - context?.let { CommunicationErrorHandler.handleError(exception, it) } + CommunicationError.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 deleted file mode 100644 index 1b876fb5..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CommunicationErrorHandler.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2020 (C) Jozsef Varga - */ -package org.moire.ultrasonic.service - -import android.app.AlertDialog -import android.content.Context -import com.fasterxml.jackson.core.JsonParseException -import java.io.FileNotFoundException -import java.io.IOException -import java.security.cert.CertPathValidatorException -import java.security.cert.CertificateException -import javax.net.ssl.SSLException -import org.moire.ultrasonic.R -import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException -import org.moire.ultrasonic.api.subsonic.SubsonicRESTException -import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage -import org.moire.ultrasonic.util.Util -import timber.log.Timber - -/** - * Contains helper functions to handle the exceptions - * thrown during the communication with a Subsonic server - */ -class CommunicationErrorHandler { - companion object { - fun handleError(error: Throwable?, context: Context) { - Timber.w(error) - - AlertDialog.Builder(context) - .setIcon(android.R.drawable.ic_dialog_alert) - .setTitle(R.string.error_label) - .setMessage(getErrorMessage(error!!, context)) - .setCancelable(true) - .setPositiveButton(R.string.common_ok) { _, _ -> } - .create().show() - } - - fun getErrorMessage(error: Throwable, context: Context): String { - if (error is IOException && !Util.isNetworkConnected()) { - return context.resources.getString(R.string.background_task_no_network) - } else if (error is FileNotFoundException) { - return context.resources.getString(R.string.background_task_not_found) - } else if (error is JsonParseException) { - return context.resources.getString(R.string.background_task_parse_error) - } else if (error is SSLException) { - return if ( - error.cause is CertificateException && - error.cause?.cause is CertPathValidatorException - ) { - context.resources - .getString( - R.string.background_task_ssl_cert_error, error.cause?.cause?.message - ) - } else { - context.resources.getString(R.string.background_task_ssl_error) - } - } else if (error is ApiNotSupportedException) { - return context.resources.getString( - R.string.background_task_unsupported_api, error.serverApiVersion - ) - } else if (error is IOException) { - return context.resources.getString(R.string.background_task_network_error) - } else if (error is SubsonicRESTException) { - return error.getLocalizedErrorMessage(context) - } - val message = error.message - return message ?: error.javaClass.simpleName - } - } -} 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 2dc5539b..f4ef95fb 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -32,12 +32,10 @@ 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 import timber.log.Timber -import java.io.File /** * Represents a Media Player which uses the mobile's resources for playback @@ -47,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 @@ -65,6 +56,7 @@ class LocalMediaPlayer : KoinComponent { var onNextSongRequested: Runnable? = null @JvmField + @Volatile var playerState = PlayerState.IDLE @JvmField @@ -133,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) @@ -165,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) @@ -195,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)) } /* @@ -263,7 +246,7 @@ class LocalMediaPlayer : KoinComponent { mediaPlayer = nextMediaPlayer!! setCurrentPlaying(nextPlaying) - setPlayerState(PlayerState.STARTED) + setPlayerState(PlayerState.STARTED, currentPlaying) attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false) @@ -344,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() @@ -355,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... @@ -370,7 +354,6 @@ class LocalMediaPlayer : KoinComponent { // downloadFile.updateModificationDate() mediaPlayer.setOnCompletionListener(null) - setPlayerState(PlayerState.IDLE) setAudioAttributes(mediaPlayer) var dataSource: String? = null @@ -403,7 +386,7 @@ class LocalMediaPlayer : KoinComponent { descriptor.close() } - setPlayerState(PlayerState.PREPARING) + setPlayerState(PlayerState.PREPARING, downloadFile) mediaPlayer.setOnBufferingUpdateListener { mp, percent -> val song = downloadFile.song @@ -421,7 +404,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) { @@ -436,9 +419,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) } } @@ -446,6 +429,7 @@ class LocalMediaPlayer : KoinComponent { onPrepared } } + attachHandlersToPlayer(mediaPlayer, downloadFile, partial) mediaPlayer.prepareAsync() } catch (x: Exception) { @@ -541,7 +525,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 @@ -588,7 +572,7 @@ class LocalMediaPlayer : KoinComponent { resetMediaPlayer() try { - setPlayerState(PlayerState.IDLE) + setPlayerState(PlayerState.IDLE, currentPlaying) mediaPlayer.setOnErrorListener(null) mediaPlayer.setOnCompletionListener(null) } catch (x: Exception) { @@ -617,7 +601,7 @@ class LocalMediaPlayer : KoinComponent { private val partialFile: String = downloadFile.partialFile override fun execute() { - setPlayerState(PlayerState.DOWNLOADING) + setPlayerState(PlayerState.DOWNLOADING, downloadFile) while (!bufferComplete() && !isOffline()) { Util.sleepQuietly(1000L) if (isCancelled) { @@ -720,10 +704,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..e3322213 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 @@ -417,6 +413,10 @@ class MediaPlayerController( } } + /** + * This function calls the music service directly and + * therefore can't be called from the main thread + */ val isJukeboxAvailable: Boolean get() { try { @@ -479,6 +479,7 @@ class MediaPlayerController( Timber.e(e) } }.start() + // TODO this would be better handled with a Rx command updateNotification() } @@ -490,16 +491,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/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index 4f6f0598..1d6e9e33 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -1,10 +1,13 @@ package org.moire.ultrasonic.util -import android.os.AsyncTask import android.os.StatFs import android.system.Os import java.util.ArrayList import java.util.HashSet +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.inject import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Playlist @@ -22,105 +25,85 @@ import timber.log.Timber /** * Responsible for cleaning up files from the offline download cache on the filesystem. */ -class CacheCleaner { +class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { + + private fun exceptionHandler(tag: String): CoroutineExceptionHandler { + return CoroutineExceptionHandler { _, exception -> + Timber.w(exception, "Exception in CacheCleaner.$tag") + } + } + fun clean() { - try { - BackgroundCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) - } catch (all: Exception) { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(all, "Exception in CacheCleaner.clean") + launch(exceptionHandler("clean")) { + backgroundCleanup() } } fun cleanSpace() { - try { - BackgroundSpaceCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) - } catch (all: Exception) { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(all, "Exception in CacheCleaner.cleanSpace") + launch(exceptionHandler("cleanSpace")) { + backgroundSpaceCleanup() } } fun cleanPlaylists(playlists: List) { - try { - BackgroundPlaylistsCleanup().executeOnExecutor( - AsyncTask.THREAD_POOL_EXECUTOR, - playlists - ) - } catch (all: Exception) { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(all, "Exception in CacheCleaner.cleanPlaylists") + launch(exceptionHandler("cleanPlaylists")) { + backgroundPlaylistsCleanup(playlists) } } - private class BackgroundCleanup : AsyncTask() { - override fun doInBackground(vararg params: Void?): Void? { - try { - Thread.currentThread().name = "BackgroundCleanup" - val files: MutableList = ArrayList() - val dirs: MutableList = ArrayList() + private fun backgroundCleanup() { + try { + val files: MutableList = ArrayList() + val dirs: MutableList = ArrayList() - findCandidatesForDeletion(musicDirectory, files, dirs) + findCandidatesForDeletion(musicDirectory, files, dirs) + sortByAscendingModificationTime(files) + val filesToNotDelete = findFilesToNotDelete() + deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true) + deleteEmptyDirs(dirs, filesToNotDelete) + } catch (all: RuntimeException) { + Timber.e(all, "Error in cache cleaning.") + } + } + + private fun backgroundSpaceCleanup() { + try { + val files: MutableList = ArrayList() + val dirs: MutableList = ArrayList() + + findCandidatesForDeletion(musicDirectory, files, dirs) + + val bytesToDelete = getMinimumDelete(files) + if (bytesToDelete > 0L) { sortByAscendingModificationTime(files) val filesToNotDelete = findFilesToNotDelete() - - deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true) - deleteEmptyDirs(dirs, filesToNotDelete) - } catch (all: RuntimeException) { - Timber.e(all, "Error in cache cleaning.") + deleteFiles(files, filesToNotDelete, bytesToDelete, false) } - return null + } catch (all: RuntimeException) { + Timber.e(all, "Error in cache cleaning.") } } - private class BackgroundSpaceCleanup : AsyncTask() { - override fun doInBackground(vararg params: Void?): Void? { - try { - Thread.currentThread().name = "BackgroundSpaceCleanup" + private fun backgroundPlaylistsCleanup(vararg params: List) { + try { + val activeServerProvider = inject( + ActiveServerProvider::class.java + ) - val files: MutableList = ArrayList() - val dirs: MutableList = ArrayList() + val server = activeServerProvider.value.getActiveServer().name + val playlistFiles = listFiles(getPlaylistDirectory(server)) + val playlists = params[0] - findCandidatesForDeletion(musicDirectory, files, dirs) - - val bytesToDelete = getMinimumDelete(files) - - if (bytesToDelete > 0L) { - sortByAscendingModificationTime(files) - val filesToNotDelete = findFilesToNotDelete() - deleteFiles(files, filesToNotDelete, bytesToDelete, false) - } - } catch (all: RuntimeException) { - Timber.e(all, "Error in cache cleaning.") + for ((_, name) in playlists) { + playlistFiles.remove(getPlaylistFile(server, name)) } - return null - } - } - private class BackgroundPlaylistsCleanup : AsyncTask, Void?, Void?>() { - override fun doInBackground(vararg params: List): Void? { - try { - val activeServerProvider = inject( - ActiveServerProvider::class.java - ) - Thread.currentThread().name = "BackgroundPlaylistsCleanup" - - val server = activeServerProvider.value.getActiveServer().name - val playlistFiles = listFiles(getPlaylistDirectory(server)) - val playlists = params[0] - - for ((_, name) in playlists) { - playlistFiles.remove(getPlaylistFile(server, name)) - } - - for (playlist in playlistFiles) { - playlist.delete() - } - } catch (all: RuntimeException) { - Timber.e(all, "Error in playlist cache cleaning.") + for (playlist in playlistFiles) { + playlist.delete() } - return null + } catch (all: RuntimeException) { + Timber.e(all, "Error in playlist cache cleaning.") } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CommunicationError.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CommunicationError.kt new file mode 100644 index 00000000..2a722e26 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CommunicationError.kt @@ -0,0 +1,91 @@ +/* + * CommunicationErrorUtil.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ +package org.moire.ultrasonic.util + +import android.app.AlertDialog +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.fasterxml.jackson.core.JsonParseException +import java.io.FileNotFoundException +import java.io.IOException +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateException +import javax.net.ssl.SSLException +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineExceptionHandler +import org.moire.ultrasonic.R +import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException +import org.moire.ultrasonic.api.subsonic.SubsonicRESTException +import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage +import timber.log.Timber + +/** + * Contains helper functions to handle the exceptions + * thrown during the communication with a Subsonic server + */ +object CommunicationError { + fun getHandler(context: Context?, handler: ((CoroutineContext, Throwable) -> Unit)? = null): + CoroutineExceptionHandler { + return CoroutineExceptionHandler { coroutineContext, exception -> + Handler(Looper.getMainLooper()).post { + handleError(exception, context) + handler?.invoke(coroutineContext, exception) + } + } + } + + @JvmStatic + 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) + .setMessage(getErrorMessage(error!!, context)) + .setCancelable(true) + .setPositiveButton(R.string.common_ok) { _, _ -> } + .create().show() + } + + @JvmStatic + @Suppress("ReturnCount") + fun getErrorMessage(error: Throwable, context: Context?): String { + if (context == null) return "Couldn't get Error message, Context is null" + if (error is IOException && !Util.isNetworkConnected()) { + return context.resources.getString(R.string.background_task_no_network) + } else if (error is FileNotFoundException) { + return context.resources.getString(R.string.background_task_not_found) + } else if (error is JsonParseException) { + return context.resources.getString(R.string.background_task_parse_error) + } else if (error is SSLException) { + return if ( + error.cause is CertificateException && + error.cause?.cause is CertPathValidatorException + ) { + context.resources + .getString( + R.string.background_task_ssl_cert_error, error.cause?.cause?.message + ) + } else { + context.resources.getString(R.string.background_task_ssl_error) + } + } else if (error is ApiNotSupportedException) { + return context.resources.getString( + R.string.background_task_unsupported_api, error.serverApiVersion + ) + } else if (error is IOException) { + return context.resources.getString(R.string.background_task_network_error) + } else if (error is SubsonicRESTException) { + return error.getLocalizedErrorMessage(context) + } + val message = error.message + return message ?: error.javaClass.simpleName + } +} 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/SilentBackgroundTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SilentBackgroundTask.kt deleted file mode 100644 index 3639aa2c..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SilentBackgroundTask.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SilentBackgroundTask.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.util - -import android.app.Activity - -/** - * @author Sindre Mehus - */ -abstract class SilentBackgroundTask(activity: Activity?) : BackgroundTask(activity) { - override fun execute() { - val thread: Thread = object : Thread() { - override fun run() { - try { - val result = doInBackground() - handler.post { done(result) } - } catch (all: Throwable) { - handler.post { error(all) } - } - } - } - thread.start() - } - - override fun updateProgress(messageId: Int) {} - override fun updateProgress(message: String) {} -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt index 74d292d5..2ba10bb2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt @@ -37,6 +37,10 @@ class StorageFile private constructor( return getPath().compareTo(other.getPath()) } + override fun toString(): String { + return name + } + var name: String = fileManager.getName(abstractFile) var isDirectory: Boolean = fileManager.isDirectory(abstractFile) 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/SubsonicUncaughtExceptionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/UncaughtExceptionHandler.kt similarity index 91% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/UncaughtExceptionHandler.kt index 4e6d1834..dd06f788 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/UncaughtExceptionHandler.kt @@ -10,7 +10,7 @@ import java.io.File /** * Logs the stack trace of uncaught exceptions to a file on the SD card. */ -class SubsonicUncaughtExceptionHandler( +class UncaughtExceptionHandler( private val context: Context ) : Thread.UncaughtExceptionHandler { private val defaultHandler: Thread.UncaughtExceptionHandler? = @@ -32,8 +32,8 @@ class SubsonicUncaughtExceptionHandler( throwable.printStackTrace(printWriter) Timber.e(throwable, "Uncaught Exception! %s", logMessage) Timber.i("Stack trace written to %s", file) - } catch (x: Throwable) { - Timber.e(x, "Failed to write stack trace to %s", file) + } catch (all: Throwable) { + Timber.e(all, "Failed to write stack trace to %s", file) } finally { printWriter.safeClose() defaultHandler?.uncaughtException(thread, throwable) 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 52f51bd5..d0604dcc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -43,8 +43,6 @@ import android.widget.Toast import androidx.annotation.AnyRes import androidx.media.utils.MediaConstants import java.io.Closeable -import java.io.IOException -import java.io.File import java.io.UnsupportedEncodingException import java.security.MessageDigest import java.text.DecimalFormat @@ -482,7 +480,6 @@ object Util { } /** - * * Broadcasts the given song info as the new song being played. */ fun broadcastNewTrackInfo(context: Context, song: MusicDirectory.Entry?) { @@ -902,6 +899,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) + } + /** * Small data class to store information about the current network **/ diff --git a/ultrasonic/src/main/res/layout-land/current_playing.xml b/ultrasonic/src/main/res/layout-land/current_playing.xml index a3028829..3d7b1acb 100644 --- a/ultrasonic/src/main/res/layout-land/current_playing.xml +++ b/ultrasonic/src/main/res/layout-land/current_playing.xml @@ -1,21 +1,23 @@ + xmlns:tools="http://schemas.android.com/tools" + a:layout_width="fill_parent" + a:layout_height="fill_parent" + a:baselineAligned="false" + a:orientation="horizontal"> - + a:layout_weight="1" + tools:ignore="UselessParent"> + a:orientation="vertical"> - + @@ -60,10 +63,11 @@ a:layout_width="0dip" a:layout_height="fill_parent" a:layout_weight="1" - a:padding="10dip" a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" + a:padding="10dip" a:scaleType="fitCenter" a:src="?attr/star_hollow" /> @@ -72,10 +76,11 @@ a:layout_width="0dip" a:layout_height="fill_parent" a:layout_weight="1" - a:padding="10dip" a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" + a:padding="10dip" a:scaleType="fitCenter" a:src="?attr/star_hollow" /> @@ -84,10 +89,11 @@ a:layout_width="0dip" a:layout_height="fill_parent" a:layout_weight="1" - a:padding="10dip" a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" + a:padding="10dip" a:scaleType="fitCenter" a:src="?attr/star_hollow" /> @@ -96,10 +102,11 @@ a:layout_width="0dip" a:layout_height="fill_parent" a:layout_weight="1" - a:padding="10dip" a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" + a:padding="10dip" a:scaleType="fitCenter" a:src="?attr/star_hollow" /> @@ -113,15 +120,16 @@ a:layout_marginStart="60dip" a:layout_marginEnd="60dip" a:background="@color/translucent" - a:orientation="vertical"/> + a:orientation="vertical" /> - - + + + - - + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/current_playing.xml b/ultrasonic/src/main/res/layout/current_playing.xml index 806ceb04..b4a7d754 100644 --- a/ultrasonic/src/main/res/layout/current_playing.xml +++ b/ultrasonic/src/main/res/layout/current_playing.xml @@ -4,7 +4,7 @@ a:layout_height="fill_parent" a:orientation="vertical" > - @@ -52,7 +51,8 @@ a:focusable="false" a:gravity="center_vertical" a:scaleType="fitCenter" - a:src="?attr/star_hollow" /> + a:src="?attr/star_hollow" + a:importantForAccessibility="no" /> + a:src="?attr/star_hollow" + a:importantForAccessibility="no" /> + a:src="?attr/star_hollow" + a:importantForAccessibility="no" /> + a:src="?attr/star_hollow" + a:importantForAccessibility="no" /> + a:src="?attr/star_hollow" + a:importantForAccessibility="no" /> @@ -119,7 +123,7 @@ - + diff --git a/ultrasonic/src/main/res/layout/current_playlist.xml b/ultrasonic/src/main/res/layout/current_playlist.xml index 1b933376..fcee1849 100644 --- a/ultrasonic/src/main/res/layout/current_playlist.xml +++ b/ultrasonic/src/main/res/layout/current_playlist.xml @@ -5,8 +5,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" a:orientation="vertical" a:layout_width="fill_parent" - a:layout_height="fill_parent" - a:layout_weight="1"> + a:layout_height="fill_parent"> + a:textAppearance="?android:attr/textAppearanceSmall" + tools:ignore="HardcodedText" />