Merged develop into api30
This commit is contained in:
commit
5c7cde2349
|
@ -42,7 +42,9 @@ ext.versions = [
|
|||
timber : "4.7.1",
|
||||
fastScroll : "2.0.1",
|
||||
colorPicker : "2.2.3",
|
||||
fsaf : "1.1"
|
||||
fsaf : "1.1",
|
||||
rxJava : "3.1.2",
|
||||
rxAndroid : "3.0.0",
|
||||
]
|
||||
|
||||
ext.gradlePlugins = [
|
||||
|
@ -91,6 +93,8 @@ ext.other = [
|
|||
sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView",
|
||||
colorPickerView : "com.github.skydoves:colorpickerview:$versions.colorPicker",
|
||||
fsaf : "com.github.K1rakishou:Fuck-Storage-Access-Framework:$versions.fsaf",
|
||||
rxJava : "io.reactivex.rxjava3:rxjava:$versions.rxJava",
|
||||
rxAndroid : "io.reactivex.rxjava3:rxandroid:$versions.rxAndroid",
|
||||
]
|
||||
|
||||
ext.testing = [
|
||||
|
|
|
@ -3,11 +3,12 @@
|
|||
<ManuallySuppressedIssues></ManuallySuppressedIssues>
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.<no name provided>$!append && !playNext && !unpin && !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: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:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons()</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.DownloadTask$String.format("Download of '%s' was cancelled", 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: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>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:TrackCollectionFragment.kt$TrackCollectionFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</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.PositionCache$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>TooManyFunctions:MediaPlayerService.kt$MediaPlayerService : Service</ID>
|
||||
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
|
||||
<ID>TooManyFunctions:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment</ID>
|
||||
<ID>UtilityClassWithPublicConstructor:CommunicationErrorHandler.kt$CommunicationErrorHandler</ID>
|
||||
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
|
|
@ -107,6 +107,8 @@ dependencies {
|
|||
implementation other.sortListView
|
||||
implementation other.colorPickerView
|
||||
implementation other.fsaf
|
||||
implementation other.rxJava
|
||||
implementation other.rxAndroid
|
||||
|
||||
kapt androidSupport.room
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -20,7 +20,6 @@ package org.moire.ultrasonic.util;
|
|||
|
||||
import android.app.Activity;
|
||||
import android.os.Handler;
|
||||
import org.moire.ultrasonic.service.CommunicationErrorHandler;
|
||||
|
||||
/**
|
||||
* @author Sindre Mehus
|
||||
|
@ -54,12 +53,12 @@ public abstract class BackgroundTask<T> implements ProgressListener
|
|||
|
||||
protected void error(Throwable error)
|
||||
{
|
||||
CommunicationErrorHandler.Companion.handleError(error, activity);
|
||||
CommunicationError.handleError(error, activity);
|
||||
}
|
||||
|
||||
protected String getErrorMessage(Throwable error)
|
||||
{
|
||||
return CommunicationErrorHandler.Companion.getErrorMessage(error, activity);
|
||||
return CommunicationError.getErrorMessage(error, activity);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import androidx.navigation.ui.setupWithNavController
|
|||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.moire.ultrasonic.R
|
||||
|
@ -43,15 +44,12 @@ import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
|||
import org.moire.ultrasonic.service.DownloadFile
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.NowPlayingEventDistributor
|
||||
import org.moire.ultrasonic.util.NowPlayingEventListener
|
||||
import org.moire.ultrasonic.util.ServerColor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.SubsonicUncaughtExceptionHandler
|
||||
import org.moire.ultrasonic.util.ThemeChangedEventDistributor
|
||||
import org.moire.ultrasonic.util.ThemeChangedEventListener
|
||||
import org.moire.ultrasonic.util.UncaughtExceptionHandler
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -73,15 +71,13 @@ class NavigationActivity : AppCompatActivity() {
|
|||
private var headerBackgroundImage: ImageView? = null
|
||||
|
||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
||||
private lateinit var nowPlayingEventListener: NowPlayingEventListener
|
||||
private lateinit var themeChangedEventListener: ThemeChangedEventListener
|
||||
private var themeChangedEventSubscription: Disposable? = null
|
||||
private var playerStateSubscription: Disposable? = null
|
||||
|
||||
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
||||
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
private val nowPlayingEventDistributor: NowPlayingEventDistributor by inject()
|
||||
private val themeChangedEventDistributor: ThemeChangedEventDistributor by inject()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private val serverRepository: ServerSettingDao by inject()
|
||||
|
||||
|
@ -166,28 +162,22 @@ class NavigationActivity : AppCompatActivity() {
|
|||
showWelcomeDialog()
|
||||
}
|
||||
|
||||
nowPlayingEventListener = object : NowPlayingEventListener {
|
||||
override fun onDismissNowPlaying() {
|
||||
nowPlayingHidden = true
|
||||
hideNowPlaying()
|
||||
}
|
||||
RxBus.dismissNowPlayingCommandObservable.subscribe {
|
||||
nowPlayingHidden = true
|
||||
hideNowPlaying()
|
||||
}
|
||||
|
||||
override fun onHideNowPlaying() {
|
||||
hideNowPlaying()
|
||||
}
|
||||
|
||||
override fun onShowNowPlaying() {
|
||||
playerStateSubscription = RxBus.playerStateObservable.subscribe {
|
||||
if (it.state === PlayerState.STARTED || it.state === PlayerState.PAUSED)
|
||||
showNowPlaying()
|
||||
}
|
||||
else
|
||||
hideNowPlaying()
|
||||
}
|
||||
|
||||
themeChangedEventListener = object : ThemeChangedEventListener {
|
||||
override fun onThemeChanged() { recreate() }
|
||||
themeChangedEventSubscription = RxBus.themeChangedEventObservable.subscribe {
|
||||
recreate()
|
||||
}
|
||||
|
||||
nowPlayingEventDistributor.subscribe(nowPlayingEventListener)
|
||||
themeChangedEventDistributor.subscribe(themeChangedEventListener)
|
||||
|
||||
serverRepository.liveServerCount().observe(
|
||||
this,
|
||||
{ count ->
|
||||
|
@ -234,8 +224,8 @@ class NavigationActivity : AppCompatActivity() {
|
|||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
nowPlayingEventDistributor.unsubscribe(nowPlayingEventListener)
|
||||
themeChangedEventDistributor.unsubscribe(themeChangedEventListener)
|
||||
themeChangedEventSubscription?.dispose()
|
||||
playerStateSubscription?.dispose()
|
||||
imageLoaderProvider.clearImageLoader()
|
||||
}
|
||||
|
||||
|
@ -382,8 +372,8 @@ class NavigationActivity : AppCompatActivity() {
|
|||
|
||||
private fun setUncaughtExceptionHandler() {
|
||||
val handler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
if (handler !is SubsonicUncaughtExceptionHandler) {
|
||||
Thread.setDefaultUncaughtExceptionHandler(SubsonicUncaughtExceptionHandler(this))
|
||||
if (handler !is UncaughtExceptionHandler) {
|
||||
Thread.setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler(this))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,10 +4,7 @@ import org.koin.android.ext.koin.androidContext
|
|||
import org.koin.dsl.module
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.MediaSessionEventDistributor
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.NowPlayingEventDistributor
|
||||
import org.moire.ultrasonic.util.ThemeChangedEventDistributor
|
||||
|
||||
/**
|
||||
* This Koin module contains the registration of general classes needed for Ultrasonic
|
||||
|
@ -15,8 +12,5 @@ import org.moire.ultrasonic.util.ThemeChangedEventDistributor
|
|||
val applicationModule = module {
|
||||
single { ActiveServerProvider(get()) }
|
||||
single { ImageLoaderProvider(androidContext()) }
|
||||
single { NowPlayingEventDistributor() }
|
||||
single { ThemeChangedEventDistributor() }
|
||||
single { MediaSessionEventDistributor() }
|
||||
single { MediaSessionHandler() }
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ package org.moire.ultrasonic.domain
|
|||
import java.text.SimpleDateFormat
|
||||
import kotlin.LazyThreadSafetyMode.NONE
|
||||
import org.moire.ultrasonic.api.subsonic.models.Playlist as APIPlaylist
|
||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
||||
|
||||
internal val playlistDateFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
|
||||
|
||||
|
@ -17,7 +18,7 @@ fun APIPlaylist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory(
|
|||
fun APIPlaylist.toDomainEntity(): Playlist = Playlist(
|
||||
this.id, this.name, this.owner,
|
||||
this.comment, this.songCount.toString(),
|
||||
this.created?.let { playlistDateFormat.format(it.time) } ?: "",
|
||||
this.created.ifNotNull { playlistDateFormat.format(it.time) } ?: "",
|
||||
public
|
||||
)
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ package org.moire.ultrasonic.domain
|
|||
import java.text.SimpleDateFormat
|
||||
import kotlin.LazyThreadSafetyMode.NONE
|
||||
import org.moire.ultrasonic.api.subsonic.models.Share as APIShare
|
||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
||||
|
||||
internal val shareTimeFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
|
||||
|
||||
|
@ -13,11 +14,11 @@ fun List<APIShare>.toDomainEntitiesList(): List<Share> = this.map {
|
|||
}
|
||||
|
||||
fun APIShare.toDomainEntity(): Share = Share(
|
||||
created = this@toDomainEntity.created?.let { shareTimeFormat.format(it.time) },
|
||||
created = this@toDomainEntity.created.ifNotNull { shareTimeFormat.format(it.time) },
|
||||
description = this@toDomainEntity.description,
|
||||
expires = this@toDomainEntity.expires?.let { shareTimeFormat.format(it.time) },
|
||||
expires = this@toDomainEntity.expires.ifNotNull { shareTimeFormat.format(it.time) },
|
||||
id = this@toDomainEntity.id,
|
||||
lastVisited = this@toDomainEntity.lastVisited?.let { shareTimeFormat.format(it.time) },
|
||||
lastVisited = this@toDomainEntity.lastVisited.ifNotNull { shareTimeFormat.format(it.time) },
|
||||
url = this@toDomainEntity.url,
|
||||
username = this@toDomainEntity.username,
|
||||
visitCount = this@toDomainEntity.visitCount.toLong(),
|
||||
|
|
|
@ -220,6 +220,6 @@ class DownloadListModel(application: Application) : GenericListModel(application
|
|||
private val downloader by inject<Downloader>()
|
||||
|
||||
fun getList(): LiveData<List<DownloadFile>> {
|
||||
return downloader.observableList
|
||||
return downloader.observableDownloads
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,9 +18,9 @@ import org.koin.core.component.inject
|
|||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.moire.ultrasonic.domain.MusicFolder
|
||||
import org.moire.ultrasonic.service.CommunicationErrorHandler
|
||||
import org.moire.ultrasonic.service.MusicService
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
|
||||
/**
|
||||
|
@ -94,7 +94,7 @@ open class GenericListModel(application: Application) :
|
|||
|
||||
private fun handleException(exception: Exception, context: Context) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
CommunicationErrorHandler.handleError(exception, context)
|
||||
CommunicationError.handleError(exception, context)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -38,17 +38,22 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.navigation.Navigation
|
||||
import com.mobeta.android.dslv.DragSortListView
|
||||
import com.mobeta.android.dslv.DragSortListView.DragSortListener
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.ArrayList
|
||||
import java.util.Date
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
@ -66,13 +71,14 @@ import org.moire.ultrasonic.service.DownloadFile
|
|||
import org.moire.ultrasonic.service.LocalMediaPlayer
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.SilentBackgroundTask
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.view.AutoRepeatButton
|
||||
import org.moire.ultrasonic.view.SongListAdapter
|
||||
|
@ -81,15 +87,13 @@ import timber.log.Timber
|
|||
|
||||
/**
|
||||
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
|
||||
*
|
||||
* TODO: This class was more or less straight converted from Java legacy code.
|
||||
* There are many places where further cleanup would be nice.
|
||||
* The usage of threads and SilentBackgroundTask can be replaced with Coroutines.
|
||||
*/
|
||||
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
||||
class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinComponent {
|
||||
// Settings
|
||||
private var currentRevision: Long = 0
|
||||
class PlayerFragment :
|
||||
Fragment(),
|
||||
GestureDetector.OnGestureListener,
|
||||
KoinComponent,
|
||||
CoroutineScope by CoroutineScope(Dispatchers.Main) {
|
||||
private var swipeDistance = 0
|
||||
private var swipeVelocity = 0
|
||||
private var jukeboxAvailable = false
|
||||
|
@ -110,7 +114,8 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
private lateinit var executorService: ScheduledExecutorService
|
||||
private var currentPlaying: DownloadFile? = null
|
||||
private var currentSong: MusicDirectory.Entry? = null
|
||||
private var onProgressChangedTask: SilentBackgroundTask<Void?>? = null
|
||||
private var rxBusSubscription: Disposable? = null
|
||||
private var ioScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
// Views and UI Elements
|
||||
private lateinit var visualizerViewLayout: LinearLayout
|
||||
|
@ -230,17 +235,11 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
|
||||
previousButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
object : SilentBackgroundTask<Void?>(activity) {
|
||||
override fun doInBackground(): Void? {
|
||||
mediaPlayerController.previous()
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Void?) {
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}.execute()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.previous()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
previousButton.setOnRepeatListener {
|
||||
|
@ -250,65 +249,43 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
|
||||
nextButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
object : SilentBackgroundTask<Boolean?>(activity) {
|
||||
override fun doInBackground(): Boolean {
|
||||
mediaPlayerController.next()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun done(result: Boolean?) {
|
||||
if (result == true) {
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
}.execute()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.next()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
nextButton.setOnRepeatListener {
|
||||
val incrementTime = Settings.incrementTime
|
||||
changeProgress(incrementTime)
|
||||
}
|
||||
|
||||
pauseButton.setOnClickListener {
|
||||
object : SilentBackgroundTask<Void?>(activity) {
|
||||
override fun doInBackground(): Void? {
|
||||
mediaPlayerController.pause()
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Void?) {
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}.execute()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.pause()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
stopButton.setOnClickListener {
|
||||
object : SilentBackgroundTask<Void?>(activity) {
|
||||
override fun doInBackground(): Void? {
|
||||
mediaPlayerController.reset()
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Void?) {
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}.execute()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.reset()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
startButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
object : SilentBackgroundTask<Void?>(activity) {
|
||||
override fun doInBackground(): Void? {
|
||||
start()
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Void?) {
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}.execute()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
start()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
shuffleButton.setOnClickListener {
|
||||
mediaPlayerController.shuffle()
|
||||
Util.toast(activity, R.string.download_menu_shuffle_notification)
|
||||
|
@ -335,16 +312,10 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
|
||||
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
object : SilentBackgroundTask<Void?>(activity) {
|
||||
override fun doInBackground(): Void? {
|
||||
mediaPlayerController.seekTo(progressBar.progress)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Void?) {
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}.execute()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.seekTo(progressBar.progress)
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {}
|
||||
|
@ -353,18 +324,13 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
|
||||
playlistView.setOnItemClickListener { _, _, position, _ ->
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
object : SilentBackgroundTask<Void?>(activity) {
|
||||
override fun doInBackground(): Void? {
|
||||
mediaPlayerController.play(position)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Void?) {
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}.execute()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.play(position)
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
registerForContextMenu(playlistView)
|
||||
|
||||
if (arguments != null && requireArguments().getBoolean(
|
||||
|
@ -419,13 +385,21 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
}
|
||||
}
|
||||
)
|
||||
Thread {
|
||||
|
||||
// Observe playlist changes and update the UI
|
||||
rxBusSubscription = RxBus.playlistObservable.subscribe {
|
||||
onPlaylistChanged()
|
||||
}
|
||||
|
||||
// Query the Jukebox state in an IO Context
|
||||
ioScope.launch(CommunicationError.getHandler(context)) {
|
||||
try {
|
||||
jukeboxAvailable = mediaPlayerController.isJukeboxAvailable
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
|
||||
}
|
||||
|
||||
|
@ -479,6 +453,8 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
rxBusSubscription?.dispose()
|
||||
cancel("CoroutineScope cancelled because the view was destroyed")
|
||||
cancellationToken.cancel()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
@ -797,9 +773,6 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
private fun update(cancel: CancellationToken?) {
|
||||
if (cancel!!.isCancellationRequested) return
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
if (currentRevision != mediaPlayerController.playListUpdateRevision) {
|
||||
onPlaylistChanged()
|
||||
}
|
||||
if (currentPlaying != mediaPlayerController.currentPlaying) {
|
||||
onCurrentChanged()
|
||||
}
|
||||
|
@ -810,33 +783,28 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
private fun savePlaylistInBackground(playlistName: String) {
|
||||
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
|
||||
mediaPlayerController.suggestedPlaylistName = playlistName
|
||||
object : SilentBackgroundTask<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)
|
||||
}
|
||||
|
||||
override fun error(error: Throwable) {
|
||||
Timber.e(error, "Exception has occurred in savePlaylistInBackground")
|
||||
} else {
|
||||
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
|
||||
val msg = String.format(
|
||||
Locale.ROOT,
|
||||
"%s %s",
|
||||
resources.getString(R.string.download_playlist_error),
|
||||
getErrorMessage(error)
|
||||
CommunicationError.getErrorMessage(it, context)
|
||||
)
|
||||
Util.toast(context, msg)
|
||||
}
|
||||
}.execute()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleFullScreenAlbumArt() {
|
||||
|
@ -914,7 +882,6 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
|
||||
emptyTextView.isVisible = list.isEmpty()
|
||||
|
||||
currentRevision = mediaPlayerController.playListUpdateRevision
|
||||
when (mediaPlayerController.repeatMode) {
|
||||
RepeatMode.OFF -> repeatButton.setImageDrawable(
|
||||
Util.getDrawableFromAttribute(
|
||||
|
@ -967,120 +934,95 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "ComplexMethod")
|
||||
@Synchronized
|
||||
private fun onSliderProgressChanged() {
|
||||
if (onProgressChangedTask != null) {
|
||||
return
|
||||
|
||||
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
||||
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
||||
val duration: Int = mediaPlayerController.playerDuration
|
||||
val playerState: PlayerState = mediaPlayerController.playerState
|
||||
|
||||
if (cancellationToken.isCancellationRequested) return
|
||||
if (currentPlaying != null) {
|
||||
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
|
||||
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
|
||||
progressBar.max =
|
||||
if (duration == 0) 100 else duration // Work-around for apparent bug.
|
||||
progressBar.progress = millisPlayed
|
||||
progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled
|
||||
} else {
|
||||
positionTextView.setText(R.string.util_zero_time)
|
||||
durationTextView.setText(R.string.util_no_time)
|
||||
progressBar.progress = 0
|
||||
progressBar.max = 0
|
||||
progressBar.isEnabled = false
|
||||
}
|
||||
onProgressChangedTask = object : SilentBackgroundTask<Void?>(activity) {
|
||||
var isJukeboxEnabled = false
|
||||
var millisPlayed = 0
|
||||
var duration: Int? = null
|
||||
var playerState: PlayerState? = null
|
||||
override fun doInBackground(): Void? {
|
||||
isJukeboxEnabled = mediaPlayerController.isJukeboxEnabled
|
||||
millisPlayed = max(0, mediaPlayerController.playerPosition)
|
||||
duration = mediaPlayerController.playerDuration
|
||||
playerState = mediaPlayerController.playerState
|
||||
return null
|
||||
|
||||
when (playerState) {
|
||||
PlayerState.DOWNLOADING -> {
|
||||
val progress =
|
||||
if (currentPlaying != null) currentPlaying!!.progress.value!! else 0
|
||||
val downloadStatus = resources.getString(
|
||||
R.string.download_playerstate_downloading,
|
||||
Util.formatPercentage(progress)
|
||||
)
|
||||
setTitle(this@PlayerFragment, downloadStatus)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override fun done(result: Void?) {
|
||||
if (cancellationToken.isCancellationRequested) return
|
||||
if (currentPlaying != null) {
|
||||
val millisTotal = if (duration == null) 0 else duration!!
|
||||
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
|
||||
durationTextView.text = Util.formatTotalDuration(millisTotal.toLong(), true)
|
||||
progressBar.max =
|
||||
if (millisTotal == 0) 100 else millisTotal // Work-around for apparent bug.
|
||||
progressBar.progress = millisPlayed
|
||||
progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled
|
||||
} else {
|
||||
positionTextView.setText(R.string.util_zero_time)
|
||||
durationTextView.setText(R.string.util_no_time)
|
||||
progressBar.progress = 0
|
||||
progressBar.max = 0
|
||||
progressBar.isEnabled = false
|
||||
}
|
||||
|
||||
when (playerState) {
|
||||
PlayerState.DOWNLOADING -> {
|
||||
val progress =
|
||||
if (currentPlaying != null) currentPlaying!!.progress.value!! else 0
|
||||
val downloadStatus = resources.getString(
|
||||
R.string.download_playerstate_downloading,
|
||||
Util.formatPercentage(progress)
|
||||
)
|
||||
setTitle(this@PlayerFragment, downloadStatus)
|
||||
}
|
||||
PlayerState.PREPARING -> setTitle(
|
||||
PlayerState.PREPARING -> setTitle(
|
||||
this@PlayerFragment,
|
||||
R.string.download_playerstate_buffering
|
||||
)
|
||||
PlayerState.STARTED -> {
|
||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
||||
setTitle(
|
||||
this@PlayerFragment,
|
||||
R.string.download_playerstate_buffering
|
||||
R.string.download_playerstate_playing_shuffle
|
||||
)
|
||||
PlayerState.STARTED -> {
|
||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
||||
setTitle(
|
||||
this@PlayerFragment,
|
||||
R.string.download_playerstate_playing_shuffle
|
||||
)
|
||||
} else {
|
||||
setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
}
|
||||
PlayerState.IDLE,
|
||||
PlayerState.PREPARED,
|
||||
PlayerState.STOPPED,
|
||||
PlayerState.PAUSED,
|
||||
PlayerState.COMPLETED -> {
|
||||
}
|
||||
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
} else {
|
||||
setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
}
|
||||
PlayerState.IDLE,
|
||||
PlayerState.PREPARED,
|
||||
PlayerState.STOPPED,
|
||||
PlayerState.PAUSED,
|
||||
PlayerState.COMPLETED -> {
|
||||
}
|
||||
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
|
||||
when (playerState) {
|
||||
PlayerState.STARTED -> {
|
||||
pauseButton.isVisible = true
|
||||
stopButton.isVisible = false
|
||||
startButton.isVisible = false
|
||||
}
|
||||
PlayerState.DOWNLOADING, PlayerState.PREPARING -> {
|
||||
pauseButton.isVisible = false
|
||||
stopButton.isVisible = true
|
||||
startButton.isVisible = false
|
||||
}
|
||||
else -> {
|
||||
pauseButton.isVisible = false
|
||||
stopButton.isVisible = false
|
||||
startButton.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: It would be a lot nicer if MediaPlayerController would send an event
|
||||
// when this is necessary instead of updating every time
|
||||
displaySongRating()
|
||||
onProgressChangedTask = null
|
||||
when (playerState) {
|
||||
PlayerState.STARTED -> {
|
||||
pauseButton.isVisible = true
|
||||
stopButton.isVisible = false
|
||||
startButton.isVisible = false
|
||||
}
|
||||
PlayerState.DOWNLOADING, PlayerState.PREPARING -> {
|
||||
pauseButton.isVisible = false
|
||||
stopButton.isVisible = true
|
||||
startButton.isVisible = false
|
||||
}
|
||||
else -> {
|
||||
pauseButton.isVisible = false
|
||||
stopButton.isVisible = false
|
||||
startButton.isVisible = true
|
||||
}
|
||||
}
|
||||
onProgressChangedTask!!.execute()
|
||||
|
||||
// TODO: It would be a lot nicer if MediaPlayerController would send an event
|
||||
// when this is necessary instead of updating every time
|
||||
displaySongRating()
|
||||
}
|
||||
|
||||
private fun changeProgress(ms: Int) {
|
||||
object : SilentBackgroundTask<Void?>(activity) {
|
||||
var msPlayed = 0
|
||||
var duration: Int? = null
|
||||
var seekTo = 0
|
||||
override fun doInBackground(): Void? {
|
||||
msPlayed = max(0, mediaPlayerController.playerPosition)
|
||||
duration = mediaPlayerController.playerDuration
|
||||
val msTotal = duration!!
|
||||
seekTo = (msPlayed + ms).coerceAtMost(msTotal)
|
||||
mediaPlayerController.seekTo(seekTo)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Void?) {
|
||||
progressBar.progress = seekTo
|
||||
}
|
||||
}.execute()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
val msPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
||||
val duration = mediaPlayerController.playerDuration
|
||||
val seekTo = (msPlayed + ms).coerceAtMost(duration)
|
||||
mediaPlayerController.seekTo(seekTo)
|
||||
progressBar.progress = seekTo
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDown(me: MotionEvent): Boolean {
|
||||
|
|
|
@ -37,6 +37,7 @@ import org.moire.ultrasonic.log.FileLoggerTree.Companion.plantToTimberForest
|
|||
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
|
||||
import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.FileUtil.defaultMusicDirectory
|
||||
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
||||
|
@ -45,7 +46,6 @@ import org.moire.ultrasonic.util.Settings
|
|||
import org.moire.ultrasonic.util.Settings.preferences
|
||||
import org.moire.ultrasonic.util.Settings.shareGreeting
|
||||
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
|
||||
import org.moire.ultrasonic.util.ThemeChangedEventDistributor
|
||||
import org.moire.ultrasonic.util.TimeSpanPreference
|
||||
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
|
@ -93,9 +93,6 @@ class SettingsFragment :
|
|||
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
|
||||
MediaPlayerController::class.java
|
||||
)
|
||||
private val themeChangedEventDistributor = inject<ThemeChangedEventDistributor>(
|
||||
ThemeChangedEventDistributor::class.java
|
||||
)
|
||||
private val mediaSessionHandler = inject<MediaSessionHandler>(
|
||||
MediaSessionHandler::class.java
|
||||
)
|
||||
|
@ -225,7 +222,7 @@ class SettingsFragment :
|
|||
showArtistPicture!!.isEnabled = sharedPreferences.getBoolean(key, false)
|
||||
}
|
||||
Constants.PREFERENCES_KEY_THEME -> {
|
||||
themeChangedEventDistributor.value.RaiseThemeChangedEvent()
|
||||
RxBus.themeChangedEventPublisher.onNext(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
|||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.getTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.service.CommunicationErrorHandler
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
|
@ -47,6 +46,7 @@ import org.moire.ultrasonic.subsonic.ShareHandler
|
|||
import org.moire.ultrasonic.subsonic.VideoPlayer
|
||||
import org.moire.ultrasonic.util.AlbumHeader
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
|
@ -211,7 +211,7 @@ class TrackCollectionFragment : Fragment() {
|
|||
|
||||
val handler = CoroutineExceptionHandler { _, exception ->
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
context?.let { CommunicationErrorHandler.handleError(exception, it) }
|
||||
CommunicationError.handleError(exception, context)
|
||||
}
|
||||
refreshAlbumListView!!.isRefreshing = false
|
||||
}
|
||||
|
|
|
@ -11,9 +11,9 @@ import android.os.Bundle
|
|||
import android.os.Handler
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
@ -25,8 +25,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider
|
|||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.SearchCriteria
|
||||
import org.moire.ultrasonic.domain.SearchResult
|
||||
import org.moire.ultrasonic.util.MediaSessionEventDistributor
|
||||
import org.moire.ultrasonic.util.MediaSessionEventListener
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
|
@ -73,8 +71,6 @@ private const val SEARCH_LIMIT = 10
|
|||
@Suppress("TooManyFunctions", "LargeClass")
|
||||
class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
|
||||
private lateinit var mediaSessionEventListener: MediaSessionEventListener
|
||||
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
|
||||
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
|
||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
|
@ -93,75 +89,24 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||
private val useId3Tags get() = Settings.shouldUseId3Tags
|
||||
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
|
||||
|
||||
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
mediaSessionEventListener = object : MediaSessionEventListener {
|
||||
override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {
|
||||
if (sessionToken == null) {
|
||||
sessionToken = token
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {
|
||||
Timber.d(
|
||||
"AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s",
|
||||
mediaId
|
||||
)
|
||||
|
||||
if (mediaId == null) return
|
||||
val mediaIdParts = mediaId.split('|')
|
||||
|
||||
when (mediaIdParts.first()) {
|
||||
MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
)
|
||||
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
)
|
||||
MEDIA_SONG_STARRED_ID -> playStarredSongs()
|
||||
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
|
||||
MEDIA_SONG_RANDOM_ID -> playRandomSongs()
|
||||
MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1])
|
||||
MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1])
|
||||
MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1])
|
||||
MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1])
|
||||
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
|
||||
mediaIdParts[1], mediaIdParts[2]
|
||||
)
|
||||
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {
|
||||
Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query)
|
||||
if (query.isNullOrBlank()) playRandomSongs()
|
||||
|
||||
serviceScope.launch {
|
||||
val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT)
|
||||
val searchResult = callWithErrorHandling { musicService.search(criteria) }
|
||||
|
||||
// Try to find the best match
|
||||
if (searchResult != null) {
|
||||
val song = searchResult.songs
|
||||
.asSequence()
|
||||
.sortedByDescending { song -> song.starred }
|
||||
.sortedByDescending { song -> song.averageRating }
|
||||
.sortedByDescending { song -> song.userRating }
|
||||
.sortedByDescending { song -> song.closeness }
|
||||
.firstOrNull()
|
||||
|
||||
if (song != null) playSong(song)
|
||||
}
|
||||
}
|
||||
}
|
||||
rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe {
|
||||
if (sessionToken == null) sessionToken = it
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.playFromMediaIdCommandObservable.subscribe {
|
||||
playFromMediaId(it.first)
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.playFromSearchCommandObservable.subscribe {
|
||||
playFromSearchCommand(it.first)
|
||||
}
|
||||
|
||||
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
|
||||
mediaSessionHandler.initialize()
|
||||
|
||||
val handler = Handler()
|
||||
|
@ -180,9 +125,66 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||
Timber.i("AutoMediaBrowserService onCreate finished")
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "ComplexMethod")
|
||||
private fun playFromMediaId(mediaId: String?) {
|
||||
Timber.d(
|
||||
"AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s",
|
||||
mediaId
|
||||
)
|
||||
|
||||
if (mediaId == null) return
|
||||
val mediaIdParts = mediaId.split('|')
|
||||
|
||||
when (mediaIdParts.first()) {
|
||||
MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
)
|
||||
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
|
||||
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||
)
|
||||
MEDIA_SONG_STARRED_ID -> playStarredSongs()
|
||||
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
|
||||
MEDIA_SONG_RANDOM_ID -> playRandomSongs()
|
||||
MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1])
|
||||
MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1])
|
||||
MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2])
|
||||
MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1])
|
||||
MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1])
|
||||
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
|
||||
mediaIdParts[1], mediaIdParts[2]
|
||||
)
|
||||
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
|
||||
}
|
||||
}
|
||||
|
||||
private fun playFromSearchCommand(query: String?) {
|
||||
Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query)
|
||||
if (query.isNullOrBlank()) playRandomSongs()
|
||||
|
||||
serviceScope.launch {
|
||||
val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT)
|
||||
val searchResult = callWithErrorHandling { musicService.search(criteria) }
|
||||
|
||||
// Try to find the best match
|
||||
if (searchResult != null) {
|
||||
val song = searchResult.songs
|
||||
.asSequence()
|
||||
.sortedByDescending { song -> song.starred }
|
||||
.sortedByDescending { song -> song.averageRating }
|
||||
.sortedByDescending { song -> song.userRating }
|
||||
.sortedByDescending { song -> song.closeness }
|
||||
.firstOrNull()
|
||||
|
||||
if (song != null) playSong(song)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
|
||||
rxBusSubscription.dispose()
|
||||
mediaSessionHandler.release()
|
||||
serviceJob.cancel()
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,13 +30,17 @@ class Downloader(
|
|||
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||
private val localMediaPlayer: LocalMediaPlayer
|
||||
) : KoinComponent {
|
||||
val playlist: MutableList<DownloadFile> = ArrayList()
|
||||
|
||||
private val playlist = mutableListOf<DownloadFile>()
|
||||
|
||||
var started: Boolean = false
|
||||
|
||||
private val downloadQueue: PriorityQueue<DownloadFile> = PriorityQueue<DownloadFile>()
|
||||
private val activelyDownloading: MutableList<DownloadFile> = ArrayList()
|
||||
private val downloadQueue = PriorityQueue<DownloadFile>()
|
||||
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()
|
||||
|
||||
|
@ -45,8 +49,11 @@ class Downloader(
|
|||
private var executorService: ScheduledExecutorService? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
|
||||
var playlistUpdateRevision: Long = 0
|
||||
private set
|
||||
private var playlistUpdateRevision: Long = 0
|
||||
private set(value) {
|
||||
field = value
|
||||
RxBus.playlistPublisher.onNext(playlist)
|
||||
}
|
||||
|
||||
val downloadChecker = Runnable {
|
||||
try {
|
||||
|
@ -61,7 +68,7 @@ class Downloader(
|
|||
stop()
|
||||
clearPlaylist()
|
||||
clearBackground()
|
||||
observableList.value = listOf()
|
||||
observableDownloads.value = listOf()
|
||||
Timber.i("Downloader destroyed")
|
||||
}
|
||||
|
||||
|
@ -179,7 +186,7 @@ class Downloader(
|
|||
}
|
||||
|
||||
private fun updateLiveData() {
|
||||
observableList.postValue(downloads)
|
||||
observableDownloads.postValue(downloads)
|
||||
}
|
||||
|
||||
private fun startDownloadOnService(task: DownloadFile) {
|
||||
|
@ -264,6 +271,10 @@ class Downloader(
|
|||
return temp.distinct().sorted()
|
||||
}
|
||||
|
||||
// Public facing playlist (immutable)
|
||||
@Synchronized
|
||||
fun getPlaylist(): List<DownloadFile> = playlist
|
||||
|
||||
@Synchronized
|
||||
fun clearPlaylist() {
|
||||
playlist.clear()
|
||||
|
@ -349,6 +360,20 @@ class Downloader(
|
|||
checkDownloads()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearIncomplete() {
|
||||
val iterator = playlist.iterator()
|
||||
var changedPlaylist = false
|
||||
while (iterator.hasNext()) {
|
||||
val downloadFile = iterator.next()
|
||||
if (!downloadFile.isCompleteFileAvailable) {
|
||||
iterator.remove()
|
||||
changedPlaylist = true
|
||||
}
|
||||
}
|
||||
if (changedPlaylist) playlistUpdateRevision++
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun downloadBackground(songs: List<MusicDirectory.Entry>, save: Boolean) {
|
||||
|
||||
|
@ -429,18 +454,21 @@ class Downloader(
|
|||
playlistUpdateRevision++
|
||||
}
|
||||
}
|
||||
|
||||
if (revisionBefore != playlistUpdateRevision) {
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
}
|
||||
|
||||
if (wasEmpty && playlist.isNotEmpty()) {
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.skip(0, 0)
|
||||
localMediaPlayer.setPlayerState(PlayerState.STARTED)
|
||||
localMediaPlayer.setPlayerState(PlayerState.STARTED, playlist[0])
|
||||
} else {
|
||||
localMediaPlayer.play(playlist[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PARALLEL_DOWNLOADS = 3
|
||||
const val CHECK_INTERVAL = 5L
|
||||
|
|
|
@ -32,12 +32,10 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
|||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.util.CancellableTask
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.StreamProxy
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Represents a Media Player which uses the mobile's resources for playback
|
||||
|
@ -47,17 +45,10 @@ class LocalMediaPlayer : KoinComponent {
|
|||
|
||||
private val audioFocusHandler by inject<AudioFocusHandler>()
|
||||
private val context by inject<Context>()
|
||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
||||
|
||||
@JvmField
|
||||
var onCurrentPlayingChanged: ((DownloadFile?) -> Unit?)? = null
|
||||
|
||||
@JvmField
|
||||
var onSongCompleted: ((DownloadFile?) -> Unit?)? = null
|
||||
|
||||
@JvmField
|
||||
var onPlayerStateChanged: ((PlayerState, DownloadFile?) -> Unit?)? = null
|
||||
|
||||
@JvmField
|
||||
var onPrepared: (() -> Any?)? = null
|
||||
|
||||
|
@ -65,6 +56,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
var onNextSongRequested: Runnable? = null
|
||||
|
||||
@JvmField
|
||||
@Volatile
|
||||
var playerState = PlayerState.IDLE
|
||||
|
||||
@JvmField
|
||||
|
@ -133,7 +125,6 @@ class LocalMediaPlayer : KoinComponent {
|
|||
// Calling reset() will result in changing this player's state. If we allow
|
||||
// the onPlayerStateChanged callback, then the state change will cause this
|
||||
// to resurrect the media session which has just been destroyed.
|
||||
onPlayerStateChanged = null
|
||||
reset()
|
||||
try {
|
||||
val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
|
@ -165,21 +156,17 @@ class LocalMediaPlayer : KoinComponent {
|
|||
}
|
||||
|
||||
@Synchronized
|
||||
fun setPlayerState(playerState: PlayerState) {
|
||||
Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, currentPlaying)
|
||||
this.playerState = playerState
|
||||
fun setPlayerState(playerState: PlayerState, track: DownloadFile?) {
|
||||
Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, track)
|
||||
synchronized(playerState) {
|
||||
this.playerState = playerState
|
||||
}
|
||||
if (playerState === PlayerState.STARTED) {
|
||||
audioFocusHandler.requestAudioFocus()
|
||||
}
|
||||
|
||||
if (onPlayerStateChanged != null) {
|
||||
val mainHandler = Handler(context.mainLooper)
|
||||
RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, track))
|
||||
|
||||
val myRunnable = Runnable {
|
||||
onPlayerStateChanged?.invoke(playerState, currentPlaying)
|
||||
}
|
||||
mainHandler.post(myRunnable)
|
||||
}
|
||||
if (playerState === PlayerState.STARTED && positionCache == null) {
|
||||
positionCache = PositionCache()
|
||||
val thread = Thread(positionCache)
|
||||
|
@ -195,14 +182,10 @@ class LocalMediaPlayer : KoinComponent {
|
|||
*/
|
||||
@Synchronized
|
||||
fun setCurrentPlaying(currentPlaying: DownloadFile?) {
|
||||
Timber.v("setCurrentPlaying %s", currentPlaying)
|
||||
// In some cases this function is called twice
|
||||
if (this.currentPlaying == currentPlaying) return
|
||||
this.currentPlaying = currentPlaying
|
||||
|
||||
if (onCurrentPlayingChanged != null) {
|
||||
val mainHandler = Handler(context.mainLooper)
|
||||
val myRunnable = Runnable { onCurrentPlayingChanged!!(currentPlaying) }
|
||||
mainHandler.post(myRunnable)
|
||||
}
|
||||
RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, currentPlaying))
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -263,7 +246,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
mediaPlayer = nextMediaPlayer!!
|
||||
|
||||
setCurrentPlaying(nextPlaying)
|
||||
setPlayerState(PlayerState.STARTED)
|
||||
setPlayerState(PlayerState.STARTED, currentPlaying)
|
||||
|
||||
attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false)
|
||||
|
||||
|
@ -344,7 +327,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
|
||||
@Synchronized
|
||||
private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) {
|
||||
if (playerState !== PlayerState.PREPARED) {
|
||||
if (playerState !== PlayerState.PREPARED && !fileToPlay.isWorkDone) {
|
||||
reset()
|
||||
bufferTask = BufferTask(fileToPlay, position, autoStart)
|
||||
bufferTask!!.start()
|
||||
|
@ -355,6 +338,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
|
||||
@Synchronized
|
||||
private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) {
|
||||
setPlayerState(PlayerState.IDLE, downloadFile)
|
||||
|
||||
// In many cases we will be resetting the mediaPlayer a second time here.
|
||||
// figure out if we can remove this call...
|
||||
|
@ -370,7 +354,6 @@ class LocalMediaPlayer : KoinComponent {
|
|||
// downloadFile.updateModificationDate()
|
||||
mediaPlayer.setOnCompletionListener(null)
|
||||
|
||||
setPlayerState(PlayerState.IDLE)
|
||||
setAudioAttributes(mediaPlayer)
|
||||
|
||||
var dataSource: String? = null
|
||||
|
@ -403,7 +386,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
descriptor.close()
|
||||
}
|
||||
|
||||
setPlayerState(PlayerState.PREPARING)
|
||||
setPlayerState(PlayerState.PREPARING, downloadFile)
|
||||
|
||||
mediaPlayer.setOnBufferingUpdateListener { mp, percent ->
|
||||
val song = downloadFile.song
|
||||
|
@ -421,7 +404,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
|
||||
mediaPlayer.setOnPreparedListener {
|
||||
Timber.i("Media player prepared")
|
||||
setPlayerState(PlayerState.PREPARED)
|
||||
setPlayerState(PlayerState.PREPARED, downloadFile)
|
||||
|
||||
// Populate seek bar secondary progress if we have a complete file for consistency
|
||||
if (downloadFile.isWorkDone) {
|
||||
|
@ -436,9 +419,9 @@ class LocalMediaPlayer : KoinComponent {
|
|||
cachedPosition = position
|
||||
if (start) {
|
||||
mediaPlayer.start()
|
||||
setPlayerState(PlayerState.STARTED)
|
||||
setPlayerState(PlayerState.STARTED, downloadFile)
|
||||
} else {
|
||||
setPlayerState(PlayerState.PAUSED)
|
||||
setPlayerState(PlayerState.PAUSED, downloadFile)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -446,6 +429,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
onPrepared
|
||||
}
|
||||
}
|
||||
|
||||
attachHandlersToPlayer(mediaPlayer, downloadFile, partial)
|
||||
mediaPlayer.prepareAsync()
|
||||
} catch (x: Exception) {
|
||||
|
@ -541,7 +525,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
Timber.i("Ending position %d of %d", pos, duration)
|
||||
|
||||
if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) {
|
||||
setPlayerState(PlayerState.COMPLETED)
|
||||
setPlayerState(PlayerState.COMPLETED, downloadFile)
|
||||
if (Settings.gaplessPlayback &&
|
||||
nextPlaying != null &&
|
||||
nextPlayerState === PlayerState.PREPARED
|
||||
|
@ -588,7 +572,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
resetMediaPlayer()
|
||||
|
||||
try {
|
||||
setPlayerState(PlayerState.IDLE)
|
||||
setPlayerState(PlayerState.IDLE, currentPlaying)
|
||||
mediaPlayer.setOnErrorListener(null)
|
||||
mediaPlayer.setOnCompletionListener(null)
|
||||
} catch (x: Exception) {
|
||||
|
@ -617,7 +601,7 @@ class LocalMediaPlayer : KoinComponent {
|
|||
private val partialFile: String = downloadFile.partialFile
|
||||
|
||||
override fun execute() {
|
||||
setPlayerState(PlayerState.DOWNLOADING)
|
||||
setPlayerState(PlayerState.DOWNLOADING, downloadFile)
|
||||
while (!bufferComplete() && !isOffline()) {
|
||||
Util.sleepQuietly(1000L)
|
||||
if (isCancelled) {
|
||||
|
@ -720,10 +704,12 @@ class LocalMediaPlayer : KoinComponent {
|
|||
while (isRunning) {
|
||||
try {
|
||||
if (playerState === PlayerState.STARTED) {
|
||||
cachedPosition = mediaPlayer.currentPosition
|
||||
mediaSessionHandler.updateMediaSessionPlaybackPosition(
|
||||
cachedPosition.toLong()
|
||||
)
|
||||
synchronized(playerState) {
|
||||
if (playerState === PlayerState.STARTED) {
|
||||
cachedPosition = mediaPlayer.currentPosition
|
||||
}
|
||||
}
|
||||
RxBus.playbackPositionPublisher.onNext(cachedPosition)
|
||||
}
|
||||
Util.sleepQuietly(100L)
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -180,7 +180,7 @@ class MediaPlayerController(
|
|||
downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist)
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
if (shuffle) shuffle()
|
||||
val isLastTrack = (downloader.playlist.size - 1 == downloader.currentPlayingIndex)
|
||||
val isLastTrack = (downloader.getPlaylist().size - 1 == downloader.currentPlayingIndex)
|
||||
|
||||
if (!playNext && !autoPlay && isLastTrack) {
|
||||
val mediaPlayerService = runningInstance
|
||||
|
@ -190,15 +190,15 @@ class MediaPlayerController(
|
|||
if (autoPlay) {
|
||||
play(0)
|
||||
} else {
|
||||
if (localMediaPlayer.currentPlaying == null && downloader.playlist.size > 0) {
|
||||
localMediaPlayer.currentPlaying = downloader.playlist[0]
|
||||
downloader.playlist[0].setPlaying(true)
|
||||
if (localMediaPlayer.currentPlaying == null && downloader.getPlaylist().isNotEmpty()) {
|
||||
localMediaPlayer.currentPlaying = downloader.getPlaylist()[0]
|
||||
downloader.getPlaylist()[0].setPlaying(true)
|
||||
}
|
||||
downloader.checkDownloads()
|
||||
}
|
||||
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
|
@ -210,7 +210,7 @@ class MediaPlayerController(
|
|||
val filteredSongs = songs.filterNotNull()
|
||||
downloader.downloadBackground(filteredSongs, save)
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
|
@ -241,7 +241,7 @@ class MediaPlayerController(
|
|||
fun shuffle() {
|
||||
downloader.shuffle()
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
|
@ -270,7 +270,7 @@ class MediaPlayerController(
|
|||
downloader.clearPlaylist()
|
||||
if (serialize) {
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex, playerPosition
|
||||
)
|
||||
}
|
||||
|
@ -281,16 +281,11 @@ class MediaPlayerController(
|
|||
@Synchronized
|
||||
fun clearIncomplete() {
|
||||
reset()
|
||||
val iterator = downloader.playlist.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val downloadFile = iterator.next()
|
||||
if (!downloadFile.isCompleteFileAvailable) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
downloader.clearIncomplete()
|
||||
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
|
@ -307,7 +302,7 @@ class MediaPlayerController(
|
|||
downloader.removeFromPlaylist(downloadFile)
|
||||
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
|
@ -359,12 +354,12 @@ class MediaPlayerController(
|
|||
when (repeatMode) {
|
||||
RepeatMode.SINGLE, RepeatMode.OFF -> {
|
||||
// Play next if exists
|
||||
if (index + 1 >= 0 && index + 1 < downloader.playlist.size) {
|
||||
if (index + 1 >= 0 && index + 1 < downloader.getPlaylist().size) {
|
||||
play(index + 1)
|
||||
}
|
||||
}
|
||||
RepeatMode.ALL -> {
|
||||
play((index + 1) % downloader.playlist.size)
|
||||
play((index + 1) % downloader.getPlaylist().size)
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
|
@ -397,7 +392,8 @@ class MediaPlayerController(
|
|||
get() = localMediaPlayer.playerState
|
||||
set(state) {
|
||||
val mediaPlayerService = runningInstance
|
||||
if (mediaPlayerService != null) localMediaPlayer.setPlayerState(state)
|
||||
if (mediaPlayerService != null)
|
||||
localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying)
|
||||
}
|
||||
|
||||
@set:Synchronized
|
||||
|
@ -417,6 +413,10 @@ class MediaPlayerController(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function calls the music service directly and
|
||||
* therefore can't be called from the main thread
|
||||
*/
|
||||
val isJukeboxAvailable: Boolean
|
||||
get() {
|
||||
try {
|
||||
|
@ -479,6 +479,7 @@ class MediaPlayerController(
|
|||
Timber.e(e)
|
||||
}
|
||||
}.start()
|
||||
// TODO this would be better handled with a Rx command
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
|
@ -490,16 +491,13 @@ class MediaPlayerController(
|
|||
}
|
||||
|
||||
val playlistSize: Int
|
||||
get() = downloader.playlist.size
|
||||
get() = downloader.getPlaylist().size
|
||||
|
||||
val currentPlayingNumberOnPlaylist: Int
|
||||
get() = downloader.currentPlayingIndex
|
||||
|
||||
val playList: List<DownloadFile>
|
||||
get() = downloader.playlist
|
||||
|
||||
val playListUpdateRevision: Long
|
||||
get() = downloader.playlistUpdateRevision
|
||||
get() = downloader.getPlaylist()
|
||||
|
||||
val playListDuration: Long
|
||||
get() = downloader.downloadListDuration
|
||||
|
|
|
@ -13,6 +13,7 @@ import android.content.Intent
|
|||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import android.view.KeyEvent
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
|
@ -20,9 +21,8 @@ import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
|||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.MediaSessionEventDistributor
|
||||
import org.moire.ultrasonic.util.MediaSessionEventListener
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
|
@ -34,11 +34,10 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val downloader by inject<Downloader>()
|
||||
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
|
||||
|
||||
private var created = false
|
||||
private var headsetEventReceiver: BroadcastReceiver? = null
|
||||
private lateinit var mediaSessionEventListener: MediaSessionEventListener
|
||||
private var mediaButtonEventSubscription: Disposable? = null
|
||||
|
||||
fun onCreate() {
|
||||
onCreate(false, null)
|
||||
|
@ -51,13 +50,10 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||
return
|
||||
}
|
||||
|
||||
mediaSessionEventListener = object : MediaSessionEventListener {
|
||||
override fun onMediaButtonEvent(keyEvent: KeyEvent?) {
|
||||
if (keyEvent != null) handleKeyEvent(keyEvent)
|
||||
}
|
||||
mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe {
|
||||
handleKeyEvent(it)
|
||||
}
|
||||
|
||||
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
|
||||
registerHeadsetReceiver()
|
||||
mediaPlayerController.onCreate()
|
||||
if (autoPlay) mediaPlayerController.preload()
|
||||
|
@ -75,7 +71,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||
// Work-around: Serialize again, as the restore() method creates a
|
||||
// serialization without current playing info.
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
mediaPlayerController.playerPosition
|
||||
)
|
||||
|
@ -92,14 +88,13 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||
if (!created) return
|
||||
|
||||
playbackStateSerializer.serializeNow(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
mediaPlayerController.playerPosition
|
||||
)
|
||||
|
||||
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
|
||||
|
||||
mediaPlayerController.clear(false)
|
||||
mediaButtonEventSubscription?.dispose()
|
||||
applicationContext().unregisterReceiver(headsetEventReceiver)
|
||||
mediaPlayerController.onDestroy()
|
||||
|
||||
|
@ -119,7 +114,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||
if (intentAction == Constants.CMD_PROCESS_KEYCODE) {
|
||||
if (intent.extras != null) {
|
||||
val event = intent.extras!![Intent.EXTRA_KEY_EVENT] as KeyEvent?
|
||||
event?.let { handleKeyEvent(it) }
|
||||
event.ifNotNull { handleKeyEvent(it) }
|
||||
}
|
||||
} else {
|
||||
handleUltrasonicIntent(intentAction)
|
||||
|
|
|
@ -22,6 +22,7 @@ import android.support.v4.media.session.MediaSessionCompat
|
|||
import android.view.KeyEvent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlin.collections.ArrayList
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.moire.ultrasonic.R
|
||||
|
@ -37,10 +38,7 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
|
|||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.MediaSessionEventDistributor
|
||||
import org.moire.ultrasonic.util.MediaSessionEventListener
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.NowPlayingEventDistributor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
||||
import org.moire.ultrasonic.util.SimpleServiceBinder
|
||||
|
@ -64,18 +62,16 @@ class MediaPlayerService : Service() {
|
|||
private val shufflePlayBuffer by inject<ShufflePlayBuffer>()
|
||||
private val downloader by inject<Downloader>()
|
||||
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 var mediaSession: MediaSessionCompat? = null
|
||||
private var mediaSessionToken: MediaSessionCompat.Token? = null
|
||||
private var isInForeground = false
|
||||
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||
private lateinit var mediaSessionEventListener: MediaSessionEventListener
|
||||
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
private val repeatMode: RepeatMode
|
||||
get() = Settings.repeatMode
|
||||
private var currentPlayerState: PlayerState? = null
|
||||
private var currentTrack: DownloadFile? = null
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
|
@ -87,13 +83,11 @@ class MediaPlayerService : Service() {
|
|||
shufflePlayBuffer.onCreate()
|
||||
localMediaPlayer.init()
|
||||
|
||||
setupOnCurrentPlayingChangedHandler()
|
||||
setupOnPlayerStateChangedHandler()
|
||||
setupOnSongCompletedHandler()
|
||||
|
||||
localMediaPlayer.onPrepared = {
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
|
@ -102,25 +96,28 @@ class MediaPlayerService : Service() {
|
|||
|
||||
localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() }
|
||||
|
||||
mediaSessionEventListener = object : MediaSessionEventListener {
|
||||
override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {
|
||||
mediaSessionToken = token
|
||||
}
|
||||
|
||||
override fun onSkipToQueueItemRequested(id: Long) {
|
||||
play(id.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
|
||||
mediaSessionHandler.initialize()
|
||||
|
||||
// Create Notification Channel
|
||||
createNotificationChannel()
|
||||
|
||||
// Update notification early. It is better to show an empty one temporarily
|
||||
// than waiting too long and letting Android kill the app
|
||||
updateNotification(PlayerState.IDLE, null)
|
||||
|
||||
// Subscribing should be after updateNotification to avoid concurrency
|
||||
rxBusSubscription += RxBus.playerStateObservable.subscribe {
|
||||
playerStateChangedHandler(it.state, it.track)
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe {
|
||||
mediaSessionToken = it
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.skipToQueueItemCommandObservable.subscribe {
|
||||
play(it.toInt())
|
||||
}
|
||||
|
||||
mediaSessionHandler.initialize()
|
||||
|
||||
instance = this
|
||||
Timber.i("MediaPlayerService created")
|
||||
}
|
||||
|
@ -134,8 +131,8 @@ class MediaPlayerService : Service() {
|
|||
super.onDestroy()
|
||||
instance = null
|
||||
try {
|
||||
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
|
||||
mediaSessionHandler.release()
|
||||
rxBusSubscription.dispose()
|
||||
|
||||
localMediaPlayer.release()
|
||||
downloader.stop()
|
||||
|
@ -201,16 +198,14 @@ class MediaPlayerService : Service() {
|
|||
@Synchronized
|
||||
fun setCurrentPlaying(currentPlayingIndex: Int) {
|
||||
try {
|
||||
localMediaPlayer.setCurrentPlaying(downloader.playlist[currentPlayingIndex])
|
||||
localMediaPlayer.setCurrentPlaying(downloader.getPlaylist()[currentPlayingIndex])
|
||||
} catch (ignored: IndexOutOfBoundsException) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setNextPlaying() {
|
||||
val gaplessPlayback = Settings.gaplessPlayback
|
||||
|
||||
if (!gaplessPlayback) {
|
||||
if (!Settings.gaplessPlayback) {
|
||||
localMediaPlayer.clearNextPlaying(true)
|
||||
return
|
||||
}
|
||||
|
@ -218,9 +213,9 @@ class MediaPlayerService : Service() {
|
|||
var index = downloader.currentPlayingIndex
|
||||
|
||||
if (index != -1) {
|
||||
when (repeatMode) {
|
||||
when (Settings.repeatMode) {
|
||||
RepeatMode.OFF -> index += 1
|
||||
RepeatMode.ALL -> index = (index + 1) % downloader.playlist.size
|
||||
RepeatMode.ALL -> index = (index + 1) % downloader.getPlaylist().size
|
||||
RepeatMode.SINGLE -> {
|
||||
}
|
||||
else -> {
|
||||
|
@ -229,8 +224,8 @@ class MediaPlayerService : Service() {
|
|||
}
|
||||
|
||||
localMediaPlayer.clearNextPlaying(false)
|
||||
if (index < downloader.playlist.size && index != -1) {
|
||||
localMediaPlayer.setNextPlaying(downloader.playlist[index])
|
||||
if (index < downloader.getPlaylist().size && index != -1) {
|
||||
localMediaPlayer.setNextPlaying(downloader.getPlaylist()[index])
|
||||
} else {
|
||||
localMediaPlayer.clearNextPlaying(true)
|
||||
}
|
||||
|
@ -283,16 +278,15 @@ class MediaPlayerService : Service() {
|
|||
@Synchronized
|
||||
fun play(index: Int, start: Boolean) {
|
||||
Timber.v("play requested for %d", index)
|
||||
if (index < 0 || index >= downloader.playlist.size) {
|
||||
if (index < 0 || index >= downloader.getPlaylist().size) {
|
||||
resetPlayback()
|
||||
} else {
|
||||
setCurrentPlaying(index)
|
||||
if (start) {
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.skip(index, 0)
|
||||
localMediaPlayer.setPlayerState(PlayerState.STARTED)
|
||||
} else {
|
||||
localMediaPlayer.play(downloader.playlist[index])
|
||||
localMediaPlayer.play(downloader.getPlaylist()[index])
|
||||
}
|
||||
}
|
||||
downloader.checkDownloads()
|
||||
|
@ -305,7 +299,7 @@ class MediaPlayerService : Service() {
|
|||
localMediaPlayer.reset()
|
||||
localMediaPlayer.setCurrentPlaying(null)
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex, playerPosition
|
||||
)
|
||||
}
|
||||
|
@ -318,7 +312,7 @@ class MediaPlayerService : Service() {
|
|||
} else {
|
||||
localMediaPlayer.pause()
|
||||
}
|
||||
localMediaPlayer.setPlayerState(PlayerState.PAUSED)
|
||||
localMediaPlayer.setPlayerState(PlayerState.PAUSED, localMediaPlayer.currentPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -331,7 +325,7 @@ class MediaPlayerService : Service() {
|
|||
localMediaPlayer.pause()
|
||||
}
|
||||
}
|
||||
localMediaPlayer.setPlayerState(PlayerState.STOPPED)
|
||||
localMediaPlayer.setPlayerState(PlayerState.STOPPED, null)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
@ -341,7 +335,7 @@ class MediaPlayerService : Service() {
|
|||
} else {
|
||||
localMediaPlayer.start()
|
||||
}
|
||||
localMediaPlayer.setPlayerState(PlayerState.STARTED)
|
||||
localMediaPlayer.setPlayerState(PlayerState.STARTED, localMediaPlayer.currentPlaying)
|
||||
}
|
||||
|
||||
private fun updateWidget(playerState: PlayerState, song: MusicDirectory.Entry?) {
|
||||
|
@ -354,100 +348,73 @@ class MediaPlayerService : Service() {
|
|||
UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false)
|
||||
}
|
||||
|
||||
private fun setupOnCurrentPlayingChangedHandler() {
|
||||
localMediaPlayer.onCurrentPlayingChanged = { currentPlaying: DownloadFile? ->
|
||||
private fun playerStateChangedHandler(
|
||||
playerState: PlayerState,
|
||||
currentPlaying: DownloadFile?
|
||||
) {
|
||||
val context = this@MediaPlayerService
|
||||
// AVRCP handles these separately so we must differentiate between the cases
|
||||
val isStateChanged = playerState != currentPlayerState
|
||||
val isTrackChanged = currentPlaying != currentTrack
|
||||
if (!isStateChanged && !isTrackChanged) return
|
||||
|
||||
if (currentPlaying != null) {
|
||||
Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying.song)
|
||||
Util.broadcastA2dpMetaDataChange(
|
||||
this@MediaPlayerService, playerPosition, currentPlaying,
|
||||
downloader.all.size, downloader.currentPlayingIndex + 1
|
||||
)
|
||||
} else {
|
||||
Util.broadcastNewTrackInfo(this@MediaPlayerService, null)
|
||||
Util.broadcastA2dpMetaDataChange(
|
||||
this@MediaPlayerService, playerPosition, null,
|
||||
downloader.all.size, downloader.currentPlayingIndex + 1
|
||||
)
|
||||
val showWhenPaused = playerState !== PlayerState.STOPPED &&
|
||||
Settings.isNotificationAlwaysEnabled
|
||||
|
||||
val show = playerState === PlayerState.STARTED || showWhenPaused
|
||||
val song = currentPlaying?.song
|
||||
|
||||
if (isStateChanged) {
|
||||
when {
|
||||
playerState === PlayerState.PAUSED -> {
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition
|
||||
)
|
||||
}
|
||||
playerState === PlayerState.STARTED -> {
|
||||
scrobbler.scrobble(currentPlaying, false)
|
||||
}
|
||||
playerState === PlayerState.COMPLETED -> {
|
||||
scrobbler.scrobble(currentPlaying, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Update widget
|
||||
val playerState = localMediaPlayer.playerState
|
||||
val song = currentPlaying?.song
|
||||
|
||||
updateWidget(playerState, song)
|
||||
|
||||
if (currentPlaying != null) {
|
||||
updateNotification(localMediaPlayer.playerState, currentPlaying)
|
||||
nowPlayingEventDistributor.raiseShowNowPlayingEvent()
|
||||
} else {
|
||||
nowPlayingEventDistributor.raiseHideNowPlayingEvent()
|
||||
stopForeground(true)
|
||||
isInForeground = false
|
||||
stopIfIdle()
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupOnPlayerStateChangedHandler() {
|
||||
localMediaPlayer.onPlayerStateChanged = {
|
||||
playerState: PlayerState,
|
||||
currentPlaying: DownloadFile?
|
||||
->
|
||||
|
||||
val context = this@MediaPlayerService
|
||||
|
||||
// Notify MediaSession
|
||||
mediaSessionHandler.updateMediaSession(
|
||||
currentPlaying,
|
||||
downloader.currentPlayingIndex.toLong(),
|
||||
playerState
|
||||
)
|
||||
|
||||
if (playerState === PlayerState.PAUSED) {
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist, downloader.currentPlayingIndex, playerPosition
|
||||
)
|
||||
}
|
||||
|
||||
val showWhenPaused = playerState !== PlayerState.STOPPED &&
|
||||
Settings.isNotificationAlwaysEnabled
|
||||
|
||||
val show = playerState === PlayerState.STARTED || showWhenPaused
|
||||
val song = currentPlaying?.song
|
||||
|
||||
Util.broadcastPlaybackStatusChange(context, playerState)
|
||||
Util.broadcastA2dpPlayStatusChange(
|
||||
context, playerState, song,
|
||||
downloader.playlist.size,
|
||||
downloader.playlist.indexOf(currentPlaying) + 1, playerPosition
|
||||
downloader.getPlaylist().size,
|
||||
downloader.getPlaylist().indexOf(currentPlaying) + 1, playerPosition
|
||||
)
|
||||
} else {
|
||||
// State didn't change, only the track
|
||||
Util.broadcastA2dpMetaDataChange(
|
||||
this@MediaPlayerService, playerPosition, currentPlaying,
|
||||
downloader.all.size, downloader.currentPlayingIndex + 1
|
||||
)
|
||||
|
||||
// Update widget
|
||||
updateWidget(playerState, song)
|
||||
|
||||
if (show) {
|
||||
// Only update notification if player state is one that will change the icon
|
||||
if (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) {
|
||||
updateNotification(playerState, currentPlaying)
|
||||
nowPlayingEventDistributor.raiseShowNowPlayingEvent()
|
||||
}
|
||||
} else {
|
||||
nowPlayingEventDistributor.raiseHideNowPlayingEvent()
|
||||
stopForeground(true)
|
||||
isInForeground = false
|
||||
stopIfIdle()
|
||||
}
|
||||
|
||||
if (playerState === PlayerState.STARTED) {
|
||||
scrobbler.scrobble(currentPlaying, false)
|
||||
} else if (playerState === PlayerState.COMPLETED) {
|
||||
scrobbler.scrobble(currentPlaying, true)
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
if (isTrackChanged) {
|
||||
Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying?.song)
|
||||
}
|
||||
|
||||
// Update widget
|
||||
updateWidget(playerState, song)
|
||||
|
||||
if (show) {
|
||||
// Only update notification if player state is one that will change the icon
|
||||
if (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) {
|
||||
updateNotification(playerState, currentPlaying)
|
||||
}
|
||||
} else {
|
||||
stopForeground(true)
|
||||
isInForeground = false
|
||||
stopIfIdle()
|
||||
}
|
||||
|
||||
currentPlayerState = playerState
|
||||
currentTrack = currentPlaying
|
||||
|
||||
Timber.d("Processed player state change")
|
||||
}
|
||||
|
||||
private fun setupOnSongCompletedHandler() {
|
||||
|
@ -465,9 +432,9 @@ class MediaPlayerService : Service() {
|
|||
}
|
||||
}
|
||||
if (index != -1) {
|
||||
when (repeatMode) {
|
||||
when (Settings.repeatMode) {
|
||||
RepeatMode.OFF -> {
|
||||
if (index + 1 < 0 || index + 1 >= downloader.playlist.size) {
|
||||
if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) {
|
||||
if (Settings.shouldClearPlaylist) {
|
||||
clear(true)
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
|
@ -478,7 +445,7 @@ class MediaPlayerService : Service() {
|
|||
}
|
||||
}
|
||||
RepeatMode.ALL -> {
|
||||
play((index + 1) % downloader.playlist.size)
|
||||
play((index + 1) % downloader.getPlaylist().size)
|
||||
}
|
||||
RepeatMode.SINGLE -> play(index)
|
||||
else -> {
|
||||
|
@ -497,7 +464,7 @@ class MediaPlayerService : Service() {
|
|||
setNextPlaying()
|
||||
if (serialize) {
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.playlist,
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex, playerPosition
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,7 +19,6 @@ import org.koin.core.component.KoinComponent
|
|||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
|
@ -30,9 +29,8 @@ import timber.log.Timber
|
|||
class PlaybackStateSerializer : KoinComponent {
|
||||
|
||||
private val context by inject<Context>()
|
||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
||||
|
||||
val lock: Lock = ReentrantLock()
|
||||
private val lock: Lock = ReentrantLock()
|
||||
private val setup = AtomicBoolean(false)
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
@ -76,9 +74,6 @@ class PlaybackStateSerializer : KoinComponent {
|
|||
)
|
||||
|
||||
FileUtil.serialize(context, state, Constants.FILENAME_PLAYLIST_SER)
|
||||
|
||||
// This is called here because the queue is usually serialized after a change
|
||||
mediaSessionHandler.updateMediaSessionQueue(state.songs)
|
||||
}
|
||||
|
||||
fun deserialize(afterDeserialized: (State?) -> Unit?) {
|
||||
|
@ -106,7 +101,6 @@ class PlaybackStateSerializer : KoinComponent {
|
|||
state.currentPlayingPosition
|
||||
)
|
||||
|
||||
mediaSessionHandler.updateMediaSessionQueue(state.songs)
|
||||
afterDeserialized(state)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -26,6 +26,7 @@ import org.moire.ultrasonic.util.Settings
|
|||
import org.moire.ultrasonic.util.ShareDetails
|
||||
import org.moire.ultrasonic.util.TimeSpan
|
||||
import org.moire.ultrasonic.util.TimeSpanPicker
|
||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
||||
|
||||
/**
|
||||
* This class handles sharing items in the media library
|
||||
|
@ -79,7 +80,7 @@ class ShareHandler(val context: Context) {
|
|||
|
||||
if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1) return null
|
||||
if (shareDetails.Entries.isEmpty()) {
|
||||
fragment.arguments?.getString(Constants.INTENT_EXTRA_NAME_ID)?.let {
|
||||
fragment.arguments?.getString(Constants.INTENT_EXTRA_NAME_ID).ifNotNull {
|
||||
ids.add(it)
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.os.AsyncTask
|
||||
import android.os.StatFs
|
||||
import android.system.Os
|
||||
import java.util.ArrayList
|
||||
import java.util.HashSet
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Playlist
|
||||
|
@ -22,105 +25,85 @@ import timber.log.Timber
|
|||
/**
|
||||
* Responsible for cleaning up files from the offline download cache on the filesystem.
|
||||
*/
|
||||
class CacheCleaner {
|
||||
class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
|
||||
private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
|
||||
return CoroutineExceptionHandler { _, exception ->
|
||||
Timber.w(exception, "Exception in CacheCleaner.$tag")
|
||||
}
|
||||
}
|
||||
|
||||
fun clean() {
|
||||
try {
|
||||
BackgroundCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)
|
||||
} catch (all: Exception) {
|
||||
// If an exception is thrown, assume we execute correctly the next time
|
||||
Timber.w(all, "Exception in CacheCleaner.clean")
|
||||
launch(exceptionHandler("clean")) {
|
||||
backgroundCleanup()
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanSpace() {
|
||||
try {
|
||||
BackgroundSpaceCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)
|
||||
} catch (all: Exception) {
|
||||
// If an exception is thrown, assume we execute correctly the next time
|
||||
Timber.w(all, "Exception in CacheCleaner.cleanSpace")
|
||||
launch(exceptionHandler("cleanSpace")) {
|
||||
backgroundSpaceCleanup()
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanPlaylists(playlists: List<Playlist>) {
|
||||
try {
|
||||
BackgroundPlaylistsCleanup().executeOnExecutor(
|
||||
AsyncTask.THREAD_POOL_EXECUTOR,
|
||||
playlists
|
||||
)
|
||||
} catch (all: Exception) {
|
||||
// If an exception is thrown, assume we execute correctly the next time
|
||||
Timber.w(all, "Exception in CacheCleaner.cleanPlaylists")
|
||||
launch(exceptionHandler("cleanPlaylists")) {
|
||||
backgroundPlaylistsCleanup(playlists)
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundCleanup : AsyncTask<Void?, Void?, Void?>() {
|
||||
override fun doInBackground(vararg params: Void?): Void? {
|
||||
try {
|
||||
Thread.currentThread().name = "BackgroundCleanup"
|
||||
val files: MutableList<StorageFile> = ArrayList()
|
||||
val dirs: MutableList<StorageFile> = ArrayList()
|
||||
private fun backgroundCleanup() {
|
||||
try {
|
||||
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)
|
||||
val filesToNotDelete = findFilesToNotDelete()
|
||||
|
||||
deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true)
|
||||
deleteEmptyDirs(dirs, filesToNotDelete)
|
||||
} catch (all: RuntimeException) {
|
||||
Timber.e(all, "Error in cache cleaning.")
|
||||
deleteFiles(files, filesToNotDelete, bytesToDelete, false)
|
||||
}
|
||||
return null
|
||||
} catch (all: RuntimeException) {
|
||||
Timber.e(all, "Error in cache cleaning.")
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundSpaceCleanup : AsyncTask<Void?, Void?, Void?>() {
|
||||
override fun doInBackground(vararg params: Void?): Void? {
|
||||
try {
|
||||
Thread.currentThread().name = "BackgroundSpaceCleanup"
|
||||
private fun backgroundPlaylistsCleanup(vararg params: List<Playlist>) {
|
||||
try {
|
||||
val activeServerProvider = inject<ActiveServerProvider>(
|
||||
ActiveServerProvider::class.java
|
||||
)
|
||||
|
||||
val files: MutableList<StorageFile> = ArrayList()
|
||||
val dirs: MutableList<StorageFile> = ArrayList()
|
||||
val server = activeServerProvider.value.getActiveServer().name
|
||||
val playlistFiles = listFiles(getPlaylistDirectory(server))
|
||||
val playlists = params[0]
|
||||
|
||||
findCandidatesForDeletion(musicDirectory, files, dirs)
|
||||
|
||||
val bytesToDelete = getMinimumDelete(files)
|
||||
|
||||
if (bytesToDelete > 0L) {
|
||||
sortByAscendingModificationTime(files)
|
||||
val filesToNotDelete = findFilesToNotDelete()
|
||||
deleteFiles(files, filesToNotDelete, bytesToDelete, false)
|
||||
}
|
||||
} catch (all: RuntimeException) {
|
||||
Timber.e(all, "Error in cache cleaning.")
|
||||
for ((_, name) in playlists) {
|
||||
playlistFiles.remove(getPlaylistFile(server, name))
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundPlaylistsCleanup : AsyncTask<List<Playlist>, Void?, Void?>() {
|
||||
override fun doInBackground(vararg params: List<Playlist>): Void? {
|
||||
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.")
|
||||
for (playlist in playlistFiles) {
|
||||
playlist.delete()
|
||||
}
|
||||
return null
|
||||
} catch (all: RuntimeException) {
|
||||
Timber.e(all, "Error in playlist cache cleaning.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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?) {}
|
||||
}
|
|
@ -17,18 +17,21 @@ import android.support.v4.media.session.MediaSessionCompat
|
|||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN
|
||||
import android.view.KeyEvent
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlin.Pair
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.imageloader.BitmapUtils
|
||||
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
|
||||
import org.moire.ultrasonic.service.DownloadFile
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
||||
import timber.log.Timber
|
||||
|
||||
private const val INTENT_CODE_MEDIA_BUTTON = 161
|
||||
private const val CALL_DIVIDE = 10
|
||||
/**
|
||||
* Central place to handle the state of the MediaSession
|
||||
*/
|
||||
|
@ -39,21 +42,22 @@ class MediaSessionHandler : KoinComponent {
|
|||
private var playbackActions: Long? = null
|
||||
private var cachedPlayingIndex: Long? = null
|
||||
|
||||
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
|
||||
private val applicationContext by inject<Context>()
|
||||
|
||||
private var referenceCount: Int = 0
|
||||
private var cachedPlaylist: List<MediaSessionCompat.QueueItem>? = null
|
||||
private var playbackPositionDelayCount: Int = 0
|
||||
private var cachedPlaylist: List<DownloadFile>? = null
|
||||
private var cachedPosition: Long = 0
|
||||
|
||||
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
fun release() {
|
||||
|
||||
if (referenceCount > 0) referenceCount--
|
||||
if (referenceCount > 0) return
|
||||
|
||||
mediaSession?.isActive = false
|
||||
mediaSessionEventDistributor.releaseCachedMediaSessionToken()
|
||||
RxBus.releaseMediaSessionToken()
|
||||
rxBusSubscription.dispose()
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
|
||||
|
@ -72,7 +76,7 @@ class MediaSessionHandler : KoinComponent {
|
|||
|
||||
mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService")
|
||||
val mediaSessionToken = mediaSession?.sessionToken ?: return
|
||||
mediaSessionEventDistributor.raiseMediaSessionTokenCreatedEvent(mediaSessionToken)
|
||||
RxBus.mediaSessionTokenPublisher.onNext(mediaSessionToken)
|
||||
|
||||
updateMediaButtonReceiver()
|
||||
|
||||
|
@ -93,14 +97,14 @@ class MediaSessionHandler : KoinComponent {
|
|||
super.onPlayFromMediaId(mediaId, extras)
|
||||
|
||||
Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId)
|
||||
mediaSessionEventDistributor.raisePlayFromMediaIdRequestedEvent(mediaId, extras)
|
||||
RxBus.playFromMediaIdCommandPublisher.onNext(Pair(mediaId, extras))
|
||||
}
|
||||
|
||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||
super.onPlayFromSearch(query, extras)
|
||||
|
||||
Timber.d("Media Session Callback: onPlayFromSearch %s", query)
|
||||
mediaSessionEventDistributor.raisePlayFromSearchRequestedEvent(query, extras)
|
||||
RxBus.playFromSearchCommandPublisher.onNext(Pair(query, extras))
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
@ -147,28 +151,36 @@ class MediaSessionHandler : KoinComponent {
|
|||
// This probably won't be necessary once we implement more
|
||||
// of the modern media APIs, like the MediaController etc.
|
||||
val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent?
|
||||
mediaSessionEventDistributor.raiseMediaButtonEvent(event)
|
||||
event.ifNotNull { RxBus.mediaButtonEventPublisher.onNext(it) }
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSkipToQueueItem(id: Long) {
|
||||
super.onSkipToQueueItem(id)
|
||||
mediaSessionEventDistributor.raiseSkipToQueueItemRequestedEvent(id)
|
||||
RxBus.skipToQueueItemCommandPublisher.onNext(id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// It seems to be the best practice to set this to true for the lifetime of the session
|
||||
mediaSession?.isActive = true
|
||||
if (cachedPlaylist != null) setMediaSessionQueue(cachedPlaylist)
|
||||
rxBusSubscription += RxBus.playbackPositionObservable.subscribe {
|
||||
updateMediaSessionPlaybackPosition(it)
|
||||
}
|
||||
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
||||
updateMediaSessionQueue(it)
|
||||
}
|
||||
rxBusSubscription += RxBus.playerStateObservable.subscribe {
|
||||
updateMediaSession(it.state, it.track)
|
||||
}
|
||||
|
||||
Timber.i("MediaSessionHandler.initialize Media Session created")
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "LongMethod")
|
||||
fun updateMediaSession(
|
||||
currentPlaying: DownloadFile?,
|
||||
currentPlayingIndex: Long?,
|
||||
playerState: PlayerState
|
||||
@Suppress("LongMethod", "ComplexMethod")
|
||||
private fun updateMediaSession(
|
||||
playerState: PlayerState,
|
||||
currentPlaying: DownloadFile?
|
||||
) {
|
||||
Timber.d("Updating the MediaSession")
|
||||
|
||||
|
@ -187,8 +199,8 @@ class MediaSessionHandler : KoinComponent {
|
|||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title)
|
||||
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error setting the metadata")
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all, "Error setting the metadata")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -244,59 +256,46 @@ class MediaSessionHandler : KoinComponent {
|
|||
// Set actions
|
||||
playbackStateBuilder.setActions(playbackActions!!)
|
||||
|
||||
cachedPlayingIndex = currentPlayingIndex
|
||||
setMediaSessionQueue(cachedPlaylist)
|
||||
if (
|
||||
currentPlayingIndex != null && cachedPlaylist != null &&
|
||||
!Settings.shouldDisableNowPlayingListSending
|
||||
)
|
||||
playbackStateBuilder.setActiveQueueItemId(currentPlayingIndex)
|
||||
val index = cachedPlaylist?.indexOf(currentPlaying)
|
||||
cachedPlayingIndex = if (index == null || index < 0) null else index.toLong()
|
||||
cachedPlaylist.ifNotNull { setMediaSessionQueue(it) }
|
||||
|
||||
if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending)
|
||||
cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) }
|
||||
|
||||
// Save the playback state
|
||||
mediaSession?.setPlaybackState(playbackStateBuilder.build())
|
||||
}
|
||||
|
||||
fun updateMediaSessionQueue(playlist: Iterable<MusicDirectory.Entry>) {
|
||||
// This call is cached because Downloader may initialize earlier than the MediaSession
|
||||
cachedPlaylist = playlist.mapIndexed { id, song ->
|
||||
MediaSessionCompat.QueueItem(
|
||||
Util.getMediaDescriptionForEntry(song),
|
||||
id.toLong()
|
||||
)
|
||||
}
|
||||
setMediaSessionQueue(cachedPlaylist)
|
||||
private fun updateMediaSessionQueue(playlist: List<DownloadFile>) {
|
||||
cachedPlaylist = playlist
|
||||
setMediaSessionQueue(playlist)
|
||||
}
|
||||
|
||||
private fun setMediaSessionQueue(queue: List<MediaSessionCompat.QueueItem>?) {
|
||||
private fun setMediaSessionQueue(playlist: List<DownloadFile>) {
|
||||
if (mediaSession == null) return
|
||||
if (Settings.shouldDisableNowPlayingListSending) return
|
||||
|
||||
val queue = playlist.mapIndexed { id, file ->
|
||||
MediaSessionCompat.QueueItem(
|
||||
Util.getMediaDescriptionForEntry(file.song),
|
||||
id.toLong()
|
||||
)
|
||||
}
|
||||
mediaSession?.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing))
|
||||
mediaSession?.setQueue(queue)
|
||||
}
|
||||
|
||||
fun updateMediaSessionPlaybackPosition(playbackPosition: Long) {
|
||||
|
||||
cachedPosition = playbackPosition
|
||||
if (mediaSession == null) return
|
||||
|
||||
private fun updateMediaSessionPlaybackPosition(playbackPosition: Int) {
|
||||
cachedPosition = playbackPosition.toLong()
|
||||
if (playbackState == null || playbackActions == null) return
|
||||
|
||||
// Playback position is updated too frequently in the player.
|
||||
// This counter makes sure that the MediaSession is updated ~ at every second
|
||||
playbackPositionDelayCount++
|
||||
if (playbackPositionDelayCount < CALL_DIVIDE) return
|
||||
|
||||
playbackPositionDelayCount = 0
|
||||
val playbackStateBuilder = PlaybackStateCompat.Builder()
|
||||
playbackStateBuilder.setState(playbackState!!, playbackPosition, 1.0f)
|
||||
playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f)
|
||||
playbackStateBuilder.setActions(playbackActions!!)
|
||||
|
||||
if (
|
||||
cachedPlayingIndex != null && cachedPlaylist != null &&
|
||||
!Settings.shouldDisableNowPlayingListSending
|
||||
)
|
||||
playbackStateBuilder.setActiveQueueItemId(cachedPlayingIndex!!)
|
||||
if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending)
|
||||
cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) }
|
||||
|
||||
mediaSession?.setPlaybackState(playbackStateBuilder.build())
|
||||
}
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
package org.moire.ultrasonic.util
|
||||
|
||||
/**
|
||||
* Callback interface for Now Playing event subscribers
|
||||
*/
|
||||
interface NowPlayingEventListener {
|
||||
fun onDismissNowPlaying()
|
||||
fun onHideNowPlaying()
|
||||
fun onShowNowPlaying()
|
||||
}
|
|
@ -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) {}
|
||||
}
|
|
@ -37,6 +37,10 @@ class StorageFile private constructor(
|
|||
return getPath().compareTo(other.getPath())
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
|
||||
var name: String = fileManager.getName(abstractFile)
|
||||
|
||||
var isDirectory: Boolean = fileManager.isDirectory(abstractFile)
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package org.moire.ultrasonic.util
|
||||
|
||||
/**
|
||||
* Callback interface for Theme change event subscribers
|
||||
*/
|
||||
interface ThemeChangedEventListener {
|
||||
fun onThemeChanged()
|
||||
}
|
|
@ -10,7 +10,7 @@ import java.io.File
|
|||
/**
|
||||
* Logs the stack trace of uncaught exceptions to a file on the SD card.
|
||||
*/
|
||||
class SubsonicUncaughtExceptionHandler(
|
||||
class UncaughtExceptionHandler(
|
||||
private val context: Context
|
||||
) : Thread.UncaughtExceptionHandler {
|
||||
private val defaultHandler: Thread.UncaughtExceptionHandler? =
|
||||
|
@ -32,8 +32,8 @@ class SubsonicUncaughtExceptionHandler(
|
|||
throwable.printStackTrace(printWriter)
|
||||
Timber.e(throwable, "Uncaught Exception! %s", logMessage)
|
||||
Timber.i("Stack trace written to %s", file)
|
||||
} catch (x: Throwable) {
|
||||
Timber.e(x, "Failed to write stack trace to %s", file)
|
||||
} catch (all: Throwable) {
|
||||
Timber.e(all, "Failed to write stack trace to %s", file)
|
||||
} finally {
|
||||
printWriter.safeClose()
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
|
@ -43,8 +43,6 @@ import android.widget.Toast
|
|||
import androidx.annotation.AnyRes
|
||||
import androidx.media.utils.MediaConstants
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.io.File
|
||||
import java.io.UnsupportedEncodingException
|
||||
import java.security.MessageDigest
|
||||
import java.text.DecimalFormat
|
||||
|
@ -482,7 +480,6 @@ object Util {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Broadcasts the given song info as the new song being played.
|
||||
*/
|
||||
fun broadcastNewTrackInfo(context: Context, song: MusicDirectory.Entry?) {
|
||||
|
@ -902,6 +899,14 @@ object Util {
|
|||
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given block if this is not null.
|
||||
* @return: the return of the block, or null if this is null
|
||||
*/
|
||||
fun <T : Any, R> T?.ifNotNull(block: (T) -> R): R? {
|
||||
return this?.let(block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Small data class to store information about the current network
|
||||
**/
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
|
||||
a:layout_width="fill_parent"
|
||||
a:layout_height="fill_parent"
|
||||
a:orientation="horizontal">
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
a:layout_width="fill_parent"
|
||||
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:layout_width="0dp"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1">
|
||||
a:layout_weight="1"
|
||||
tools:ignore="UselessParent">
|
||||
|
||||
<FrameLayout
|
||||
a:id="@+id/current_playing_album_art_layout"
|
||||
a:layout_width="fill_parent"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1"
|
||||
a:gravity="left"
|
||||
a:gravity="start"
|
||||
a:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
|
@ -31,9 +33,9 @@
|
|||
a:layout_width="fill_parent"
|
||||
a:layout_height="fill_parent"
|
||||
a:gravity="bottom"
|
||||
a:orientation="vertical" >
|
||||
a:orientation="vertical">
|
||||
|
||||
<include layout="@layout/player_media_info"/>
|
||||
<include layout="@layout/player_media_info" />
|
||||
|
||||
<LinearLayout
|
||||
a:id="@+id/song_rating"
|
||||
|
@ -48,10 +50,11 @@
|
|||
a:layout_width="0dip"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1"
|
||||
a:padding="10dip"
|
||||
a:background="@android:color/transparent"
|
||||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:importantForAccessibility="no"
|
||||
a:padding="10dip"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
|
||||
|
@ -60,10 +63,11 @@
|
|||
a:layout_width="0dip"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1"
|
||||
a:padding="10dip"
|
||||
a:background="@android:color/transparent"
|
||||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:importantForAccessibility="no"
|
||||
a:padding="10dip"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
|
||||
|
@ -72,10 +76,11 @@
|
|||
a:layout_width="0dip"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1"
|
||||
a:padding="10dip"
|
||||
a:background="@android:color/transparent"
|
||||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:importantForAccessibility="no"
|
||||
a:padding="10dip"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
|
||||
|
@ -84,10 +89,11 @@
|
|||
a:layout_width="0dip"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1"
|
||||
a:padding="10dip"
|
||||
a:background="@android:color/transparent"
|
||||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:importantForAccessibility="no"
|
||||
a:padding="10dip"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
|
||||
|
@ -96,10 +102,11 @@
|
|||
a:layout_width="0dip"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1"
|
||||
a:padding="10dip"
|
||||
a:background="@android:color/transparent"
|
||||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:importantForAccessibility="no"
|
||||
a:padding="10dip"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
|
||||
|
@ -113,15 +120,16 @@
|
|||
a:layout_marginStart="60dip"
|
||||
a:layout_marginEnd="60dip"
|
||||
a:background="@color/translucent"
|
||||
a:orientation="vertical"/>
|
||||
a:orientation="vertical" />
|
||||
|
||||
<include layout="@layout/player_slider"/>
|
||||
<include layout="@layout/media_buttons"/>
|
||||
<include layout="@layout/player_slider" />
|
||||
|
||||
<include layout="@layout/media_buttons" />
|
||||
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<include layout="@layout/current_playlist"/>
|
||||
</org.moire.ultrasonic.util.MyViewFlipper>
|
||||
<include layout="@layout/current_playlist" />
|
||||
</ViewFlipper>
|
||||
|
||||
</LinearLayout>
|
|
@ -4,7 +4,7 @@
|
|||
a:layout_height="fill_parent"
|
||||
a:orientation="vertical" >
|
||||
|
||||
<org.moire.ultrasonic.util.MyViewFlipper
|
||||
<ViewFlipper
|
||||
a:id="@+id/current_playing_playlist_flipper"
|
||||
a:layout_width="fill_parent"
|
||||
a:layout_height="0dip"
|
||||
|
@ -14,7 +14,6 @@
|
|||
a:id="@+id/current_playing_album_art_layout"
|
||||
a:layout_width="fill_parent"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1"
|
||||
a:gravity="start"
|
||||
a:orientation="vertical" >
|
||||
|
||||
|
@ -52,7 +51,8 @@
|
|||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
a:src="?attr/star_hollow"
|
||||
a:importantForAccessibility="no" />
|
||||
|
||||
<ImageView
|
||||
a:id="@+id/song_five_star_2"
|
||||
|
@ -64,7 +64,8 @@
|
|||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
a:src="?attr/star_hollow"
|
||||
a:importantForAccessibility="no" />
|
||||
|
||||
<ImageView
|
||||
a:id="@+id/song_five_star_3"
|
||||
|
@ -76,7 +77,8 @@
|
|||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
a:src="?attr/star_hollow"
|
||||
a:importantForAccessibility="no" />
|
||||
|
||||
<ImageView
|
||||
a:id="@+id/song_five_star_4"
|
||||
|
@ -88,7 +90,8 @@
|
|||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
a:src="?attr/star_hollow"
|
||||
a:importantForAccessibility="no" />
|
||||
|
||||
<ImageView
|
||||
a:id="@+id/song_five_star_5"
|
||||
|
@ -100,7 +103,8 @@
|
|||
a:focusable="false"
|
||||
a:gravity="center_vertical"
|
||||
a:scaleType="fitCenter"
|
||||
a:src="?attr/star_hollow" />
|
||||
a:src="?attr/star_hollow"
|
||||
a:importantForAccessibility="no" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
@ -119,7 +123,7 @@
|
|||
</RelativeLayout>
|
||||
|
||||
<include layout="@layout/current_playlist" />
|
||||
</org.moire.ultrasonic.util.MyViewFlipper>
|
||||
</ViewFlipper>
|
||||
|
||||
<include layout="@layout/player_media_info" />
|
||||
|
||||
|
|
|
@ -5,8 +5,7 @@
|
|||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
a:orientation="vertical"
|
||||
a:layout_width="fill_parent"
|
||||
a:layout_height="fill_parent"
|
||||
a:layout_weight="1">
|
||||
a:layout_height="fill_parent">
|
||||
|
||||
<TextView
|
||||
a:id="@+id/playlist_empty"
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
a:id="@+id/button_shuffle"
|
||||
a:layout_width="0dip"
|
||||
a:layout_height="26dp"
|
||||
a:layout_alignParentLeft="true"
|
||||
a:layout_gravity="center"
|
||||
a:layout_weight="1"
|
||||
a:adjustViewBounds="true"
|
||||
|
|
|
@ -18,7 +18,9 @@
|
|||
a:id="@+id/current_playing_song"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content"
|
||||
a:ellipsize="start"
|
||||
a:layout_marginEnd="10dip"
|
||||
a:paddingRight="30dip"
|
||||
a:ellipsize="marquee"
|
||||
a:gravity="left"
|
||||
a:singleLine="true"
|
||||
a:textAppearance="?android:attr/textAppearanceLarge"
|
||||
|
@ -29,7 +31,7 @@
|
|||
a:id="@+id/current_playing_artist"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content"
|
||||
a:ellipsize="start"
|
||||
a:ellipsize="marquee"
|
||||
a:gravity="left"
|
||||
a:singleLine="true"
|
||||
a:textAppearance="?android:attr/textAppearanceSmall"
|
||||
|
@ -62,7 +64,8 @@
|
|||
a:ellipsize="start"
|
||||
a:gravity="right"
|
||||
a:text="0 / 0"
|
||||
a:textAppearance="?android:attr/textAppearanceSmall" />
|
||||
a:textAppearance="?android:attr/textAppearanceSmall"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
<TextView
|
||||
a:id="@+id/current_total_duration"
|
||||
|
|
Loading…
Reference in New Issue