Merged develop into api30

This commit is contained in:
Nite 2021-11-19 20:34:03 +01:00
commit 5c7cde2349
No known key found for this signature in database
GPG Key ID: 1D1AD59B1C6386C1
44 changed files with 1002 additions and 1265 deletions

View File

@ -42,7 +42,9 @@ ext.versions = [
timber : "4.7.1", timber : "4.7.1",
fastScroll : "2.0.1", fastScroll : "2.0.1",
colorPicker : "2.2.3", colorPicker : "2.2.3",
fsaf : "1.1" fsaf : "1.1",
rxJava : "3.1.2",
rxAndroid : "3.0.0",
] ]
ext.gradlePlugins = [ ext.gradlePlugins = [
@ -91,6 +93,8 @@ ext.other = [
sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView", sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView",
colorPickerView : "com.github.skydoves:colorpickerview:$versions.colorPicker", colorPickerView : "com.github.skydoves:colorpickerview:$versions.colorPicker",
fsaf : "com.github.K1rakishou:Fuck-Storage-Access-Framework:$versions.fsaf", 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 = [ ext.testing = [

View File

@ -3,11 +3,12 @@
<ManuallySuppressedIssues></ManuallySuppressedIssues> <ManuallySuppressedIssues></ManuallySuppressedIssues>
<CurrentIssues> <CurrentIssues>
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.&lt;no name provided&gt;$!append &amp;&amp; !playNext &amp;&amp; !unpin &amp;&amp; !background</ID> <ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.&lt;no name provided&gt;$!append &amp;&amp; !playNext &amp;&amp; !unpin &amp;&amp; !background</ID>
<ID>ComplexCondition:FilePickerAdapter.kt$FilePickerAdapter$currentDirectory.absolutePath == "/" || currentDirectory.absolutePath == "/storage" || currentDirectory.absolutePath == "/storage/emulated" || currentDirectory.absolutePath == "/mnt"</ID>
<ID>ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID> <ID>ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>ComplexMethod:FilePickerAdapter.kt$FilePickerAdapter$private fun fileLister(currentDirectory: File)</ID>
<ID>ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean)</ID> <ID>ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean)</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons()</ID> <ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons()</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID> <ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID>
<ID>FunctionNaming:ThemeChangedEventDistributor.kt$ThemeChangedEventDistributor$fun RaiseThemeChangedEvent()</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile$String.format("DownloadFile (%s)", song)</ID> <ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile$String.format("DownloadFile (%s)", song)</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("Download of '%s' was cancelled", song)</ID> <ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("Download of '%s' was cancelled", song)</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("DownloadTask (%s)", song)</ID> <ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("DownloadTask (%s)", song)</ID>
@ -49,7 +50,6 @@
<ID>NestedBlockDepth:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID> <ID>NestedBlockDepth:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>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 )</ID> <ID>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 )</ID>
<ID>NestedBlockDepth:MediaPlayerService.kt$MediaPlayerService$private fun setupOnSongCompletedHandler()</ID> <ID>NestedBlockDepth:MediaPlayerService.kt$MediaPlayerService$private fun setupOnSongCompletedHandler()</ID>
<ID>ReturnCount:CommunicationErrorHandler.kt$CommunicationErrorHandler.Companion$fun getErrorMessage(error: Throwable, context: Context): String</ID>
<ID>ReturnCount:ServerRowAdapter.kt$ServerRowAdapter$ private fun popupMenuItemClick(menuItem: MenuItem, position: Int): Boolean</ID> <ID>ReturnCount:ServerRowAdapter.kt$ServerRowAdapter$ private fun popupMenuItemClick(menuItem: MenuItem, position: Int): Boolean</ID>
<ID>ReturnCount:TrackCollectionFragment.kt$TrackCollectionFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID> <ID>ReturnCount:TrackCollectionFragment.kt$TrackCollectionFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>TooGenericExceptionCaught:DownloadFile.kt$DownloadFile$e: Exception</ID> <ID>TooGenericExceptionCaught:DownloadFile.kt$DownloadFile$e: Exception</ID>
@ -59,12 +59,10 @@
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$x: Exception</ID> <ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$x: Exception</ID>
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer.PositionCache$e: Exception</ID> <ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer.PositionCache$e: Exception</ID>
<ID>TooGenericExceptionCaught:SongView.kt$SongView$e: Exception</ID> <ID>TooGenericExceptionCaught:SongView.kt$SongView$e: Exception</ID>
<ID>TooGenericExceptionCaught:SubsonicUncaughtExceptionHandler.kt$SubsonicUncaughtExceptionHandler$x: Throwable</ID>
<ID>TooGenericExceptionThrown:DownloadFile.kt$DownloadFile.DownloadTask$throw Exception(String.format("Download of '%s' was cancelled", song))</ID> <ID>TooGenericExceptionThrown:DownloadFile.kt$DownloadFile.DownloadTask$throw Exception(String.format("Download of '%s' was cancelled", song))</ID>
<ID>TooManyFunctions:MediaPlayerService.kt$MediaPlayerService : Service</ID> <ID>TooManyFunctions:MediaPlayerService.kt$MediaPlayerService : Service</ID>
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID> <ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
<ID>TooManyFunctions:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment</ID> <ID>TooManyFunctions:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment</ID>
<ID>UtilityClassWithPublicConstructor:CommunicationErrorHandler.kt$CommunicationErrorHandler</ID>
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID> <ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
</CurrentIssues> </CurrentIssues>
</SmellBaseline> </SmellBaseline>

View File

@ -107,6 +107,8 @@ dependencies {
implementation other.sortListView implementation other.sortListView
implementation other.colorPickerView implementation other.colorPickerView
implementation other.fsaf implementation other.fsaf
implementation other.rxJava
implementation other.rxAndroid
kapt androidSupport.room kapt androidSupport.room

View File

@ -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<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
private final Lazy<ImageLoaderProvider> imageLoader = inject(ImageLoaderProvider.class);
private final Lazy<NowPlayingEventDistributor> 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;
}
}

View File

@ -20,7 +20,6 @@ package org.moire.ultrasonic.util;
import android.app.Activity; import android.app.Activity;
import android.os.Handler; import android.os.Handler;
import org.moire.ultrasonic.service.CommunicationErrorHandler;
/** /**
* @author Sindre Mehus * @author Sindre Mehus
@ -54,12 +53,12 @@ public abstract class BackgroundTask<T> implements ProgressListener
protected void error(Throwable error) protected void error(Throwable error)
{ {
CommunicationErrorHandler.Companion.handleError(error, activity); CommunicationError.handleError(error, activity);
} }
protected String getErrorMessage(Throwable error) protected String getErrorMessage(Throwable error)
{ {
return CommunicationErrorHandler.Companion.getErrorMessage(error, activity); return CommunicationError.getErrorMessage(error, activity);
} }
@Override @Override

View File

@ -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 <http://www.gnu.org/licenses/>.
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();
}
}
}

View File

@ -31,6 +31,7 @@ import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import io.reactivex.rxjava3.disposables.Disposable
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R 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.DownloadFile
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Constants 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.ServerColor
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.SubsonicUncaughtExceptionHandler import org.moire.ultrasonic.util.UncaughtExceptionHandler
import org.moire.ultrasonic.util.ThemeChangedEventDistributor
import org.moire.ultrasonic.util.ThemeChangedEventListener
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
@ -73,15 +71,13 @@ class NavigationActivity : AppCompatActivity() {
private var headerBackgroundImage: ImageView? = null private var headerBackgroundImage: ImageView? = null
private lateinit var appBarConfiguration: AppBarConfiguration private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var nowPlayingEventListener: NowPlayingEventListener private var themeChangedEventSubscription: Disposable? = null
private lateinit var themeChangedEventListener: ThemeChangedEventListener private var playerStateSubscription: Disposable? = null
private val serverSettingsModel: ServerSettingsModel by viewModel() private val serverSettingsModel: ServerSettingsModel by viewModel()
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject() private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
private val mediaPlayerController: MediaPlayerController by inject() private val mediaPlayerController: MediaPlayerController by inject()
private val imageLoaderProvider: ImageLoaderProvider 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 activeServerProvider: ActiveServerProvider by inject()
private val serverRepository: ServerSettingDao by inject() private val serverRepository: ServerSettingDao by inject()
@ -166,28 +162,22 @@ class NavigationActivity : AppCompatActivity() {
showWelcomeDialog() showWelcomeDialog()
} }
nowPlayingEventListener = object : NowPlayingEventListener { RxBus.dismissNowPlayingCommandObservable.subscribe {
override fun onDismissNowPlaying() { nowPlayingHidden = true
nowPlayingHidden = true hideNowPlaying()
hideNowPlaying() }
}
override fun onHideNowPlaying() { playerStateSubscription = RxBus.playerStateObservable.subscribe {
hideNowPlaying() if (it.state === PlayerState.STARTED || it.state === PlayerState.PAUSED)
}
override fun onShowNowPlaying() {
showNowPlaying() showNowPlaying()
} else
hideNowPlaying()
} }
themeChangedEventListener = object : ThemeChangedEventListener { themeChangedEventSubscription = RxBus.themeChangedEventObservable.subscribe {
override fun onThemeChanged() { recreate() } recreate()
} }
nowPlayingEventDistributor.subscribe(nowPlayingEventListener)
themeChangedEventDistributor.subscribe(themeChangedEventListener)
serverRepository.liveServerCount().observe( serverRepository.liveServerCount().observe(
this, this,
{ count -> { count ->
@ -234,8 +224,8 @@ class NavigationActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
nowPlayingEventDistributor.unsubscribe(nowPlayingEventListener) themeChangedEventSubscription?.dispose()
themeChangedEventDistributor.unsubscribe(themeChangedEventListener) playerStateSubscription?.dispose()
imageLoaderProvider.clearImageLoader() imageLoaderProvider.clearImageLoader()
} }
@ -382,8 +372,8 @@ class NavigationActivity : AppCompatActivity() {
private fun setUncaughtExceptionHandler() { private fun setUncaughtExceptionHandler() {
val handler = Thread.getDefaultUncaughtExceptionHandler() val handler = Thread.getDefaultUncaughtExceptionHandler()
if (handler !is SubsonicUncaughtExceptionHandler) { if (handler !is UncaughtExceptionHandler) {
Thread.setDefaultUncaughtExceptionHandler(SubsonicUncaughtExceptionHandler(this)) Thread.setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler(this))
} }
} }

View File

@ -4,10 +4,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.MediaSessionEventDistributor
import org.moire.ultrasonic.util.MediaSessionHandler 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 * 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 { val applicationModule = module {
single { ActiveServerProvider(get()) } single { ActiveServerProvider(get()) }
single { ImageLoaderProvider(androidContext()) } single { ImageLoaderProvider(androidContext()) }
single { NowPlayingEventDistributor() }
single { ThemeChangedEventDistributor() }
single { MediaSessionEventDistributor() }
single { MediaSessionHandler() } single { MediaSessionHandler() }
} }

View File

@ -6,6 +6,7 @@ package org.moire.ultrasonic.domain
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import kotlin.LazyThreadSafetyMode.NONE import kotlin.LazyThreadSafetyMode.NONE
import org.moire.ultrasonic.api.subsonic.models.Playlist as APIPlaylist 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() } internal val playlistDateFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
@ -17,7 +18,7 @@ fun APIPlaylist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory(
fun APIPlaylist.toDomainEntity(): Playlist = Playlist( fun APIPlaylist.toDomainEntity(): Playlist = Playlist(
this.id, this.name, this.owner, this.id, this.name, this.owner,
this.comment, this.songCount.toString(), this.comment, this.songCount.toString(),
this.created?.let { playlistDateFormat.format(it.time) } ?: "", this.created.ifNotNull { playlistDateFormat.format(it.time) } ?: "",
public public
) )

View File

@ -5,6 +5,7 @@ package org.moire.ultrasonic.domain
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import kotlin.LazyThreadSafetyMode.NONE import kotlin.LazyThreadSafetyMode.NONE
import org.moire.ultrasonic.api.subsonic.models.Share as APIShare 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() } internal val shareTimeFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
@ -13,11 +14,11 @@ fun List<APIShare>.toDomainEntitiesList(): List<Share> = this.map {
} }
fun APIShare.toDomainEntity(): Share = Share( 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, 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, 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, url = this@toDomainEntity.url,
username = this@toDomainEntity.username, username = this@toDomainEntity.username,
visitCount = this@toDomainEntity.visitCount.toLong(), visitCount = this@toDomainEntity.visitCount.toLong(),

View File

@ -220,6 +220,6 @@ class DownloadListModel(application: Application) : GenericListModel(application
private val downloader by inject<Downloader>() private val downloader by inject<Downloader>()
fun getList(): LiveData<List<DownloadFile>> { fun getList(): LiveData<List<DownloadFile>> {
return downloader.observableList return downloader.observableDownloads
} }
} }

View File

@ -18,9 +18,9 @@ import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.domain.MusicFolder import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.service.CommunicationErrorHandler
import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
/** /**
@ -94,7 +94,7 @@ open class GenericListModel(application: Application) :
private fun handleException(exception: Exception, context: Context) { private fun handleException(exception: Exception, context: Context) {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
CommunicationErrorHandler.handleError(exception, context) CommunicationError.handleError(exception, context)
} }
} }

View File

@ -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
}
}

View File

@ -38,17 +38,22 @@ import androidx.fragment.app.Fragment
import androidx.navigation.Navigation import androidx.navigation.Navigation
import com.mobeta.android.dslv.DragSortListView import com.mobeta.android.dslv.DragSortListView
import com.mobeta.android.dslv.DragSortListView.DragSortListener import com.mobeta.android.dslv.DragSortListView.DragSortListener
import io.reactivex.rxjava3.disposables.Disposable
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.ArrayList import java.util.ArrayList
import java.util.Date import java.util.Date
import java.util.LinkedList
import java.util.Locale import java.util.Locale
import java.util.concurrent.CancellationException
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max 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.android.ext.android.inject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get 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.LocalMediaPlayer
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.util.CancellationToken import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.SilentBackgroundTask
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.view.AutoRepeatButton import org.moire.ultrasonic.view.AutoRepeatButton
import org.moire.ultrasonic.view.SongListAdapter 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 * 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") @Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinComponent { class PlayerFragment :
// Settings Fragment(),
private var currentRevision: Long = 0 GestureDetector.OnGestureListener,
KoinComponent,
CoroutineScope by CoroutineScope(Dispatchers.Main) {
private var swipeDistance = 0 private var swipeDistance = 0
private var swipeVelocity = 0 private var swipeVelocity = 0
private var jukeboxAvailable = false private var jukeboxAvailable = false
@ -110,7 +114,8 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
private lateinit var executorService: ScheduledExecutorService private lateinit var executorService: ScheduledExecutorService
private var currentPlaying: DownloadFile? = null private var currentPlaying: DownloadFile? = null
private var currentSong: MusicDirectory.Entry? = null private var currentSong: MusicDirectory.Entry? = null
private var onProgressChangedTask: SilentBackgroundTask<Void?>? = null private var rxBusSubscription: Disposable? = null
private var ioScope = CoroutineScope(Dispatchers.IO)
// Views and UI Elements // Views and UI Elements
private lateinit var visualizerViewLayout: LinearLayout private lateinit var visualizerViewLayout: LinearLayout
@ -230,17 +235,11 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
previousButton.setOnClickListener { previousButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
object : SilentBackgroundTask<Void?>(activity) { launch(CommunicationError.getHandler(context)) {
override fun doInBackground(): Void? { mediaPlayerController.previous()
mediaPlayerController.previous() onCurrentChanged()
return null onSliderProgressChanged()
} }
override fun done(result: Void?) {
onCurrentChanged()
onSliderProgressChanged()
}
}.execute()
} }
previousButton.setOnRepeatListener { previousButton.setOnRepeatListener {
@ -250,65 +249,43 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
nextButton.setOnClickListener { nextButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
object : SilentBackgroundTask<Boolean?>(activity) { launch(CommunicationError.getHandler(context)) {
override fun doInBackground(): Boolean { mediaPlayerController.next()
mediaPlayerController.next() onCurrentChanged()
return true onSliderProgressChanged()
} }
override fun done(result: Boolean?) {
if (result == true) {
onCurrentChanged()
onSliderProgressChanged()
}
}
}.execute()
} }
nextButton.setOnRepeatListener { nextButton.setOnRepeatListener {
val incrementTime = Settings.incrementTime val incrementTime = Settings.incrementTime
changeProgress(incrementTime) changeProgress(incrementTime)
} }
pauseButton.setOnClickListener { pauseButton.setOnClickListener {
object : SilentBackgroundTask<Void?>(activity) { launch(CommunicationError.getHandler(context)) {
override fun doInBackground(): Void? { mediaPlayerController.pause()
mediaPlayerController.pause() onCurrentChanged()
return null onSliderProgressChanged()
} }
override fun done(result: Void?) {
onCurrentChanged()
onSliderProgressChanged()
}
}.execute()
} }
stopButton.setOnClickListener { stopButton.setOnClickListener {
object : SilentBackgroundTask<Void?>(activity) { launch(CommunicationError.getHandler(context)) {
override fun doInBackground(): Void? { mediaPlayerController.reset()
mediaPlayerController.reset() onCurrentChanged()
return null onSliderProgressChanged()
} }
override fun done(result: Void?) {
onCurrentChanged()
onSliderProgressChanged()
}
}.execute()
} }
startButton.setOnClickListener { startButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
object : SilentBackgroundTask<Void?>(activity) { launch(CommunicationError.getHandler(context)) {
override fun doInBackground(): Void? { start()
start() onCurrentChanged()
return null onSliderProgressChanged()
} }
override fun done(result: Void?) {
onCurrentChanged()
onSliderProgressChanged()
}
}.execute()
} }
shuffleButton.setOnClickListener { shuffleButton.setOnClickListener {
mediaPlayerController.shuffle() mediaPlayerController.shuffle()
Util.toast(activity, R.string.download_menu_shuffle_notification) Util.toast(activity, R.string.download_menu_shuffle_notification)
@ -335,16 +312,10 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) { override fun onStopTrackingTouch(seekBar: SeekBar) {
object : SilentBackgroundTask<Void?>(activity) { launch(CommunicationError.getHandler(context)) {
override fun doInBackground(): Void? { mediaPlayerController.seekTo(progressBar.progress)
mediaPlayerController.seekTo(progressBar.progress) onSliderProgressChanged()
return null }
}
override fun done(result: Void?) {
onSliderProgressChanged()
}
}.execute()
} }
override fun onStartTrackingTouch(seekBar: SeekBar) {} override fun onStartTrackingTouch(seekBar: SeekBar) {}
@ -353,18 +324,13 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
playlistView.setOnItemClickListener { _, _, position, _ -> playlistView.setOnItemClickListener { _, _, position, _ ->
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
object : SilentBackgroundTask<Void?>(activity) { launch(CommunicationError.getHandler(context)) {
override fun doInBackground(): Void? { mediaPlayerController.play(position)
mediaPlayerController.play(position) onCurrentChanged()
return null onSliderProgressChanged()
} }
override fun done(result: Void?) {
onCurrentChanged()
onSliderProgressChanged()
}
}.execute()
} }
registerForContextMenu(playlistView) registerForContextMenu(playlistView)
if (arguments != null && requireArguments().getBoolean( 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 { try {
jukeboxAvailable = mediaPlayerController.isJukeboxAvailable jukeboxAvailable = mediaPlayerController.isJukeboxAvailable
} catch (all: Exception) { } catch (all: Exception) {
Timber.e(all) Timber.e(all)
} }
}.start() }
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) } view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
} }
@ -479,6 +453,8 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
} }
override fun onDestroyView() { override fun onDestroyView() {
rxBusSubscription?.dispose()
cancel("CoroutineScope cancelled because the view was destroyed")
cancellationToken.cancel() cancellationToken.cancel()
super.onDestroyView() super.onDestroyView()
} }
@ -797,9 +773,6 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
private fun update(cancel: CancellationToken?) { private fun update(cancel: CancellationToken?) {
if (cancel!!.isCancellationRequested) return if (cancel!!.isCancellationRequested) return
val mediaPlayerController = mediaPlayerController val mediaPlayerController = mediaPlayerController
if (currentRevision != mediaPlayerController.playListUpdateRevision) {
onPlaylistChanged()
}
if (currentPlaying != mediaPlayerController.currentPlaying) { if (currentPlaying != mediaPlayerController.currentPlaying) {
onCurrentChanged() onCurrentChanged()
} }
@ -810,33 +783,28 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
private fun savePlaylistInBackground(playlistName: String) { private fun savePlaylistInBackground(playlistName: String) {
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName)) Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
mediaPlayerController.suggestedPlaylistName = playlistName mediaPlayerController.suggestedPlaylistName = playlistName
object : SilentBackgroundTask<Void?>(activity) {
@Throws(Throwable::class)
override fun doInBackground(): Void? {
val entries: MutableList<MusicDirectory.Entry> = 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) Util.toast(context, R.string.download_playlist_done)
} } else {
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
override fun error(error: Throwable) {
Timber.e(error, "Exception has occurred in savePlaylistInBackground")
val msg = String.format( val msg = String.format(
Locale.ROOT, Locale.ROOT,
"%s %s", "%s %s",
resources.getString(R.string.download_playlist_error), resources.getString(R.string.download_playlist_error),
getErrorMessage(error) CommunicationError.getErrorMessage(it, context)
) )
Util.toast(context, msg) Util.toast(context, msg)
} }
}.execute() }
} }
private fun toggleFullScreenAlbumArt() { private fun toggleFullScreenAlbumArt() {
@ -914,7 +882,6 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
emptyTextView.isVisible = list.isEmpty() emptyTextView.isVisible = list.isEmpty()
currentRevision = mediaPlayerController.playListUpdateRevision
when (mediaPlayerController.repeatMode) { when (mediaPlayerController.repeatMode) {
RepeatMode.OFF -> repeatButton.setImageDrawable( RepeatMode.OFF -> repeatButton.setImageDrawable(
Util.getDrawableFromAttribute( Util.getDrawableFromAttribute(
@ -967,120 +934,95 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
} }
} }
@Suppress("LongMethod", "ComplexMethod")
@Synchronized
private fun onSliderProgressChanged() { 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<Void?>(activity) {
var isJukeboxEnabled = false when (playerState) {
var millisPlayed = 0 PlayerState.DOWNLOADING -> {
var duration: Int? = null val progress =
var playerState: PlayerState? = null if (currentPlaying != null) currentPlaying!!.progress.value!! else 0
override fun doInBackground(): Void? { val downloadStatus = resources.getString(
isJukeboxEnabled = mediaPlayerController.isJukeboxEnabled R.string.download_playerstate_downloading,
millisPlayed = max(0, mediaPlayerController.playerPosition) Util.formatPercentage(progress)
duration = mediaPlayerController.playerDuration )
playerState = mediaPlayerController.playerState setTitle(this@PlayerFragment, downloadStatus)
return null
} }
PlayerState.PREPARING -> setTitle(
@Suppress("LongMethod") this@PlayerFragment,
override fun done(result: Void?) { R.string.download_playerstate_buffering
if (cancellationToken.isCancellationRequested) return )
if (currentPlaying != null) { PlayerState.STARTED -> {
val millisTotal = if (duration == null) 0 else duration!! if (mediaPlayerController.isShufflePlayEnabled) {
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true) setTitle(
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(
this@PlayerFragment, this@PlayerFragment,
R.string.download_playerstate_buffering R.string.download_playerstate_playing_shuffle
) )
PlayerState.STARTED -> { } else {
if (mediaPlayerController.isShufflePlayEnabled) { setTitle(this@PlayerFragment, R.string.common_appname)
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)
} }
}
PlayerState.IDLE,
PlayerState.PREPARED,
PlayerState.STOPPED,
PlayerState.PAUSED,
PlayerState.COMPLETED -> {
}
else -> setTitle(this@PlayerFragment, R.string.common_appname)
}
when (playerState) { when (playerState) {
PlayerState.STARTED -> { PlayerState.STARTED -> {
pauseButton.isVisible = true pauseButton.isVisible = true
stopButton.isVisible = false stopButton.isVisible = false
startButton.isVisible = false startButton.isVisible = false
} }
PlayerState.DOWNLOADING, PlayerState.PREPARING -> { PlayerState.DOWNLOADING, PlayerState.PREPARING -> {
pauseButton.isVisible = false pauseButton.isVisible = false
stopButton.isVisible = true stopButton.isVisible = true
startButton.isVisible = false startButton.isVisible = false
} }
else -> { else -> {
pauseButton.isVisible = false pauseButton.isVisible = false
stopButton.isVisible = false stopButton.isVisible = false
startButton.isVisible = true 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
} }
} }
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) { private fun changeProgress(ms: Int) {
object : SilentBackgroundTask<Void?>(activity) { launch(CommunicationError.getHandler(context)) {
var msPlayed = 0 val msPlayed: Int = max(0, mediaPlayerController.playerPosition)
var duration: Int? = null val duration = mediaPlayerController.playerDuration
var seekTo = 0 val seekTo = (msPlayed + ms).coerceAtMost(duration)
override fun doInBackground(): Void? { mediaPlayerController.seekTo(seekTo)
msPlayed = max(0, mediaPlayerController.playerPosition) progressBar.progress = seekTo
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()
} }
override fun onDown(me: MotionEvent): Boolean { override fun onDown(me: MotionEvent): Boolean {

View File

@ -37,6 +37,7 @@ import org.moire.ultrasonic.log.FileLoggerTree.Companion.plantToTimberForest
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
import org.moire.ultrasonic.provider.SearchSuggestionProvider import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.FileUtil.defaultMusicDirectory import org.moire.ultrasonic.util.FileUtil.defaultMusicDirectory
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory 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.preferences
import org.moire.ultrasonic.util.Settings.shareGreeting import org.moire.ultrasonic.util.Settings.shareGreeting
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
import org.moire.ultrasonic.util.ThemeChangedEventDistributor
import org.moire.ultrasonic.util.TimeSpanPreference import org.moire.ultrasonic.util.TimeSpanPreference
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
import org.moire.ultrasonic.util.Util.toast import org.moire.ultrasonic.util.Util.toast
@ -93,9 +93,6 @@ class SettingsFragment :
private val mediaPlayerControllerLazy = inject<MediaPlayerController>( private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
MediaPlayerController::class.java MediaPlayerController::class.java
) )
private val themeChangedEventDistributor = inject<ThemeChangedEventDistributor>(
ThemeChangedEventDistributor::class.java
)
private val mediaSessionHandler = inject<MediaSessionHandler>( private val mediaSessionHandler = inject<MediaSessionHandler>(
MediaSessionHandler::class.java MediaSessionHandler::class.java
) )
@ -225,7 +222,7 @@ class SettingsFragment :
showArtistPicture!!.isEnabled = sharedPreferences.getBoolean(key, false) showArtistPicture!!.isEnabled = sharedPreferences.getBoolean(key, false)
} }
Constants.PREFERENCES_KEY_THEME -> { Constants.PREFERENCES_KEY_THEME -> {
themeChangedEventDistributor.value.RaiseThemeChangedEvent() RxBus.themeChangedEventPublisher.onNext(Unit)
} }
} }
} }

View File

@ -38,7 +38,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.getTitle import org.moire.ultrasonic.fragment.FragmentTitle.Companion.getTitle
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.CommunicationErrorHandler
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.ImageLoaderProvider 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.subsonic.VideoPlayer
import org.moire.ultrasonic.util.AlbumHeader import org.moire.ultrasonic.util.AlbumHeader
import org.moire.ultrasonic.util.CancellationToken import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
@ -211,7 +211,7 @@ class TrackCollectionFragment : Fragment() {
val handler = CoroutineExceptionHandler { _, exception -> val handler = CoroutineExceptionHandler { _, exception ->
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
context?.let { CommunicationErrorHandler.handleError(exception, it) } CommunicationError.handleError(exception, context)
} }
refreshAlbumListView!!.isRefreshing = false refreshAlbumListView!!.isRefreshing = false
} }

View File

@ -11,9 +11,9 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.session.MediaSessionCompat
import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job 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.MusicDirectory
import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult 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.MediaSessionHandler
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
@ -73,8 +71,6 @@ private const val SEARCH_LIMIT = 10
@Suppress("TooManyFunctions", "LargeClass") @Suppress("TooManyFunctions", "LargeClass")
class AutoMediaBrowserService : MediaBrowserServiceCompat() { class AutoMediaBrowserService : MediaBrowserServiceCompat() {
private lateinit var mediaSessionEventListener: MediaSessionEventListener
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>() private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
private val mediaSessionHandler by inject<MediaSessionHandler>() private val mediaSessionHandler by inject<MediaSessionHandler>()
private val mediaPlayerController by inject<MediaPlayerController>() private val mediaPlayerController by inject<MediaPlayerController>()
@ -93,75 +89,24 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
private val useId3Tags get() = Settings.shouldUseId3Tags private val useId3Tags get() = Settings.shouldUseId3Tags
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
@Suppress("MagicNumber") @Suppress("MagicNumber")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
mediaSessionEventListener = object : MediaSessionEventListener { rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe {
override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { if (sessionToken == null) sessionToken = it
if (sessionToken == null) { }
sessionToken = token
} rxBusSubscription += RxBus.playFromMediaIdCommandObservable.subscribe {
} playFromMediaId(it.first)
}
override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {
Timber.d( rxBusSubscription += RxBus.playFromSearchCommandObservable.subscribe {
"AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", playFromSearchCommand(it.first)
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)
}
}
}
} }
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
mediaSessionHandler.initialize() mediaSessionHandler.initialize()
val handler = Handler() val handler = Handler()
@ -180,9 +125,66 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
Timber.i("AutoMediaBrowserService onCreate finished") 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) rxBusSubscription.dispose()
mediaSessionHandler.release() mediaSessionHandler.release()
serviceJob.cancel() serviceJob.cancel()

View File

@ -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 <http://www.gnu.org/licenses/>.
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
}
}
}

View File

@ -30,13 +30,17 @@ class Downloader(
private val externalStorageMonitor: ExternalStorageMonitor, private val externalStorageMonitor: ExternalStorageMonitor,
private val localMediaPlayer: LocalMediaPlayer private val localMediaPlayer: LocalMediaPlayer
) : KoinComponent { ) : KoinComponent {
val playlist: MutableList<DownloadFile> = ArrayList()
private val playlist = mutableListOf<DownloadFile>()
var started: Boolean = false var started: Boolean = false
private val downloadQueue: PriorityQueue<DownloadFile> = PriorityQueue<DownloadFile>() private val downloadQueue = PriorityQueue<DownloadFile>()
private val activelyDownloading: MutableList<DownloadFile> = ArrayList() private val activelyDownloading = mutableListOf<DownloadFile>()
val observableList: MutableLiveData<List<DownloadFile>> = MutableLiveData<List<DownloadFile>>() // TODO: The playlist is now published with RX, while the observableDownloads is using LiveData.
// Use the same for both
val observableDownloads = MutableLiveData<List<DownloadFile>>()
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
@ -45,8 +49,11 @@ class Downloader(
private var executorService: ScheduledExecutorService? = null private var executorService: ScheduledExecutorService? = null
private var wifiLock: WifiManager.WifiLock? = null private var wifiLock: WifiManager.WifiLock? = null
var playlistUpdateRevision: Long = 0 private var playlistUpdateRevision: Long = 0
private set private set(value) {
field = value
RxBus.playlistPublisher.onNext(playlist)
}
val downloadChecker = Runnable { val downloadChecker = Runnable {
try { try {
@ -61,7 +68,7 @@ class Downloader(
stop() stop()
clearPlaylist() clearPlaylist()
clearBackground() clearBackground()
observableList.value = listOf() observableDownloads.value = listOf()
Timber.i("Downloader destroyed") Timber.i("Downloader destroyed")
} }
@ -179,7 +186,7 @@ class Downloader(
} }
private fun updateLiveData() { private fun updateLiveData() {
observableList.postValue(downloads) observableDownloads.postValue(downloads)
} }
private fun startDownloadOnService(task: DownloadFile) { private fun startDownloadOnService(task: DownloadFile) {
@ -264,6 +271,10 @@ class Downloader(
return temp.distinct().sorted() return temp.distinct().sorted()
} }
// Public facing playlist (immutable)
@Synchronized
fun getPlaylist(): List<DownloadFile> = playlist
@Synchronized @Synchronized
fun clearPlaylist() { fun clearPlaylist() {
playlist.clear() playlist.clear()
@ -349,6 +360,20 @@ class Downloader(
checkDownloads() 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 @Synchronized
fun downloadBackground(songs: List<MusicDirectory.Entry>, save: Boolean) { fun downloadBackground(songs: List<MusicDirectory.Entry>, save: Boolean) {
@ -429,18 +454,21 @@ class Downloader(
playlistUpdateRevision++ playlistUpdateRevision++
} }
} }
if (revisionBefore != playlistUpdateRevision) { if (revisionBefore != playlistUpdateRevision) {
jukeboxMediaPlayer.updatePlaylist() jukeboxMediaPlayer.updatePlaylist()
} }
if (wasEmpty && playlist.isNotEmpty()) { if (wasEmpty && playlist.isNotEmpty()) {
if (jukeboxMediaPlayer.isEnabled) { if (jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.skip(0, 0) jukeboxMediaPlayer.skip(0, 0)
localMediaPlayer.setPlayerState(PlayerState.STARTED) localMediaPlayer.setPlayerState(PlayerState.STARTED, playlist[0])
} else { } else {
localMediaPlayer.play(playlist[0]) localMediaPlayer.play(playlist[0])
} }
} }
} }
companion object { companion object {
const val PARALLEL_DOWNLOADS = 3 const val PARALLEL_DOWNLOADS = 3
const val CHECK_INTERVAL = 5L const val CHECK_INTERVAL = 5L

View File

@ -32,12 +32,10 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.StreamProxy import org.moire.ultrasonic.util.StreamProxy
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
import java.io.File
/** /**
* Represents a Media Player which uses the mobile's resources for playback * Represents a Media Player which uses the mobile's resources for playback
@ -47,17 +45,10 @@ class LocalMediaPlayer : KoinComponent {
private val audioFocusHandler by inject<AudioFocusHandler>() private val audioFocusHandler by inject<AudioFocusHandler>()
private val context by inject<Context>() private val context by inject<Context>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
@JvmField
var onCurrentPlayingChanged: ((DownloadFile?) -> Unit?)? = null
@JvmField @JvmField
var onSongCompleted: ((DownloadFile?) -> Unit?)? = null var onSongCompleted: ((DownloadFile?) -> Unit?)? = null
@JvmField
var onPlayerStateChanged: ((PlayerState, DownloadFile?) -> Unit?)? = null
@JvmField @JvmField
var onPrepared: (() -> Any?)? = null var onPrepared: (() -> Any?)? = null
@ -65,6 +56,7 @@ class LocalMediaPlayer : KoinComponent {
var onNextSongRequested: Runnable? = null var onNextSongRequested: Runnable? = null
@JvmField @JvmField
@Volatile
var playerState = PlayerState.IDLE var playerState = PlayerState.IDLE
@JvmField @JvmField
@ -133,7 +125,6 @@ class LocalMediaPlayer : KoinComponent {
// Calling reset() will result in changing this player's state. If we allow // Calling reset() will result in changing this player's state. If we allow
// the onPlayerStateChanged callback, then the state change will cause this // the onPlayerStateChanged callback, then the state change will cause this
// to resurrect the media session which has just been destroyed. // to resurrect the media session which has just been destroyed.
onPlayerStateChanged = null
reset() reset()
try { try {
val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
@ -165,21 +156,17 @@ class LocalMediaPlayer : KoinComponent {
} }
@Synchronized @Synchronized
fun setPlayerState(playerState: PlayerState) { fun setPlayerState(playerState: PlayerState, track: DownloadFile?) {
Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, currentPlaying) Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, track)
this.playerState = playerState synchronized(playerState) {
this.playerState = playerState
}
if (playerState === PlayerState.STARTED) { if (playerState === PlayerState.STARTED) {
audioFocusHandler.requestAudioFocus() audioFocusHandler.requestAudioFocus()
} }
if (onPlayerStateChanged != null) { RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, track))
val mainHandler = Handler(context.mainLooper)
val myRunnable = Runnable {
onPlayerStateChanged?.invoke(playerState, currentPlaying)
}
mainHandler.post(myRunnable)
}
if (playerState === PlayerState.STARTED && positionCache == null) { if (playerState === PlayerState.STARTED && positionCache == null) {
positionCache = PositionCache() positionCache = PositionCache()
val thread = Thread(positionCache) val thread = Thread(positionCache)
@ -195,14 +182,10 @@ class LocalMediaPlayer : KoinComponent {
*/ */
@Synchronized @Synchronized
fun setCurrentPlaying(currentPlaying: DownloadFile?) { 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 this.currentPlaying = currentPlaying
RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, currentPlaying))
if (onCurrentPlayingChanged != null) {
val mainHandler = Handler(context.mainLooper)
val myRunnable = Runnable { onCurrentPlayingChanged!!(currentPlaying) }
mainHandler.post(myRunnable)
}
} }
/* /*
@ -263,7 +246,7 @@ class LocalMediaPlayer : KoinComponent {
mediaPlayer = nextMediaPlayer!! mediaPlayer = nextMediaPlayer!!
setCurrentPlaying(nextPlaying) setCurrentPlaying(nextPlaying)
setPlayerState(PlayerState.STARTED) setPlayerState(PlayerState.STARTED, currentPlaying)
attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false) attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false)
@ -344,7 +327,7 @@ class LocalMediaPlayer : KoinComponent {
@Synchronized @Synchronized
private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) { private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) {
if (playerState !== PlayerState.PREPARED) { if (playerState !== PlayerState.PREPARED && !fileToPlay.isWorkDone) {
reset() reset()
bufferTask = BufferTask(fileToPlay, position, autoStart) bufferTask = BufferTask(fileToPlay, position, autoStart)
bufferTask!!.start() bufferTask!!.start()
@ -355,6 +338,7 @@ class LocalMediaPlayer : KoinComponent {
@Synchronized @Synchronized
private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) { 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. // In many cases we will be resetting the mediaPlayer a second time here.
// figure out if we can remove this call... // figure out if we can remove this call...
@ -370,7 +354,6 @@ class LocalMediaPlayer : KoinComponent {
// downloadFile.updateModificationDate() // downloadFile.updateModificationDate()
mediaPlayer.setOnCompletionListener(null) mediaPlayer.setOnCompletionListener(null)
setPlayerState(PlayerState.IDLE)
setAudioAttributes(mediaPlayer) setAudioAttributes(mediaPlayer)
var dataSource: String? = null var dataSource: String? = null
@ -403,7 +386,7 @@ class LocalMediaPlayer : KoinComponent {
descriptor.close() descriptor.close()
} }
setPlayerState(PlayerState.PREPARING) setPlayerState(PlayerState.PREPARING, downloadFile)
mediaPlayer.setOnBufferingUpdateListener { mp, percent -> mediaPlayer.setOnBufferingUpdateListener { mp, percent ->
val song = downloadFile.song val song = downloadFile.song
@ -421,7 +404,7 @@ class LocalMediaPlayer : KoinComponent {
mediaPlayer.setOnPreparedListener { mediaPlayer.setOnPreparedListener {
Timber.i("Media player prepared") 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 // Populate seek bar secondary progress if we have a complete file for consistency
if (downloadFile.isWorkDone) { if (downloadFile.isWorkDone) {
@ -436,9 +419,9 @@ class LocalMediaPlayer : KoinComponent {
cachedPosition = position cachedPosition = position
if (start) { if (start) {
mediaPlayer.start() mediaPlayer.start()
setPlayerState(PlayerState.STARTED) setPlayerState(PlayerState.STARTED, downloadFile)
} else { } else {
setPlayerState(PlayerState.PAUSED) setPlayerState(PlayerState.PAUSED, downloadFile)
} }
} }
@ -446,6 +429,7 @@ class LocalMediaPlayer : KoinComponent {
onPrepared onPrepared
} }
} }
attachHandlersToPlayer(mediaPlayer, downloadFile, partial) attachHandlersToPlayer(mediaPlayer, downloadFile, partial)
mediaPlayer.prepareAsync() mediaPlayer.prepareAsync()
} catch (x: Exception) { } catch (x: Exception) {
@ -541,7 +525,7 @@ class LocalMediaPlayer : KoinComponent {
Timber.i("Ending position %d of %d", pos, duration) Timber.i("Ending position %d of %d", pos, duration)
if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) { if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) {
setPlayerState(PlayerState.COMPLETED) setPlayerState(PlayerState.COMPLETED, downloadFile)
if (Settings.gaplessPlayback && if (Settings.gaplessPlayback &&
nextPlaying != null && nextPlaying != null &&
nextPlayerState === PlayerState.PREPARED nextPlayerState === PlayerState.PREPARED
@ -588,7 +572,7 @@ class LocalMediaPlayer : KoinComponent {
resetMediaPlayer() resetMediaPlayer()
try { try {
setPlayerState(PlayerState.IDLE) setPlayerState(PlayerState.IDLE, currentPlaying)
mediaPlayer.setOnErrorListener(null) mediaPlayer.setOnErrorListener(null)
mediaPlayer.setOnCompletionListener(null) mediaPlayer.setOnCompletionListener(null)
} catch (x: Exception) { } catch (x: Exception) {
@ -617,7 +601,7 @@ class LocalMediaPlayer : KoinComponent {
private val partialFile: String = downloadFile.partialFile private val partialFile: String = downloadFile.partialFile
override fun execute() { override fun execute() {
setPlayerState(PlayerState.DOWNLOADING) setPlayerState(PlayerState.DOWNLOADING, downloadFile)
while (!bufferComplete() && !isOffline()) { while (!bufferComplete() && !isOffline()) {
Util.sleepQuietly(1000L) Util.sleepQuietly(1000L)
if (isCancelled) { if (isCancelled) {
@ -720,10 +704,12 @@ class LocalMediaPlayer : KoinComponent {
while (isRunning) { while (isRunning) {
try { try {
if (playerState === PlayerState.STARTED) { if (playerState === PlayerState.STARTED) {
cachedPosition = mediaPlayer.currentPosition synchronized(playerState) {
mediaSessionHandler.updateMediaSessionPlaybackPosition( if (playerState === PlayerState.STARTED) {
cachedPosition.toLong() cachedPosition = mediaPlayer.currentPosition
) }
}
RxBus.playbackPositionPublisher.onNext(cachedPosition)
} }
Util.sleepQuietly(100L) Util.sleepQuietly(100L)
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -180,7 +180,7 @@ class MediaPlayerController(
downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist) downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist)
jukeboxMediaPlayer.updatePlaylist() jukeboxMediaPlayer.updatePlaylist()
if (shuffle) shuffle() if (shuffle) shuffle()
val isLastTrack = (downloader.playlist.size - 1 == downloader.currentPlayingIndex) val isLastTrack = (downloader.getPlaylist().size - 1 == downloader.currentPlayingIndex)
if (!playNext && !autoPlay && isLastTrack) { if (!playNext && !autoPlay && isLastTrack) {
val mediaPlayerService = runningInstance val mediaPlayerService = runningInstance
@ -190,15 +190,15 @@ class MediaPlayerController(
if (autoPlay) { if (autoPlay) {
play(0) play(0)
} else { } else {
if (localMediaPlayer.currentPlaying == null && downloader.playlist.size > 0) { if (localMediaPlayer.currentPlaying == null && downloader.getPlaylist().isNotEmpty()) {
localMediaPlayer.currentPlaying = downloader.playlist[0] localMediaPlayer.currentPlaying = downloader.getPlaylist()[0]
downloader.playlist[0].setPlaying(true) downloader.getPlaylist()[0].setPlaying(true)
} }
downloader.checkDownloads() downloader.checkDownloads()
} }
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, downloader.currentPlayingIndex,
playerPosition playerPosition
) )
@ -210,7 +210,7 @@ class MediaPlayerController(
val filteredSongs = songs.filterNotNull() val filteredSongs = songs.filterNotNull()
downloader.downloadBackground(filteredSongs, save) downloader.downloadBackground(filteredSongs, save)
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, downloader.currentPlayingIndex,
playerPosition playerPosition
) )
@ -241,7 +241,7 @@ class MediaPlayerController(
fun shuffle() { fun shuffle() {
downloader.shuffle() downloader.shuffle()
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, downloader.currentPlayingIndex,
playerPosition playerPosition
) )
@ -270,7 +270,7 @@ class MediaPlayerController(
downloader.clearPlaylist() downloader.clearPlaylist()
if (serialize) { if (serialize) {
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, playerPosition downloader.currentPlayingIndex, playerPosition
) )
} }
@ -281,16 +281,11 @@ class MediaPlayerController(
@Synchronized @Synchronized
fun clearIncomplete() { fun clearIncomplete() {
reset() reset()
val iterator = downloader.playlist.iterator()
while (iterator.hasNext()) { downloader.clearIncomplete()
val downloadFile = iterator.next()
if (!downloadFile.isCompleteFileAvailable) {
iterator.remove()
}
}
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, downloader.currentPlayingIndex,
playerPosition playerPosition
) )
@ -307,7 +302,7 @@ class MediaPlayerController(
downloader.removeFromPlaylist(downloadFile) downloader.removeFromPlaylist(downloadFile)
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, downloader.currentPlayingIndex,
playerPosition playerPosition
) )
@ -359,12 +354,12 @@ class MediaPlayerController(
when (repeatMode) { when (repeatMode) {
RepeatMode.SINGLE, RepeatMode.OFF -> { RepeatMode.SINGLE, RepeatMode.OFF -> {
// Play next if exists // 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) play(index + 1)
} }
} }
RepeatMode.ALL -> { RepeatMode.ALL -> {
play((index + 1) % downloader.playlist.size) play((index + 1) % downloader.getPlaylist().size)
} }
else -> { else -> {
} }
@ -397,7 +392,8 @@ class MediaPlayerController(
get() = localMediaPlayer.playerState get() = localMediaPlayer.playerState
set(state) { set(state) {
val mediaPlayerService = runningInstance val mediaPlayerService = runningInstance
if (mediaPlayerService != null) localMediaPlayer.setPlayerState(state) if (mediaPlayerService != null)
localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying)
} }
@set:Synchronized @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 val isJukeboxAvailable: Boolean
get() { get() {
try { try {
@ -479,6 +479,7 @@ class MediaPlayerController(
Timber.e(e) Timber.e(e)
} }
}.start() }.start()
// TODO this would be better handled with a Rx command
updateNotification() updateNotification()
} }
@ -490,16 +491,13 @@ class MediaPlayerController(
} }
val playlistSize: Int val playlistSize: Int
get() = downloader.playlist.size get() = downloader.getPlaylist().size
val currentPlayingNumberOnPlaylist: Int val currentPlayingNumberOnPlaylist: Int
get() = downloader.currentPlayingIndex get() = downloader.currentPlayingIndex
val playList: List<DownloadFile> val playList: List<DownloadFile>
get() = downloader.playlist get() = downloader.getPlaylist()
val playListUpdateRevision: Long
get() = downloader.playlistUpdateRevision
val playListDuration: Long val playListDuration: Long
get() = downloader.downloadListDuration get() = downloader.downloadListDuration

View File

@ -13,6 +13,7 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.media.AudioManager import android.media.AudioManager
import android.view.KeyEvent import android.view.KeyEvent
import io.reactivex.rxjava3.disposables.Disposable
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.R 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.domain.PlayerState
import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.Constants 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.Settings
import org.moire.ultrasonic.util.Util.ifNotNull
import timber.log.Timber import timber.log.Timber
/** /**
@ -34,11 +34,10 @@ class MediaPlayerLifecycleSupport : KoinComponent {
private val playbackStateSerializer by inject<PlaybackStateSerializer>() private val playbackStateSerializer by inject<PlaybackStateSerializer>()
private val mediaPlayerController by inject<MediaPlayerController>() private val mediaPlayerController by inject<MediaPlayerController>()
private val downloader by inject<Downloader>() private val downloader by inject<Downloader>()
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private var created = false private var created = false
private var headsetEventReceiver: BroadcastReceiver? = null private var headsetEventReceiver: BroadcastReceiver? = null
private lateinit var mediaSessionEventListener: MediaSessionEventListener private var mediaButtonEventSubscription: Disposable? = null
fun onCreate() { fun onCreate() {
onCreate(false, null) onCreate(false, null)
@ -51,13 +50,10 @@ class MediaPlayerLifecycleSupport : KoinComponent {
return return
} }
mediaSessionEventListener = object : MediaSessionEventListener { mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe {
override fun onMediaButtonEvent(keyEvent: KeyEvent?) { handleKeyEvent(it)
if (keyEvent != null) handleKeyEvent(keyEvent)
}
} }
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
registerHeadsetReceiver() registerHeadsetReceiver()
mediaPlayerController.onCreate() mediaPlayerController.onCreate()
if (autoPlay) mediaPlayerController.preload() if (autoPlay) mediaPlayerController.preload()
@ -75,7 +71,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
// Work-around: Serialize again, as the restore() method creates a // Work-around: Serialize again, as the restore() method creates a
// serialization without current playing info. // serialization without current playing info.
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, downloader.currentPlayingIndex,
mediaPlayerController.playerPosition mediaPlayerController.playerPosition
) )
@ -92,14 +88,13 @@ class MediaPlayerLifecycleSupport : KoinComponent {
if (!created) return if (!created) return
playbackStateSerializer.serializeNow( playbackStateSerializer.serializeNow(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, downloader.currentPlayingIndex,
mediaPlayerController.playerPosition mediaPlayerController.playerPosition
) )
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
mediaPlayerController.clear(false) mediaPlayerController.clear(false)
mediaButtonEventSubscription?.dispose()
applicationContext().unregisterReceiver(headsetEventReceiver) applicationContext().unregisterReceiver(headsetEventReceiver)
mediaPlayerController.onDestroy() mediaPlayerController.onDestroy()
@ -119,7 +114,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
if (intentAction == Constants.CMD_PROCESS_KEYCODE) { if (intentAction == Constants.CMD_PROCESS_KEYCODE) {
if (intent.extras != null) { if (intent.extras != null) {
val event = intent.extras!![Intent.EXTRA_KEY_EVENT] as KeyEvent? val event = intent.extras!![Intent.EXTRA_KEY_EVENT] as KeyEvent?
event?.let { handleKeyEvent(it) } event.ifNotNull { handleKeyEvent(it) }
} }
} else { } else {
handleUltrasonicIntent(intentAction) handleUltrasonicIntent(intentAction)

View File

@ -22,6 +22,7 @@ import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.moire.ultrasonic.R 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.provider.UltrasonicAppWidgetProvider4X4
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Constants 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.MediaSessionHandler
import org.moire.ultrasonic.util.NowPlayingEventDistributor
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.ShufflePlayBuffer import org.moire.ultrasonic.util.ShufflePlayBuffer
import org.moire.ultrasonic.util.SimpleServiceBinder import org.moire.ultrasonic.util.SimpleServiceBinder
@ -64,18 +62,16 @@ class MediaPlayerService : Service() {
private val shufflePlayBuffer by inject<ShufflePlayBuffer>() private val shufflePlayBuffer by inject<ShufflePlayBuffer>()
private val downloader by inject<Downloader>() private val downloader by inject<Downloader>()
private val localMediaPlayer by inject<LocalMediaPlayer>() private val localMediaPlayer by inject<LocalMediaPlayer>()
private val nowPlayingEventDistributor by inject<NowPlayingEventDistributor>()
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private val mediaSessionHandler by inject<MediaSessionHandler>() private val mediaSessionHandler by inject<MediaSessionHandler>()
private var mediaSession: MediaSessionCompat? = null private var mediaSession: MediaSessionCompat? = null
private var mediaSessionToken: MediaSessionCompat.Token? = null private var mediaSessionToken: MediaSessionCompat.Token? = null
private var isInForeground = false private var isInForeground = false
private var notificationBuilder: NotificationCompat.Builder? = null private var notificationBuilder: NotificationCompat.Builder? = null
private lateinit var mediaSessionEventListener: MediaSessionEventListener private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
private val repeatMode: RepeatMode private var currentPlayerState: PlayerState? = null
get() = Settings.repeatMode private var currentTrack: DownloadFile? = null
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
return binder return binder
@ -87,13 +83,11 @@ class MediaPlayerService : Service() {
shufflePlayBuffer.onCreate() shufflePlayBuffer.onCreate()
localMediaPlayer.init() localMediaPlayer.init()
setupOnCurrentPlayingChangedHandler()
setupOnPlayerStateChangedHandler()
setupOnSongCompletedHandler() setupOnSongCompletedHandler()
localMediaPlayer.onPrepared = { localMediaPlayer.onPrepared = {
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, downloader.currentPlayingIndex,
playerPosition playerPosition
) )
@ -102,25 +96,28 @@ class MediaPlayerService : Service() {
localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } 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 // Create Notification Channel
createNotificationChannel() createNotificationChannel()
// Update notification early. It is better to show an empty one temporarily // Update notification early. It is better to show an empty one temporarily
// than waiting too long and letting Android kill the app // than waiting too long and letting Android kill the app
updateNotification(PlayerState.IDLE, null) 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 instance = this
Timber.i("MediaPlayerService created") Timber.i("MediaPlayerService created")
} }
@ -134,8 +131,8 @@ class MediaPlayerService : Service() {
super.onDestroy() super.onDestroy()
instance = null instance = null
try { try {
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
mediaSessionHandler.release() mediaSessionHandler.release()
rxBusSubscription.dispose()
localMediaPlayer.release() localMediaPlayer.release()
downloader.stop() downloader.stop()
@ -201,16 +198,14 @@ class MediaPlayerService : Service() {
@Synchronized @Synchronized
fun setCurrentPlaying(currentPlayingIndex: Int) { fun setCurrentPlaying(currentPlayingIndex: Int) {
try { try {
localMediaPlayer.setCurrentPlaying(downloader.playlist[currentPlayingIndex]) localMediaPlayer.setCurrentPlaying(downloader.getPlaylist()[currentPlayingIndex])
} catch (ignored: IndexOutOfBoundsException) { } catch (ignored: IndexOutOfBoundsException) {
} }
} }
@Synchronized @Synchronized
fun setNextPlaying() { fun setNextPlaying() {
val gaplessPlayback = Settings.gaplessPlayback if (!Settings.gaplessPlayback) {
if (!gaplessPlayback) {
localMediaPlayer.clearNextPlaying(true) localMediaPlayer.clearNextPlaying(true)
return return
} }
@ -218,9 +213,9 @@ class MediaPlayerService : Service() {
var index = downloader.currentPlayingIndex var index = downloader.currentPlayingIndex
if (index != -1) { if (index != -1) {
when (repeatMode) { when (Settings.repeatMode) {
RepeatMode.OFF -> index += 1 RepeatMode.OFF -> index += 1
RepeatMode.ALL -> index = (index + 1) % downloader.playlist.size RepeatMode.ALL -> index = (index + 1) % downloader.getPlaylist().size
RepeatMode.SINGLE -> { RepeatMode.SINGLE -> {
} }
else -> { else -> {
@ -229,8 +224,8 @@ class MediaPlayerService : Service() {
} }
localMediaPlayer.clearNextPlaying(false) localMediaPlayer.clearNextPlaying(false)
if (index < downloader.playlist.size && index != -1) { if (index < downloader.getPlaylist().size && index != -1) {
localMediaPlayer.setNextPlaying(downloader.playlist[index]) localMediaPlayer.setNextPlaying(downloader.getPlaylist()[index])
} else { } else {
localMediaPlayer.clearNextPlaying(true) localMediaPlayer.clearNextPlaying(true)
} }
@ -283,16 +278,15 @@ class MediaPlayerService : Service() {
@Synchronized @Synchronized
fun play(index: Int, start: Boolean) { fun play(index: Int, start: Boolean) {
Timber.v("play requested for %d", index) Timber.v("play requested for %d", index)
if (index < 0 || index >= downloader.playlist.size) { if (index < 0 || index >= downloader.getPlaylist().size) {
resetPlayback() resetPlayback()
} else { } else {
setCurrentPlaying(index) setCurrentPlaying(index)
if (start) { if (start) {
if (jukeboxMediaPlayer.isEnabled) { if (jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.skip(index, 0) jukeboxMediaPlayer.skip(index, 0)
localMediaPlayer.setPlayerState(PlayerState.STARTED)
} else { } else {
localMediaPlayer.play(downloader.playlist[index]) localMediaPlayer.play(downloader.getPlaylist()[index])
} }
} }
downloader.checkDownloads() downloader.checkDownloads()
@ -305,7 +299,7 @@ class MediaPlayerService : Service() {
localMediaPlayer.reset() localMediaPlayer.reset()
localMediaPlayer.setCurrentPlaying(null) localMediaPlayer.setCurrentPlaying(null)
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, playerPosition downloader.currentPlayingIndex, playerPosition
) )
} }
@ -318,7 +312,7 @@ class MediaPlayerService : Service() {
} else { } else {
localMediaPlayer.pause() localMediaPlayer.pause()
} }
localMediaPlayer.setPlayerState(PlayerState.PAUSED) localMediaPlayer.setPlayerState(PlayerState.PAUSED, localMediaPlayer.currentPlaying)
} }
} }
@ -331,7 +325,7 @@ class MediaPlayerService : Service() {
localMediaPlayer.pause() localMediaPlayer.pause()
} }
} }
localMediaPlayer.setPlayerState(PlayerState.STOPPED) localMediaPlayer.setPlayerState(PlayerState.STOPPED, null)
} }
@Synchronized @Synchronized
@ -341,7 +335,7 @@ class MediaPlayerService : Service() {
} else { } else {
localMediaPlayer.start() localMediaPlayer.start()
} }
localMediaPlayer.setPlayerState(PlayerState.STARTED) localMediaPlayer.setPlayerState(PlayerState.STARTED, localMediaPlayer.currentPlaying)
} }
private fun updateWidget(playerState: PlayerState, song: MusicDirectory.Entry?) { private fun updateWidget(playerState: PlayerState, song: MusicDirectory.Entry?) {
@ -354,100 +348,73 @@ class MediaPlayerService : Service() {
UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false) UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false)
} }
private fun setupOnCurrentPlayingChangedHandler() { private fun playerStateChangedHandler(
localMediaPlayer.onCurrentPlayingChanged = { currentPlaying: DownloadFile? -> 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) { val showWhenPaused = playerState !== PlayerState.STOPPED &&
Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying.song) Settings.isNotificationAlwaysEnabled
Util.broadcastA2dpMetaDataChange(
this@MediaPlayerService, playerPosition, currentPlaying, val show = playerState === PlayerState.STARTED || showWhenPaused
downloader.all.size, downloader.currentPlayingIndex + 1 val song = currentPlaying?.song
)
} else { if (isStateChanged) {
Util.broadcastNewTrackInfo(this@MediaPlayerService, null) when {
Util.broadcastA2dpMetaDataChange( playerState === PlayerState.PAUSED -> {
this@MediaPlayerService, playerPosition, null, playbackStateSerializer.serialize(
downloader.all.size, downloader.currentPlayingIndex + 1 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.broadcastPlaybackStatusChange(context, playerState)
Util.broadcastA2dpPlayStatusChange( Util.broadcastA2dpPlayStatusChange(
context, playerState, song, context, playerState, song,
downloader.playlist.size, downloader.getPlaylist().size,
downloader.playlist.indexOf(currentPlaying) + 1, playerPosition 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() { private fun setupOnSongCompletedHandler() {
@ -465,9 +432,9 @@ class MediaPlayerService : Service() {
} }
} }
if (index != -1) { if (index != -1) {
when (repeatMode) { when (Settings.repeatMode) {
RepeatMode.OFF -> { RepeatMode.OFF -> {
if (index + 1 < 0 || index + 1 >= downloader.playlist.size) { if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) {
if (Settings.shouldClearPlaylist) { if (Settings.shouldClearPlaylist) {
clear(true) clear(true)
jukeboxMediaPlayer.updatePlaylist() jukeboxMediaPlayer.updatePlaylist()
@ -478,7 +445,7 @@ class MediaPlayerService : Service() {
} }
} }
RepeatMode.ALL -> { RepeatMode.ALL -> {
play((index + 1) % downloader.playlist.size) play((index + 1) % downloader.getPlaylist().size)
} }
RepeatMode.SINGLE -> play(index) RepeatMode.SINGLE -> play(index)
else -> { else -> {
@ -497,7 +464,7 @@ class MediaPlayerService : Service() {
setNextPlaying() setNextPlaying()
if (serialize) { if (serialize) {
playbackStateSerializer.serialize( playbackStateSerializer.serialize(
downloader.playlist, downloader.getPlaylist(),
downloader.currentPlayingIndex, playerPosition downloader.currentPlayingIndex, playerPosition
) )
} }

View File

@ -19,7 +19,6 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.MediaSessionHandler
import timber.log.Timber import timber.log.Timber
/** /**
@ -30,9 +29,8 @@ import timber.log.Timber
class PlaybackStateSerializer : KoinComponent { class PlaybackStateSerializer : KoinComponent {
private val context by inject<Context>() private val context by inject<Context>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
val lock: Lock = ReentrantLock() private val lock: Lock = ReentrantLock()
private val setup = AtomicBoolean(false) private val setup = AtomicBoolean(false)
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -76,9 +74,6 @@ class PlaybackStateSerializer : KoinComponent {
) )
FileUtil.serialize(context, state, Constants.FILENAME_PLAYLIST_SER) 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?) { fun deserialize(afterDeserialized: (State?) -> Unit?) {
@ -106,7 +101,6 @@ class PlaybackStateSerializer : KoinComponent {
state.currentPlayingPosition state.currentPlayingPosition
) )
mediaSessionHandler.updateMediaSessionQueue(state.songs)
afterDeserialized(state) afterDeserialized(state)
} }
} }

View File

@ -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<MediaSessionCompat.Token> =
PublishSubject.create()
val mediaSessionTokenObservable: Observable<MediaSessionCompat.Token> =
mediaSessionTokenPublisher.observeOn(AndroidSchedulers.mainThread())
.replay(1)
.autoConnect(0)
val mediaButtonEventPublisher: PublishSubject<KeyEvent> =
PublishSubject.create()
val mediaButtonEventObservable: Observable<KeyEvent> =
mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread())
val themeChangedEventPublisher: PublishSubject<Unit> =
PublishSubject.create()
val themeChangedEventObservable: Observable<Unit> =
themeChangedEventPublisher.observeOn(AndroidSchedulers.mainThread())
val playerStatePublisher: PublishSubject<StateWithTrack> =
PublishSubject.create()
val playerStateObservable: Observable<StateWithTrack> =
playerStatePublisher.observeOn(AndroidSchedulers.mainThread())
.replay(1)
.autoConnect(0)
val playlistPublisher: PublishSubject<List<DownloadFile>> =
PublishSubject.create()
val playlistObservable: Observable<List<DownloadFile>> =
playlistPublisher.observeOn(AndroidSchedulers.mainThread())
.replay(1)
.autoConnect(0)
val playbackPositionPublisher: PublishSubject<Int> =
PublishSubject.create()
val playbackPositionObservable: Observable<Int> =
playbackPositionPublisher.observeOn(AndroidSchedulers.mainThread())
.throttleFirst(1, TimeUnit.SECONDS)
.replay(1)
.autoConnect(0)
// Commands
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
PublishSubject.create()
val dismissNowPlayingCommandObservable: Observable<Unit> =
dismissNowPlayingCommandPublisher.observeOn(AndroidSchedulers.mainThread())
val playFromMediaIdCommandPublisher: PublishSubject<Pair<String?, Bundle?>> =
PublishSubject.create()
val playFromMediaIdCommandObservable: Observable<Pair<String?, Bundle?>> =
playFromMediaIdCommandPublisher.observeOn(AndroidSchedulers.mainThread())
val playFromSearchCommandPublisher: PublishSubject<Pair<String?, Bundle?>> =
PublishSubject.create()
val playFromSearchCommandObservable: Observable<Pair<String?, Bundle?>> =
playFromSearchCommandPublisher.observeOn(AndroidSchedulers.mainThread())
val skipToQueueItemCommandPublisher: PublishSubject<Long> =
PublishSubject.create()
val skipToQueueItemCommandObservable: Observable<Long> =
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)
}

View File

@ -26,6 +26,7 @@ import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.ShareDetails import org.moire.ultrasonic.util.ShareDetails
import org.moire.ultrasonic.util.TimeSpan import org.moire.ultrasonic.util.TimeSpan
import org.moire.ultrasonic.util.TimeSpanPicker import org.moire.ultrasonic.util.TimeSpanPicker
import org.moire.ultrasonic.util.Util.ifNotNull
/** /**
* This class handles sharing items in the media library * 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.ShareOnServer && shareDetails.Entries.size == 1) return null
if (shareDetails.Entries.isEmpty()) { 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) ids.add(it)
} }
} else { } else {

View File

@ -1,10 +1,13 @@
package org.moire.ultrasonic.util package org.moire.ultrasonic.util
import android.os.AsyncTask
import android.os.StatFs import android.os.StatFs
import android.system.Os import android.system.Os
import java.util.ArrayList import java.util.ArrayList
import java.util.HashSet 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.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Playlist 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. * 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() { fun clean() {
try { launch(exceptionHandler("clean")) {
BackgroundCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) backgroundCleanup()
} catch (all: Exception) {
// If an exception is thrown, assume we execute correctly the next time
Timber.w(all, "Exception in CacheCleaner.clean")
} }
} }
fun cleanSpace() { fun cleanSpace() {
try { launch(exceptionHandler("cleanSpace")) {
BackgroundSpaceCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) backgroundSpaceCleanup()
} catch (all: Exception) {
// If an exception is thrown, assume we execute correctly the next time
Timber.w(all, "Exception in CacheCleaner.cleanSpace")
} }
} }
fun cleanPlaylists(playlists: List<Playlist>) { fun cleanPlaylists(playlists: List<Playlist>) {
try { launch(exceptionHandler("cleanPlaylists")) {
BackgroundPlaylistsCleanup().executeOnExecutor( backgroundPlaylistsCleanup(playlists)
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")
} }
} }
private class BackgroundCleanup : AsyncTask<Void?, Void?, Void?>() { private fun backgroundCleanup() {
override fun doInBackground(vararg params: Void?): Void? { try {
try { val files: MutableList<StorageFile> = ArrayList()
Thread.currentThread().name = "BackgroundCleanup" val dirs: MutableList<StorageFile> = ArrayList()
val files: MutableList<StorageFile> = ArrayList()
val dirs: MutableList<StorageFile> = 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<StorageFile> = ArrayList()
val dirs: MutableList<StorageFile> = ArrayList()
findCandidatesForDeletion(musicDirectory, files, dirs)
val bytesToDelete = getMinimumDelete(files)
if (bytesToDelete > 0L) {
sortByAscendingModificationTime(files) sortByAscendingModificationTime(files)
val filesToNotDelete = findFilesToNotDelete() val filesToNotDelete = findFilesToNotDelete()
deleteFiles(files, filesToNotDelete, bytesToDelete, false)
deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true)
deleteEmptyDirs(dirs, filesToNotDelete)
} catch (all: RuntimeException) {
Timber.e(all, "Error in cache cleaning.")
} }
return null } catch (all: RuntimeException) {
Timber.e(all, "Error in cache cleaning.")
} }
} }
private class BackgroundSpaceCleanup : AsyncTask<Void?, Void?, Void?>() { private fun backgroundPlaylistsCleanup(vararg params: List<Playlist>) {
override fun doInBackground(vararg params: Void?): Void? { try {
try { val activeServerProvider = inject<ActiveServerProvider>(
Thread.currentThread().name = "BackgroundSpaceCleanup" ActiveServerProvider::class.java
)
val files: MutableList<StorageFile> = ArrayList() val server = activeServerProvider.value.getActiveServer().name
val dirs: MutableList<StorageFile> = ArrayList() val playlistFiles = listFiles(getPlaylistDirectory(server))
val playlists = params[0]
findCandidatesForDeletion(musicDirectory, files, dirs) for ((_, name) in playlists) {
playlistFiles.remove(getPlaylistFile(server, name))
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.")
} }
return null
}
}
private class BackgroundPlaylistsCleanup : AsyncTask<List<Playlist>, Void?, Void?>() { for (playlist in playlistFiles) {
override fun doInBackground(vararg params: List<Playlist>): Void? { playlist.delete()
try {
val activeServerProvider = inject<ActiveServerProvider>(
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.")
} }
return null } catch (all: RuntimeException) {
Timber.e(all, "Error in playlist cache cleaning.")
} }
} }

View File

@ -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
}
}

View File

@ -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<MediaSessionEventListener> =
listOf<MediaSessionEventListener>().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) }
}
}

View File

@ -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?) {}
}

View File

@ -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
import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN
import android.view.KeyEvent import android.view.KeyEvent
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlin.Pair
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.imageloader.BitmapUtils import org.moire.ultrasonic.imageloader.BitmapUtils
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
import org.moire.ultrasonic.service.DownloadFile 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 import timber.log.Timber
private const val INTENT_CODE_MEDIA_BUTTON = 161 private const val INTENT_CODE_MEDIA_BUTTON = 161
private const val CALL_DIVIDE = 10
/** /**
* Central place to handle the state of the MediaSession * Central place to handle the state of the MediaSession
*/ */
@ -39,21 +42,22 @@ class MediaSessionHandler : KoinComponent {
private var playbackActions: Long? = null private var playbackActions: Long? = null
private var cachedPlayingIndex: Long? = null private var cachedPlayingIndex: Long? = null
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private val applicationContext by inject<Context>() private val applicationContext by inject<Context>()
private var referenceCount: Int = 0 private var referenceCount: Int = 0
private var cachedPlaylist: List<MediaSessionCompat.QueueItem>? = null private var cachedPlaylist: List<DownloadFile>? = null
private var playbackPositionDelayCount: Int = 0
private var cachedPosition: Long = 0 private var cachedPosition: Long = 0
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
fun release() { fun release() {
if (referenceCount > 0) referenceCount-- if (referenceCount > 0) referenceCount--
if (referenceCount > 0) return if (referenceCount > 0) return
mediaSession?.isActive = false mediaSession?.isActive = false
mediaSessionEventDistributor.releaseCachedMediaSessionToken() RxBus.releaseMediaSessionToken()
rxBusSubscription.dispose()
mediaSession?.release() mediaSession?.release()
mediaSession = null mediaSession = null
@ -72,7 +76,7 @@ class MediaSessionHandler : KoinComponent {
mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService")
val mediaSessionToken = mediaSession?.sessionToken ?: return val mediaSessionToken = mediaSession?.sessionToken ?: return
mediaSessionEventDistributor.raiseMediaSessionTokenCreatedEvent(mediaSessionToken) RxBus.mediaSessionTokenPublisher.onNext(mediaSessionToken)
updateMediaButtonReceiver() updateMediaButtonReceiver()
@ -93,14 +97,14 @@ class MediaSessionHandler : KoinComponent {
super.onPlayFromMediaId(mediaId, extras) super.onPlayFromMediaId(mediaId, extras)
Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId) 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?) { override fun onPlayFromSearch(query: String?, extras: Bundle?) {
super.onPlayFromSearch(query, extras) super.onPlayFromSearch(query, extras)
Timber.d("Media Session Callback: onPlayFromSearch %s", query) Timber.d("Media Session Callback: onPlayFromSearch %s", query)
mediaSessionEventDistributor.raisePlayFromSearchRequestedEvent(query, extras) RxBus.playFromSearchCommandPublisher.onNext(Pair(query, extras))
} }
override fun onPause() { override fun onPause() {
@ -147,28 +151,36 @@ class MediaSessionHandler : KoinComponent {
// This probably won't be necessary once we implement more // This probably won't be necessary once we implement more
// of the modern media APIs, like the MediaController etc. // of the modern media APIs, like the MediaController etc.
val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent? val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent?
mediaSessionEventDistributor.raiseMediaButtonEvent(event) event.ifNotNull { RxBus.mediaButtonEventPublisher.onNext(it) }
return true return true
} }
override fun onSkipToQueueItem(id: Long) { override fun onSkipToQueueItem(id: Long) {
super.onSkipToQueueItem(id) 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 // It seems to be the best practice to set this to true for the lifetime of the session
mediaSession?.isActive = true 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") Timber.i("MediaSessionHandler.initialize Media Session created")
} }
@Suppress("TooGenericExceptionCaught", "LongMethod") @Suppress("LongMethod", "ComplexMethod")
fun updateMediaSession( private fun updateMediaSession(
currentPlaying: DownloadFile?, playerState: PlayerState,
currentPlayingIndex: Long?, currentPlaying: DownloadFile?
playerState: PlayerState
) { ) {
Timber.d("Updating the MediaSession") 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_ALBUM, song.album)
metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title) metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title)
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover) metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover)
} catch (e: Exception) { } catch (all: Exception) {
Timber.e(e, "Error setting the metadata") Timber.e(all, "Error setting the metadata")
} }
} }
@ -244,59 +256,46 @@ class MediaSessionHandler : KoinComponent {
// Set actions // Set actions
playbackStateBuilder.setActions(playbackActions!!) playbackStateBuilder.setActions(playbackActions!!)
cachedPlayingIndex = currentPlayingIndex val index = cachedPlaylist?.indexOf(currentPlaying)
setMediaSessionQueue(cachedPlaylist) cachedPlayingIndex = if (index == null || index < 0) null else index.toLong()
if ( cachedPlaylist.ifNotNull { setMediaSessionQueue(it) }
currentPlayingIndex != null && cachedPlaylist != null &&
!Settings.shouldDisableNowPlayingListSending if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending)
) cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) }
playbackStateBuilder.setActiveQueueItemId(currentPlayingIndex)
// Save the playback state // Save the playback state
mediaSession?.setPlaybackState(playbackStateBuilder.build()) mediaSession?.setPlaybackState(playbackStateBuilder.build())
} }
fun updateMediaSessionQueue(playlist: Iterable<MusicDirectory.Entry>) { private fun updateMediaSessionQueue(playlist: List<DownloadFile>) {
// This call is cached because Downloader may initialize earlier than the MediaSession cachedPlaylist = playlist
cachedPlaylist = playlist.mapIndexed { id, song -> setMediaSessionQueue(playlist)
MediaSessionCompat.QueueItem(
Util.getMediaDescriptionForEntry(song),
id.toLong()
)
}
setMediaSessionQueue(cachedPlaylist)
} }
private fun setMediaSessionQueue(queue: List<MediaSessionCompat.QueueItem>?) { private fun setMediaSessionQueue(playlist: List<DownloadFile>) {
if (mediaSession == null) return if (mediaSession == null) return
if (Settings.shouldDisableNowPlayingListSending) 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?.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing))
mediaSession?.setQueue(queue) mediaSession?.setQueue(queue)
} }
fun updateMediaSessionPlaybackPosition(playbackPosition: Long) { private fun updateMediaSessionPlaybackPosition(playbackPosition: Int) {
cachedPosition = playbackPosition.toLong()
cachedPosition = playbackPosition
if (mediaSession == null) return
if (playbackState == null || playbackActions == null) return 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() val playbackStateBuilder = PlaybackStateCompat.Builder()
playbackStateBuilder.setState(playbackState!!, playbackPosition, 1.0f) playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f)
playbackStateBuilder.setActions(playbackActions!!) playbackStateBuilder.setActions(playbackActions!!)
if ( if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending)
cachedPlayingIndex != null && cachedPlaylist != null && cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) }
!Settings.shouldDisableNowPlayingListSending
)
playbackStateBuilder.setActiveQueueItemId(cachedPlayingIndex!!)
mediaSession?.setPlaybackState(playbackStateBuilder.build()) mediaSession?.setPlaybackState(playbackStateBuilder.build())
} }

View File

@ -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<NowPlayingEventListener> =
listOf<NowPlayingEventListener>().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() }
}
}

View File

@ -1,10 +0,0 @@
package org.moire.ultrasonic.util
/**
* Callback interface for Now Playing event subscribers
*/
interface NowPlayingEventListener {
fun onDismissNowPlaying()
fun onHideNowPlaying()
fun onShowNowPlaying()
}

View File

@ -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<T>(activity: Activity?) : BackgroundTask<T>(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) {}
}

View File

@ -37,6 +37,10 @@ class StorageFile private constructor(
return getPath().compareTo(other.getPath()) return getPath().compareTo(other.getPath())
} }
override fun toString(): String {
return name
}
var name: String = fileManager.getName(abstractFile) var name: String = fileManager.getName(abstractFile)
var isDirectory: Boolean = fileManager.isDirectory(abstractFile) var isDirectory: Boolean = fileManager.isDirectory(abstractFile)

View File

@ -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<ThemeChangedEventListener> =
listOf<ThemeChangedEventListener>().toMutableList()
fun subscribe(listener: ThemeChangedEventListener) {
eventListenerList.add(listener)
}
fun unsubscribe(listener: ThemeChangedEventListener) {
eventListenerList.remove(listener)
}
fun RaiseThemeChangedEvent() {
eventListenerList.forEach { listener -> listener.onThemeChanged() }
}
}

View File

@ -1,8 +0,0 @@
package org.moire.ultrasonic.util
/**
* Callback interface for Theme change event subscribers
*/
interface ThemeChangedEventListener {
fun onThemeChanged()
}

View File

@ -10,7 +10,7 @@ import java.io.File
/** /**
* Logs the stack trace of uncaught exceptions to a file on the SD card. * Logs the stack trace of uncaught exceptions to a file on the SD card.
*/ */
class SubsonicUncaughtExceptionHandler( class UncaughtExceptionHandler(
private val context: Context private val context: Context
) : Thread.UncaughtExceptionHandler { ) : Thread.UncaughtExceptionHandler {
private val defaultHandler: Thread.UncaughtExceptionHandler? = private val defaultHandler: Thread.UncaughtExceptionHandler? =
@ -32,8 +32,8 @@ class SubsonicUncaughtExceptionHandler(
throwable.printStackTrace(printWriter) throwable.printStackTrace(printWriter)
Timber.e(throwable, "Uncaught Exception! %s", logMessage) Timber.e(throwable, "Uncaught Exception! %s", logMessage)
Timber.i("Stack trace written to %s", file) Timber.i("Stack trace written to %s", file)
} catch (x: Throwable) { } catch (all: Throwable) {
Timber.e(x, "Failed to write stack trace to %s", file) Timber.e(all, "Failed to write stack trace to %s", file)
} finally { } finally {
printWriter.safeClose() printWriter.safeClose()
defaultHandler?.uncaughtException(thread, throwable) defaultHandler?.uncaughtException(thread, throwable)

View File

@ -43,8 +43,6 @@ import android.widget.Toast
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import java.io.Closeable import java.io.Closeable
import java.io.IOException
import java.io.File
import java.io.UnsupportedEncodingException import java.io.UnsupportedEncodingException
import java.security.MessageDigest import java.security.MessageDigest
import java.text.DecimalFormat import java.text.DecimalFormat
@ -482,7 +480,6 @@ object Util {
} }
/** /**
*
* Broadcasts the given song info as the new song being played. * Broadcasts the given song info as the new song being played.
*/ */
fun broadcastNewTrackInfo(context: Context, song: MusicDirectory.Entry?) { fun broadcastNewTrackInfo(context: Context, song: MusicDirectory.Entry?) {
@ -902,6 +899,14 @@ object Util {
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 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 : Any, R> T?.ifNotNull(block: (T) -> R): R? {
return this?.let(block)
}
/** /**
* Small data class to store information about the current network * Small data class to store information about the current network
**/ **/

View File

@ -1,21 +1,23 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:layout_width="fill_parent" xmlns:tools="http://schemas.android.com/tools"
a:layout_height="fill_parent" a:layout_width="fill_parent"
a:orientation="horizontal"> a:layout_height="fill_parent"
a:baselineAligned="false"
a:orientation="horizontal">
<org.moire.ultrasonic.util.MyViewFlipper <ViewFlipper
a:id="@+id/current_playing_playlist_flipper" a:id="@+id/current_playing_playlist_flipper"
a:layout_width="0dp" a:layout_width="0dp"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:layout_weight="1"> a:layout_weight="1"
tools:ignore="UselessParent">
<FrameLayout <FrameLayout
a:id="@+id/current_playing_album_art_layout" a:id="@+id/current_playing_album_art_layout"
a:layout_width="fill_parent" a:layout_width="fill_parent"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:layout_weight="1" a:gravity="start"
a:gravity="left"
a:orientation="horizontal"> a:orientation="horizontal">
<ImageView <ImageView
@ -31,9 +33,9 @@
a:layout_width="fill_parent" a:layout_width="fill_parent"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:gravity="bottom" a:gravity="bottom"
a:orientation="vertical" > a:orientation="vertical">
<include layout="@layout/player_media_info"/> <include layout="@layout/player_media_info" />
<LinearLayout <LinearLayout
a:id="@+id/song_rating" a:id="@+id/song_rating"
@ -48,10 +50,11 @@
a:layout_width="0dip" a:layout_width="0dip"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:layout_weight="1" a:layout_weight="1"
a:padding="10dip"
a:background="@android:color/transparent" a:background="@android:color/transparent"
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:importantForAccessibility="no"
a:padding="10dip"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow" />
@ -60,10 +63,11 @@
a:layout_width="0dip" a:layout_width="0dip"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:layout_weight="1" a:layout_weight="1"
a:padding="10dip"
a:background="@android:color/transparent" a:background="@android:color/transparent"
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:importantForAccessibility="no"
a:padding="10dip"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow" />
@ -72,10 +76,11 @@
a:layout_width="0dip" a:layout_width="0dip"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:layout_weight="1" a:layout_weight="1"
a:padding="10dip"
a:background="@android:color/transparent" a:background="@android:color/transparent"
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:importantForAccessibility="no"
a:padding="10dip"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow" />
@ -84,10 +89,11 @@
a:layout_width="0dip" a:layout_width="0dip"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:layout_weight="1" a:layout_weight="1"
a:padding="10dip"
a:background="@android:color/transparent" a:background="@android:color/transparent"
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:importantForAccessibility="no"
a:padding="10dip"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow" />
@ -96,10 +102,11 @@
a:layout_width="0dip" a:layout_width="0dip"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:layout_weight="1" a:layout_weight="1"
a:padding="10dip"
a:background="@android:color/transparent" a:background="@android:color/transparent"
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:importantForAccessibility="no"
a:padding="10dip"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow" />
@ -113,15 +120,16 @@
a:layout_marginStart="60dip" a:layout_marginStart="60dip"
a:layout_marginEnd="60dip" a:layout_marginEnd="60dip"
a:background="@color/translucent" a:background="@color/translucent"
a:orientation="vertical"/> a:orientation="vertical" />
<include layout="@layout/player_slider"/> <include layout="@layout/player_slider" />
<include layout="@layout/media_buttons"/>
<include layout="@layout/media_buttons" />
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
<include layout="@layout/current_playlist"/> <include layout="@layout/current_playlist" />
</org.moire.ultrasonic.util.MyViewFlipper> </ViewFlipper>
</LinearLayout> </LinearLayout>

View File

@ -4,7 +4,7 @@
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:orientation="vertical" > a:orientation="vertical" >
<org.moire.ultrasonic.util.MyViewFlipper <ViewFlipper
a:id="@+id/current_playing_playlist_flipper" a:id="@+id/current_playing_playlist_flipper"
a:layout_width="fill_parent" a:layout_width="fill_parent"
a:layout_height="0dip" a:layout_height="0dip"
@ -14,7 +14,6 @@
a:id="@+id/current_playing_album_art_layout" a:id="@+id/current_playing_album_art_layout"
a:layout_width="fill_parent" a:layout_width="fill_parent"
a:layout_height="fill_parent" a:layout_height="fill_parent"
a:layout_weight="1"
a:gravity="start" a:gravity="start"
a:orientation="vertical" > a:orientation="vertical" >
@ -52,7 +51,8 @@
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow"
a:importantForAccessibility="no" />
<ImageView <ImageView
a:id="@+id/song_five_star_2" a:id="@+id/song_five_star_2"
@ -64,7 +64,8 @@
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow"
a:importantForAccessibility="no" />
<ImageView <ImageView
a:id="@+id/song_five_star_3" a:id="@+id/song_five_star_3"
@ -76,7 +77,8 @@
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow"
a:importantForAccessibility="no" />
<ImageView <ImageView
a:id="@+id/song_five_star_4" a:id="@+id/song_five_star_4"
@ -88,7 +90,8 @@
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow"
a:importantForAccessibility="no" />
<ImageView <ImageView
a:id="@+id/song_five_star_5" a:id="@+id/song_five_star_5"
@ -100,7 +103,8 @@
a:focusable="false" a:focusable="false"
a:gravity="center_vertical" a:gravity="center_vertical"
a:scaleType="fitCenter" a:scaleType="fitCenter"
a:src="?attr/star_hollow" /> a:src="?attr/star_hollow"
a:importantForAccessibility="no" />
</LinearLayout> </LinearLayout>
@ -119,7 +123,7 @@
</RelativeLayout> </RelativeLayout>
<include layout="@layout/current_playlist" /> <include layout="@layout/current_playlist" />
</org.moire.ultrasonic.util.MyViewFlipper> </ViewFlipper>
<include layout="@layout/player_media_info" /> <include layout="@layout/player_media_info" />

View File

@ -5,8 +5,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
a:orientation="vertical" a:orientation="vertical"
a:layout_width="fill_parent" a:layout_width="fill_parent"
a:layout_height="fill_parent" a:layout_height="fill_parent">
a:layout_weight="1">
<TextView <TextView
a:id="@+id/playlist_empty" a:id="@+id/playlist_empty"

View File

@ -11,7 +11,6 @@
a:id="@+id/button_shuffle" a:id="@+id/button_shuffle"
a:layout_width="0dip" a:layout_width="0dip"
a:layout_height="26dp" a:layout_height="26dp"
a:layout_alignParentLeft="true"
a:layout_gravity="center" a:layout_gravity="center"
a:layout_weight="1" a:layout_weight="1"
a:adjustViewBounds="true" a:adjustViewBounds="true"

View File

@ -18,7 +18,9 @@
a:id="@+id/current_playing_song" a:id="@+id/current_playing_song"
a:layout_width="wrap_content" a:layout_width="wrap_content"
a:layout_height="wrap_content" a:layout_height="wrap_content"
a:ellipsize="start" a:layout_marginEnd="10dip"
a:paddingRight="30dip"
a:ellipsize="marquee"
a:gravity="left" a:gravity="left"
a:singleLine="true" a:singleLine="true"
a:textAppearance="?android:attr/textAppearanceLarge" a:textAppearance="?android:attr/textAppearanceLarge"
@ -29,7 +31,7 @@
a:id="@+id/current_playing_artist" a:id="@+id/current_playing_artist"
a:layout_width="wrap_content" a:layout_width="wrap_content"
a:layout_height="wrap_content" a:layout_height="wrap_content"
a:ellipsize="start" a:ellipsize="marquee"
a:gravity="left" a:gravity="left"
a:singleLine="true" a:singleLine="true"
a:textAppearance="?android:attr/textAppearanceSmall" a:textAppearance="?android:attr/textAppearanceSmall"
@ -62,7 +64,8 @@
a:ellipsize="start" a:ellipsize="start"
a:gravity="right" a:gravity="right"
a:text="0 / 0" a:text="0 / 0"
a:textAppearance="?android:attr/textAppearanceSmall" /> a:textAppearance="?android:attr/textAppearanceSmall"
tools:ignore="HardcodedText" />
<TextView <TextView
a:id="@+id/current_total_duration" a:id="@+id/current_total_duration"