Merged develop into api30

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

View File

@ -42,7 +42,9 @@ ext.versions = [
timber : "4.7.1",
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 = [

View File

@ -3,11 +3,12 @@
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<CurrentIssues>
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.&lt;no name provided&gt;$!append &amp;&amp; !playNext &amp;&amp; !unpin &amp;&amp; !background</ID>
<ID>ComplexCondition:FilePickerAdapter.kt$FilePickerAdapter$currentDirectory.absolutePath == "/" || currentDirectory.absolutePath == "/storage" || currentDirectory.absolutePath == "/storage/emulated" || currentDirectory.absolutePath == "/mnt"</ID>
<ID>ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>ComplexMethod: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>

View File

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

View File

@ -1,194 +0,0 @@
package org.moire.ultrasonic.fragment;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.navigation.Navigation;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.domain.PlayerState;
import org.moire.ultrasonic.service.DownloadFile;
import org.moire.ultrasonic.service.MediaPlayerController;
import org.moire.ultrasonic.subsonic.ImageLoaderProvider;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.NowPlayingEventDistributor;
import org.moire.ultrasonic.util.NowPlayingEventListener;
import org.moire.ultrasonic.util.Settings;
import org.moire.ultrasonic.util.Util;
import kotlin.Lazy;
import timber.log.Timber;
import static org.koin.java.KoinJavaComponent.inject;
/**
* Contains the mini-now playing information box displayed at the bottom of the screen
*/
public class NowPlayingFragment extends Fragment {
private static final int MIN_DISTANCE = 30;
private float downX;
private float downY;
ImageView playButton;
ImageView nowPlayingAlbumArtImage;
TextView nowPlayingTrack;
TextView nowPlayingArtist;
private final Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
private final Lazy<ImageLoaderProvider> imageLoader = inject(ImageLoaderProvider.class);
private final Lazy<NowPlayingEventDistributor> nowPlayingEventDistributor = inject(NowPlayingEventDistributor.class);
private NowPlayingEventListener nowPlayingEventListener;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
Util.applyTheme(this.getContext());
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(R.layout.now_playing, container, false);
}
@Override
public void onViewCreated(@NonNull final View view, @Nullable Bundle savedInstanceState) {
playButton = view.findViewById(R.id.now_playing_control_play);
nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image);
nowPlayingTrack = view.findViewById(R.id.now_playing_trackname);
nowPlayingArtist = view.findViewById(R.id.now_playing_artist);
nowPlayingEventListener = new NowPlayingEventListener() {
@Override
public void onDismissNowPlaying() { }
@Override
public void onHideNowPlaying() { }
@Override
public void onShowNowPlaying() { update(); }
};
nowPlayingEventDistributor.getValue().subscribe(nowPlayingEventListener);
}
@Override
public void onResume() {
super.onResume();
update();
}
@Override
public void onDestroy() {
super.onDestroy();
nowPlayingEventDistributor.getValue().unsubscribe(nowPlayingEventListener);
}
private void update() {
try
{
PlayerState playerState = mediaPlayerControllerLazy.getValue().getPlayerState();
if (playerState == PlayerState.PAUSED) {
playButton.setImageDrawable(Util.getDrawableFromAttribute(getContext(), R.attr.media_play));
} else if (playerState == PlayerState.STARTED) {
playButton.setImageDrawable(Util.getDrawableFromAttribute(getContext(), R.attr.media_pause));
}
DownloadFile file = mediaPlayerControllerLazy.getValue().getCurrentPlaying();
if (file != null) {
final MusicDirectory.Entry song = file.getSong();
String title = song.getTitle();
String artist = song.getArtist();
imageLoader.getValue().getImageLoader().loadImage(nowPlayingAlbumArtImage, song, false, Util.getNotificationImageSize(getContext()));
nowPlayingTrack.setText(title);
nowPlayingArtist.setText(artist);
nowPlayingAlbumArtImage.setOnClickListener(v -> {
Bundle bundle = new Bundle();
if (Settings.getShouldUseId3Tags()) {
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, true);
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.getAlbumId());
} else {
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, false);
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.getParent());
}
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.getAlbum());
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.getAlbum());
Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(R.id.trackCollectionFragment, bundle);
});
}
getView().setOnTouchListener((v, event) -> handleOnTouch(event));
// This empty onClickListener is necessary for the onTouchListener to work
getView().setOnClickListener(v -> {});
playButton.setOnClickListener(v -> mediaPlayerControllerLazy.getValue().togglePlayPause());
}
catch (Exception x) {
Timber.w(x, "Failed to get notification cover art");
}
}
private boolean handleOnTouch(MotionEvent event) {
switch (event.getAction())
{
case MotionEvent.ACTION_DOWN:
{
downX = event.getX();
downY = event.getY();
return false;
}
case MotionEvent.ACTION_UP:
{
float upX = event.getX();
float upY = event.getY();
float deltaX = downX - upX;
float deltaY = downY - upY;
if (Math.abs(deltaX) > MIN_DISTANCE)
{
// left or right
if (deltaX < 0)
{
mediaPlayerControllerLazy.getValue().previous();
return false;
}
if (deltaX > 0)
{
mediaPlayerControllerLazy.getValue().next();
return false;
}
}
else if (Math.abs(deltaY) > MIN_DISTANCE)
{
if (deltaY < 0)
{
nowPlayingEventDistributor.getValue().raiseNowPlayingDismissedEvent();
return false;
}
if (deltaY > 0)
{
return false;
}
}
Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(R.id.playerFragment);
return false;
}
}
return false;
}
}

View File

@ -20,7 +20,6 @@ package org.moire.ultrasonic.util;
import android.app.Activity;
import android.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

View File

@ -1,58 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ViewFlipper;
/**
* Work-around for Android Issue 6191 (http://code.google.com/p/android/issues/detail?id=6191)
*
* @author Sindre Mehus
* @version $Id$
*/
public class MyViewFlipper extends ViewFlipper
{
public MyViewFlipper(Context context)
{
super(context);
}
public MyViewFlipper(Context context, AttributeSet attrs)
{
super(context, attrs);
}
@Override
protected void onDetachedFromWindow()
{
try
{
super.onDetachedFromWindow();
}
catch (IllegalArgumentException e)
{
// Call stopFlipping() in order to kick off updateRunning()
stopFlipping();
}
}
}

View File

@ -31,6 +31,7 @@ import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
import 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))
}
}

View File

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

View File

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

View File

@ -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(),

View File

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

View File

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

View File

@ -0,0 +1,188 @@
/*
* NowPlayingFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import io.reactivex.rxjava3.disposables.Disposable
import java.lang.Exception
import kotlin.math.abs
import org.koin.android.ext.android.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util.applyTheme
import org.moire.ultrasonic.util.Util.getDrawableFromAttribute
import org.moire.ultrasonic.util.Util.getNotificationImageSize
import timber.log.Timber
/**
* Contains the mini-now playing information box displayed at the bottom of the screen
*/
class NowPlayingFragment : Fragment() {
private var downX = 0f
private var downY = 0f
private var playButton: ImageView? = null
private var nowPlayingAlbumArtImage: ImageView? = null
private var nowPlayingTrack: TextView? = null
private var nowPlayingArtist: TextView? = null
private var playerStateSubscription: Disposable? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val imageLoader: ImageLoaderProvider by inject()
override fun onCreate(savedInstanceState: Bundle?) {
applyTheme(this.context)
super.onCreate(savedInstanceState)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.now_playing, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
playButton = view.findViewById(R.id.now_playing_control_play)
nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image)
nowPlayingTrack = view.findViewById(R.id.now_playing_trackname)
nowPlayingArtist = view.findViewById(R.id.now_playing_artist)
playerStateSubscription =
RxBus.playerStateObservable.subscribe { update() }
}
override fun onResume() {
super.onResume()
update()
}
override fun onDestroy() {
super.onDestroy()
playerStateSubscription!!.dispose()
}
@SuppressLint("ClickableViewAccessibility")
private fun update() {
try {
val playerState = mediaPlayerController.playerState
if (playerState === PlayerState.PAUSED) {
playButton!!.setImageDrawable(
getDrawableFromAttribute(
context, R.attr.media_play
)
)
} else if (playerState === PlayerState.STARTED) {
playButton!!.setImageDrawable(
getDrawableFromAttribute(
context, R.attr.media_pause
)
)
}
val file = mediaPlayerController.currentPlaying
if (file != null) {
val song = file.song
val title = song.title
val artist = song.artist
imageLoader.getImageLoader().loadImage(
nowPlayingAlbumArtImage,
song,
false,
getNotificationImageSize(requireContext())
)
nowPlayingTrack!!.text = title
nowPlayingArtist!!.text = artist
nowPlayingAlbumArtImage!!.setOnClickListener {
val bundle = Bundle()
if (Settings.shouldUseId3Tags) {
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, true)
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.albumId)
} else {
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, false)
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.parent)
}
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.album)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.album)
Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
.navigate(R.id.trackCollectionFragment, bundle)
}
}
requireView().setOnTouchListener { _: View?, event: MotionEvent ->
handleOnTouch(event)
}
// This empty onClickListener is necessary for the onTouchListener to work
requireView().setOnClickListener { }
playButton!!.setOnClickListener { mediaPlayerController.togglePlayPause() }
} catch (all: Exception) {
Timber.w(all, "Failed to get notification cover art")
}
}
private fun handleOnTouch(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
}
MotionEvent.ACTION_UP -> {
val upX = event.x
val upY = event.y
val deltaX = downX - upX
val deltaY = downY - upY
if (abs(deltaX) > MIN_DISTANCE) {
// left or right
if (deltaX < 0) {
mediaPlayerController.previous()
}
if (deltaX > 0) {
mediaPlayerController.next()
}
} else if (abs(deltaY) > MIN_DISTANCE) {
if (deltaY < 0) {
RxBus.dismissNowPlayingCommandPublisher.onNext(Unit)
}
} else {
Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
.navigate(R.id.playerFragment)
}
}
}
return false
}
companion object {
private const val MIN_DISTANCE = 30
}
}

View File

@ -38,17 +38,22 @@ import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import 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 {

View File

@ -37,6 +37,7 @@ import org.moire.ultrasonic.log.FileLoggerTree.Companion.plantToTimberForest
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
import org.moire.ultrasonic.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)
}
}
}

View File

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

View File

@ -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()

View File

@ -1,86 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2020 (C) Jozsef Varga
*/
package org.moire.ultrasonic.service
import android.app.AlertDialog
import android.content.Context
import com.fasterxml.jackson.core.JsonParseException
import java.io.FileNotFoundException
import java.io.IOException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage
import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* Contains helper functions to handle the exceptions
* thrown during the communication with a Subsonic server
*/
class CommunicationErrorHandler {
companion object {
fun handleError(error: Throwable?, context: Context) {
Timber.w(error)
AlertDialog.Builder(context)
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(R.string.error_label)
.setMessage(getErrorMessage(error!!, context))
.setCancelable(true)
.setPositiveButton(R.string.common_ok) { _, _ -> }
.create().show()
}
fun getErrorMessage(error: Throwable, context: Context): String {
if (error is IOException && !Util.isNetworkConnected()) {
return context.resources.getString(R.string.background_task_no_network)
} else if (error is FileNotFoundException) {
return context.resources.getString(R.string.background_task_not_found)
} else if (error is JsonParseException) {
return context.resources.getString(R.string.background_task_parse_error)
} else if (error is SSLException) {
return if (
error.cause is CertificateException &&
error.cause?.cause is CertPathValidatorException
) {
context.resources
.getString(
R.string.background_task_ssl_cert_error, error.cause?.cause?.message
)
} else {
context.resources.getString(R.string.background_task_ssl_error)
}
} else if (error is ApiNotSupportedException) {
return context.resources.getString(
R.string.background_task_unsupported_api, error.serverApiVersion
)
} else if (error is IOException) {
return context.resources.getString(R.string.background_task_network_error)
} else if (error is SubsonicRESTException) {
return error.getLocalizedErrorMessage(context)
}
val message = error.message
return message ?: error.javaClass.simpleName
}
}
}

View File

@ -30,13 +30,17 @@ class Downloader(
private val externalStorageMonitor: ExternalStorageMonitor,
private val 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,86 @@
package org.moire.ultrasonic.service
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import java.util.concurrent.TimeUnit
import org.moire.ultrasonic.domain.PlayerState
class RxBus {
companion object {
var mediaSessionTokenPublisher: PublishSubject<MediaSessionCompat.Token> =
PublishSubject.create()
val mediaSessionTokenObservable: Observable<MediaSessionCompat.Token> =
mediaSessionTokenPublisher.observeOn(AndroidSchedulers.mainThread())
.replay(1)
.autoConnect(0)
val mediaButtonEventPublisher: PublishSubject<KeyEvent> =
PublishSubject.create()
val mediaButtonEventObservable: Observable<KeyEvent> =
mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread())
val themeChangedEventPublisher: PublishSubject<Unit> =
PublishSubject.create()
val themeChangedEventObservable: Observable<Unit> =
themeChangedEventPublisher.observeOn(AndroidSchedulers.mainThread())
val playerStatePublisher: PublishSubject<StateWithTrack> =
PublishSubject.create()
val playerStateObservable: Observable<StateWithTrack> =
playerStatePublisher.observeOn(AndroidSchedulers.mainThread())
.replay(1)
.autoConnect(0)
val playlistPublisher: PublishSubject<List<DownloadFile>> =
PublishSubject.create()
val playlistObservable: Observable<List<DownloadFile>> =
playlistPublisher.observeOn(AndroidSchedulers.mainThread())
.replay(1)
.autoConnect(0)
val playbackPositionPublisher: PublishSubject<Int> =
PublishSubject.create()
val playbackPositionObservable: Observable<Int> =
playbackPositionPublisher.observeOn(AndroidSchedulers.mainThread())
.throttleFirst(1, TimeUnit.SECONDS)
.replay(1)
.autoConnect(0)
// Commands
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
PublishSubject.create()
val dismissNowPlayingCommandObservable: Observable<Unit> =
dismissNowPlayingCommandPublisher.observeOn(AndroidSchedulers.mainThread())
val playFromMediaIdCommandPublisher: PublishSubject<Pair<String?, Bundle?>> =
PublishSubject.create()
val playFromMediaIdCommandObservable: Observable<Pair<String?, Bundle?>> =
playFromMediaIdCommandPublisher.observeOn(AndroidSchedulers.mainThread())
val playFromSearchCommandPublisher: PublishSubject<Pair<String?, Bundle?>> =
PublishSubject.create()
val playFromSearchCommandObservable: Observable<Pair<String?, Bundle?>> =
playFromSearchCommandPublisher.observeOn(AndroidSchedulers.mainThread())
val skipToQueueItemCommandPublisher: PublishSubject<Long> =
PublishSubject.create()
val skipToQueueItemCommandObservable: Observable<Long> =
skipToQueueItemCommandPublisher.observeOn(AndroidSchedulers.mainThread())
fun releaseMediaSessionToken() {
mediaSessionTokenPublisher = PublishSubject.create()
}
}
data class StateWithTrack(val state: PlayerState, val track: DownloadFile?)
}
operator fun CompositeDisposable.plusAssign(disposable: Disposable) {
this.add(disposable)
}

View File

@ -26,6 +26,7 @@ import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.ShareDetails
import org.moire.ultrasonic.util.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 {

View File

@ -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.")
}
}

View File

@ -0,0 +1,91 @@
/*
* CommunicationErrorUtil.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.app.AlertDialog
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.fasterxml.jackson.core.JsonParseException
import java.io.FileNotFoundException
import java.io.IOException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineExceptionHandler
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage
import timber.log.Timber
/**
* Contains helper functions to handle the exceptions
* thrown during the communication with a Subsonic server
*/
object CommunicationError {
fun getHandler(context: Context?, handler: ((CoroutineContext, Throwable) -> Unit)? = null):
CoroutineExceptionHandler {
return CoroutineExceptionHandler { coroutineContext, exception ->
Handler(Looper.getMainLooper()).post {
handleError(exception, context)
handler?.invoke(coroutineContext, exception)
}
}
}
@JvmStatic
fun handleError(error: Throwable?, context: Context?) {
Timber.w(error)
if (context == null) return
AlertDialog.Builder(context)
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(R.string.error_label)
.setMessage(getErrorMessage(error!!, context))
.setCancelable(true)
.setPositiveButton(R.string.common_ok) { _, _ -> }
.create().show()
}
@JvmStatic
@Suppress("ReturnCount")
fun getErrorMessage(error: Throwable, context: Context?): String {
if (context == null) return "Couldn't get Error message, Context is null"
if (error is IOException && !Util.isNetworkConnected()) {
return context.resources.getString(R.string.background_task_no_network)
} else if (error is FileNotFoundException) {
return context.resources.getString(R.string.background_task_not_found)
} else if (error is JsonParseException) {
return context.resources.getString(R.string.background_task_parse_error)
} else if (error is SSLException) {
return if (
error.cause is CertificateException &&
error.cause?.cause is CertPathValidatorException
) {
context.resources
.getString(
R.string.background_task_ssl_cert_error, error.cause?.cause?.message
)
} else {
context.resources.getString(R.string.background_task_ssl_error)
}
} else if (error is ApiNotSupportedException) {
return context.resources.getString(
R.string.background_task_unsupported_api, error.serverApiVersion
)
} else if (error is IOException) {
return context.resources.getString(R.string.background_task_network_error)
} else if (error is SubsonicRESTException) {
return error.getLocalizedErrorMessage(context)
}
val message = error.message
return message ?: error.javaClass.simpleName
}
}

View File

@ -1,68 +0,0 @@
/*
* MediaSessionEventDistributor.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
/**
* This class distributes MediaSession related events to its subscribers.
* It is a primitive implementation of a pub-sub event bus
*/
class MediaSessionEventDistributor {
var eventListenerList: MutableList<MediaSessionEventListener> =
listOf<MediaSessionEventListener>().toMutableList()
var cachedToken: MediaSessionCompat.Token? = null
fun subscribe(listener: MediaSessionEventListener) {
eventListenerList.add(listener)
synchronized(this) {
if (cachedToken != null)
listener.onMediaSessionTokenCreated(cachedToken!!)
}
}
fun unsubscribe(listener: MediaSessionEventListener) {
eventListenerList.remove(listener)
}
fun releaseCachedMediaSessionToken() {
synchronized(this) {
cachedToken = null
}
}
fun raiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) {
synchronized(this) {
cachedToken = token
eventListenerList.forEach { listener -> listener.onMediaSessionTokenCreated(token) }
}
}
fun raisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) {
eventListenerList.forEach {
listener ->
listener.onPlayFromMediaIdRequested(mediaId, extras)
}
}
fun raisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) {
eventListenerList.forEach { listener -> listener.onPlayFromSearchRequested(query, extras) }
}
fun raiseSkipToQueueItemRequestedEvent(id: Long) {
eventListenerList.forEach { listener -> listener.onSkipToQueueItemRequested(id) }
}
fun raiseMediaButtonEvent(keyEvent: KeyEvent?) {
eventListenerList.forEach { listener -> listener.onMediaButtonEvent(keyEvent) }
}
}

View File

@ -1,23 +0,0 @@
/*
* MediaSessionEventListener.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
/**
* Callback interface for MediaSession related event subscribers
*/
interface MediaSessionEventListener {
fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {}
fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {}
fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {}
fun onSkipToQueueItemRequested(id: Long) {}
fun onMediaButtonEvent(keyEvent: KeyEvent?) {}
}

View File

@ -17,18 +17,21 @@ import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.support.v4.media.session.PlaybackStateCompat.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())
}

View File

@ -1,30 +0,0 @@
package org.moire.ultrasonic.util
/**
* This class distributes Now Playing related events to its subscribers.
* It is a primitive implementation of a pub-sub event bus
*/
class NowPlayingEventDistributor {
private var eventListenerList: MutableList<NowPlayingEventListener> =
listOf<NowPlayingEventListener>().toMutableList()
fun subscribe(listener: NowPlayingEventListener) {
eventListenerList.add(listener)
}
fun unsubscribe(listener: NowPlayingEventListener) {
eventListenerList.remove(listener)
}
fun raiseShowNowPlayingEvent() {
eventListenerList.forEach { listener -> listener.onShowNowPlaying() }
}
fun raiseHideNowPlayingEvent() {
eventListenerList.forEach { listener -> listener.onHideNowPlaying() }
}
fun raiseNowPlayingDismissedEvent() {
eventListenerList.forEach { listener -> listener.onDismissNowPlaying() }
}
}

View File

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

View File

@ -1,32 +0,0 @@
/*
* SilentBackgroundTask.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.app.Activity
/**
* @author Sindre Mehus
*/
abstract class SilentBackgroundTask<T>(activity: Activity?) : BackgroundTask<T>(activity) {
override fun execute() {
val thread: Thread = object : Thread() {
override fun run() {
try {
val result = doInBackground()
handler.post { done(result) }
} catch (all: Throwable) {
handler.post { error(all) }
}
}
}
thread.start()
}
override fun updateProgress(messageId: Int) {}
override fun updateProgress(message: String) {}
}

View File

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

View File

@ -1,22 +0,0 @@
package org.moire.ultrasonic.util
/**
* This class distributes Theme change related events to its subscribers.
* It is a primitive implementation of a pub-sub event bus
*/
class ThemeChangedEventDistributor {
var eventListenerList: MutableList<ThemeChangedEventListener> =
listOf<ThemeChangedEventListener>().toMutableList()
fun subscribe(listener: ThemeChangedEventListener) {
eventListenerList.add(listener)
}
fun unsubscribe(listener: ThemeChangedEventListener) {
eventListenerList.remove(listener)
}
fun RaiseThemeChangedEvent() {
eventListenerList.forEach { listener -> listener.onThemeChanged() }
}
}

View File

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

View File

@ -10,7 +10,7 @@ import java.io.File
/**
* Logs the stack trace of uncaught exceptions to a file on the SD card.
*/
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)

View File

@ -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
**/

View File

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

View File

@ -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" />

View File

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

View File

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

View File

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