mirror of
https://github.com/ultrasonic/ultrasonic
synced 2025-02-16 11:41:16 +01:00
Switch to Media3
This commit is contained in:
parent
bfc11f9924
commit
922022ab03
@ -1,15 +0,0 @@
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
enum class RepeatMode {
|
||||
OFF {
|
||||
override operator fun next(): RepeatMode = ALL
|
||||
},
|
||||
ALL {
|
||||
override operator fun next(): RepeatMode = SINGLE
|
||||
},
|
||||
SINGLE {
|
||||
override operator fun next(): RepeatMode = OFF
|
||||
};
|
||||
|
||||
abstract operator fun next(): RepeatMode
|
||||
}
|
@ -11,6 +11,7 @@ detekt = "1.19.0"
|
||||
jacoco = "0.8.7"
|
||||
preferences = "1.1.1"
|
||||
media = "1.3.1"
|
||||
media3 = "1.0.0-alpha03"
|
||||
|
||||
androidSupport = "28.0.0"
|
||||
androidLegacySupport = "1.0.0"
|
||||
@ -66,6 +67,9 @@ navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", ve
|
||||
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
|
||||
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
|
||||
media = { module = "androidx.media:media", version.ref = "media" }
|
||||
media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
media3okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
|
||||
media3session = { module = "androidx.media3:media3-session", version.ref = "media3" }
|
||||
|
||||
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||
|
@ -100,6 +100,9 @@ dependencies {
|
||||
implementation libs.constraintLayout
|
||||
implementation libs.preferences
|
||||
implementation libs.media
|
||||
implementation libs.media3exoplayer
|
||||
implementation libs.media3session
|
||||
implementation libs.media3okhttp
|
||||
|
||||
implementation libs.navigationFragment
|
||||
implementation libs.navigationUi
|
||||
|
@ -56,18 +56,17 @@
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".service.MediaPlayerService"
|
||||
android:name=".service.DownloadService"
|
||||
android:label="Ultrasonic Media Player Service"
|
||||
android:exported="false">
|
||||
</service>
|
||||
|
||||
<service
|
||||
tools:ignore="ExportedService"
|
||||
android:name=".service.AutoMediaBrowserService"
|
||||
<service android:name=".playback.PlaybackService"
|
||||
android:label="@string/common.appname"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaLibraryService" />
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
@ -146,13 +145,6 @@
|
||||
android:name=".provider.SearchSuggestionProvider"
|
||||
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.A2dpIntentReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.android.music.playstatusrequest"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
@ -1,57 +0,0 @@
|
||||
package org.moire.ultrasonic.receiver;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import org.moire.ultrasonic.domain.Track;
|
||||
import org.moire.ultrasonic.service.MediaPlayerController;
|
||||
|
||||
import kotlin.Lazy;
|
||||
|
||||
public class A2dpIntentReceiver extends BroadcastReceiver
|
||||
{
|
||||
private static final String PLAYSTATUS_RESPONSE = "com.android.music.playstatusresponse";
|
||||
private Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent)
|
||||
{
|
||||
if (mediaPlayerControllerLazy.getValue().getCurrentPlaying() == null) return;
|
||||
|
||||
Track song = mediaPlayerControllerLazy.getValue().getCurrentPlaying().getTrack();
|
||||
if (song == null) return;
|
||||
|
||||
Intent avrcpIntent = new Intent(PLAYSTATUS_RESPONSE);
|
||||
|
||||
Integer duration = song.getDuration();
|
||||
int playerPosition = mediaPlayerControllerLazy.getValue().getPlayerPosition();
|
||||
int listSize = mediaPlayerControllerLazy.getValue().getPlaylistSize();
|
||||
|
||||
if (duration != null)
|
||||
{
|
||||
avrcpIntent.putExtra("duration", (long) duration);
|
||||
}
|
||||
|
||||
avrcpIntent.putExtra("position", (long) playerPosition);
|
||||
avrcpIntent.putExtra("ListSize", (long) listSize);
|
||||
|
||||
switch (mediaPlayerControllerLazy.getValue().getPlayerState())
|
||||
{
|
||||
case STARTED:
|
||||
avrcpIntent.putExtra("playing", true);
|
||||
break;
|
||||
case STOPPED:
|
||||
case PAUSED:
|
||||
case COMPLETED:
|
||||
avrcpIntent.putExtra("playing", false);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
context.sendBroadcast(avrcpIntent);
|
||||
}
|
||||
}
|
@ -1,489 +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.service;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import timber.log.Timber;
|
||||
import android.view.Gravity;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.moire.ultrasonic.R;
|
||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException;
|
||||
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException;
|
||||
import org.moire.ultrasonic.app.UApp;
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider;
|
||||
import org.moire.ultrasonic.domain.JukeboxStatus;
|
||||
import org.moire.ultrasonic.domain.PlayerState;
|
||||
import org.moire.ultrasonic.util.Util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import kotlin.Lazy;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
/**
|
||||
* Provides an asynchronous interface to the remote jukebox on the Subsonic server.
|
||||
*
|
||||
* @author Sindre Mehus
|
||||
* @version $Id$
|
||||
*/
|
||||
public class JukeboxMediaPlayer
|
||||
{
|
||||
private static final long STATUS_UPDATE_INTERVAL_SECONDS = 5L;
|
||||
|
||||
private final TaskQueue tasks = new TaskQueue();
|
||||
private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
|
||||
private ScheduledFuture<?> statusUpdateFuture;
|
||||
private final AtomicLong timeOfLastUpdate = new AtomicLong();
|
||||
private JukeboxStatus jukeboxStatus;
|
||||
private float gain = 0.5f;
|
||||
private VolumeToast volumeToast;
|
||||
private final AtomicBoolean running = new AtomicBoolean();
|
||||
private Thread serviceThread;
|
||||
private boolean enabled = false;
|
||||
|
||||
// TODO: These create circular references, try to refactor
|
||||
private final Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
|
||||
private final Downloader downloader;
|
||||
|
||||
// TODO: Report warning if queue fills up.
|
||||
// TODO: Create shutdown method?
|
||||
// TODO: Disable repeat.
|
||||
// TODO: Persist RC state?
|
||||
// TODO: Minimize status updates.
|
||||
|
||||
public JukeboxMediaPlayer(Downloader downloader)
|
||||
{
|
||||
this.downloader = downloader;
|
||||
}
|
||||
|
||||
public void startJukeboxService()
|
||||
{
|
||||
if (running.get())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
running.set(true);
|
||||
startProcessTasks();
|
||||
Timber.d("Started Jukebox Service");
|
||||
}
|
||||
|
||||
public void stopJukeboxService()
|
||||
{
|
||||
running.set(false);
|
||||
Util.sleepQuietly(1000);
|
||||
|
||||
if (serviceThread != null)
|
||||
{
|
||||
serviceThread.interrupt();
|
||||
}
|
||||
Timber.d("Stopped Jukebox Service");
|
||||
}
|
||||
|
||||
private void startProcessTasks()
|
||||
{
|
||||
serviceThread = new Thread()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
processTasks();
|
||||
}
|
||||
};
|
||||
|
||||
serviceThread.start();
|
||||
}
|
||||
|
||||
private synchronized void startStatusUpdate()
|
||||
{
|
||||
stopStatusUpdate();
|
||||
|
||||
Runnable updateTask = new Runnable()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
tasks.remove(GetStatus.class);
|
||||
tasks.add(new GetStatus());
|
||||
}
|
||||
};
|
||||
|
||||
statusUpdateFuture = executorService.scheduleWithFixedDelay(updateTask, STATUS_UPDATE_INTERVAL_SECONDS, STATUS_UPDATE_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private synchronized void stopStatusUpdate()
|
||||
{
|
||||
if (statusUpdateFuture != null)
|
||||
{
|
||||
statusUpdateFuture.cancel(false);
|
||||
statusUpdateFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void processTasks()
|
||||
{
|
||||
while (running.get())
|
||||
{
|
||||
JukeboxTask task = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (!ActiveServerProvider.Companion.isOffline())
|
||||
{
|
||||
task = tasks.take();
|
||||
JukeboxStatus status = task.execute();
|
||||
onStatusUpdate(status);
|
||||
}
|
||||
}
|
||||
catch (InterruptedException ignored)
|
||||
{
|
||||
|
||||
}
|
||||
catch (Throwable x)
|
||||
{
|
||||
onError(task, x);
|
||||
}
|
||||
|
||||
Util.sleepQuietly(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void onStatusUpdate(JukeboxStatus jukeboxStatus)
|
||||
{
|
||||
timeOfLastUpdate.set(System.currentTimeMillis());
|
||||
this.jukeboxStatus = jukeboxStatus;
|
||||
|
||||
// Track change?
|
||||
Integer index = jukeboxStatus.getCurrentPlayingIndex();
|
||||
|
||||
if (index != null && index != -1 && index != downloader.getCurrentPlayingIndex())
|
||||
{
|
||||
mediaPlayerControllerLazy.getValue().setCurrentPlaying(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void onError(JukeboxTask task, Throwable x)
|
||||
{
|
||||
if (x instanceof ApiNotSupportedException && !(task instanceof Stop))
|
||||
{
|
||||
disableJukeboxOnError(x, R.string.download_jukebox_server_too_old);
|
||||
}
|
||||
else if (x instanceof OfflineException && !(task instanceof Stop))
|
||||
{
|
||||
disableJukeboxOnError(x, R.string.download_jukebox_offline);
|
||||
}
|
||||
else if (x instanceof SubsonicRESTException && ((SubsonicRESTException) x).getCode() == 50 && !(task instanceof Stop))
|
||||
{
|
||||
disableJukeboxOnError(x, R.string.download_jukebox_not_authorized);
|
||||
}
|
||||
else
|
||||
{
|
||||
Timber.e(x, "Failed to process jukebox task");
|
||||
}
|
||||
}
|
||||
|
||||
private void disableJukeboxOnError(Throwable x, final int resourceId)
|
||||
{
|
||||
Timber.w(x.toString());
|
||||
Context context = UApp.Companion.applicationContext();
|
||||
new Handler().post(() -> Util.toast(context, resourceId, false));
|
||||
|
||||
mediaPlayerControllerLazy.getValue().setJukeboxEnabled(false);
|
||||
}
|
||||
|
||||
public void updatePlaylist()
|
||||
{
|
||||
if (!enabled) return;
|
||||
|
||||
tasks.remove(Skip.class);
|
||||
tasks.remove(Stop.class);
|
||||
tasks.remove(Start.class);
|
||||
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (DownloadFile file : downloader.getAll())
|
||||
{
|
||||
ids.add(file.getTrack().getId());
|
||||
}
|
||||
|
||||
tasks.add(new SetPlaylist(ids));
|
||||
}
|
||||
|
||||
public void skip(final int index, final int offsetSeconds)
|
||||
{
|
||||
tasks.remove(Skip.class);
|
||||
tasks.remove(Stop.class);
|
||||
tasks.remove(Start.class);
|
||||
|
||||
startStatusUpdate();
|
||||
|
||||
if (jukeboxStatus != null)
|
||||
{
|
||||
jukeboxStatus.setPositionSeconds(offsetSeconds);
|
||||
}
|
||||
|
||||
tasks.add(new Skip(index, offsetSeconds));
|
||||
mediaPlayerControllerLazy.getValue().setPlayerState(PlayerState.STARTED);
|
||||
}
|
||||
|
||||
public void stop()
|
||||
{
|
||||
tasks.remove(Stop.class);
|
||||
tasks.remove(Start.class);
|
||||
|
||||
stopStatusUpdate();
|
||||
|
||||
tasks.add(new Stop());
|
||||
}
|
||||
|
||||
public void start()
|
||||
{
|
||||
tasks.remove(Stop.class);
|
||||
tasks.remove(Start.class);
|
||||
|
||||
startStatusUpdate();
|
||||
tasks.add(new Start());
|
||||
}
|
||||
|
||||
public synchronized void adjustVolume(boolean up)
|
||||
{
|
||||
float delta = up ? 0.05f : -0.05f;
|
||||
gain += delta;
|
||||
gain = Math.max(gain, 0.0f);
|
||||
gain = Math.min(gain, 1.0f);
|
||||
|
||||
tasks.remove(SetGain.class);
|
||||
tasks.add(new SetGain(gain));
|
||||
|
||||
Context context = UApp.Companion.applicationContext();
|
||||
if (volumeToast == null) volumeToast = new VolumeToast(context);
|
||||
|
||||
volumeToast.setVolume(gain);
|
||||
}
|
||||
|
||||
private MusicService getMusicService()
|
||||
{
|
||||
return MusicServiceFactory.getMusicService();
|
||||
}
|
||||
|
||||
public int getPositionSeconds()
|
||||
{
|
||||
if (jukeboxStatus == null || jukeboxStatus.getPositionSeconds() == null || timeOfLastUpdate.get() == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (jukeboxStatus.isPlaying())
|
||||
{
|
||||
int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L);
|
||||
return jukeboxStatus.getPositionSeconds() + secondsSinceLastUpdate;
|
||||
}
|
||||
|
||||
return jukeboxStatus.getPositionSeconds();
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled)
|
||||
{
|
||||
Timber.d("Jukebox Service setting enabled to %b", enabled);
|
||||
this.enabled = enabled;
|
||||
|
||||
tasks.clear();
|
||||
if (enabled)
|
||||
{
|
||||
updatePlaylist();
|
||||
}
|
||||
|
||||
stop();
|
||||
}
|
||||
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
|
||||
private static class TaskQueue
|
||||
{
|
||||
private final LinkedBlockingQueue<JukeboxTask> queue = new LinkedBlockingQueue<>();
|
||||
|
||||
void add(JukeboxTask jukeboxTask)
|
||||
{
|
||||
queue.add(jukeboxTask);
|
||||
}
|
||||
|
||||
JukeboxTask take() throws InterruptedException
|
||||
{
|
||||
return queue.take();
|
||||
}
|
||||
|
||||
void remove(Class<? extends JukeboxTask> taskClass)
|
||||
{
|
||||
try
|
||||
{
|
||||
Iterator<JukeboxTask> iterator = queue.iterator();
|
||||
|
||||
while (iterator.hasNext())
|
||||
{
|
||||
JukeboxTask task = iterator.next();
|
||||
|
||||
if (taskClass.equals(task.getClass()))
|
||||
{
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Throwable x)
|
||||
{
|
||||
Timber.w(x, "Failed to clean-up task queue.");
|
||||
}
|
||||
}
|
||||
|
||||
void clear()
|
||||
{
|
||||
queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private abstract static class JukeboxTask
|
||||
{
|
||||
abstract JukeboxStatus execute() throws Exception;
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
|
||||
private class GetStatus extends JukeboxTask
|
||||
{
|
||||
@Override
|
||||
JukeboxStatus execute() throws Exception
|
||||
{
|
||||
return getMusicService().getJukeboxStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private class SetPlaylist extends JukeboxTask
|
||||
{
|
||||
private final List<String> ids;
|
||||
|
||||
SetPlaylist(List<String> ids)
|
||||
{
|
||||
this.ids = ids;
|
||||
}
|
||||
|
||||
@Override
|
||||
JukeboxStatus execute() throws Exception
|
||||
{
|
||||
return getMusicService().updateJukeboxPlaylist(ids);
|
||||
}
|
||||
}
|
||||
|
||||
private class Skip extends JukeboxTask
|
||||
{
|
||||
private final int index;
|
||||
private final int offsetSeconds;
|
||||
|
||||
Skip(int index, int offsetSeconds)
|
||||
{
|
||||
this.index = index;
|
||||
this.offsetSeconds = offsetSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
JukeboxStatus execute() throws Exception
|
||||
{
|
||||
return getMusicService().skipJukebox(index, offsetSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
private class Stop extends JukeboxTask
|
||||
{
|
||||
@Override
|
||||
JukeboxStatus execute() throws Exception
|
||||
{
|
||||
return getMusicService().stopJukebox();
|
||||
}
|
||||
}
|
||||
|
||||
private class Start extends JukeboxTask
|
||||
{
|
||||
@Override
|
||||
JukeboxStatus execute() throws Exception
|
||||
{
|
||||
return getMusicService().startJukebox();
|
||||
}
|
||||
}
|
||||
|
||||
private class SetGain extends JukeboxTask
|
||||
{
|
||||
|
||||
private final float gain;
|
||||
|
||||
private SetGain(float gain)
|
||||
{
|
||||
this.gain = gain;
|
||||
}
|
||||
|
||||
@Override
|
||||
JukeboxStatus execute() throws Exception
|
||||
{
|
||||
return getMusicService().setJukeboxGain(gain);
|
||||
}
|
||||
}
|
||||
|
||||
private static class VolumeToast extends Toast
|
||||
{
|
||||
|
||||
private final ProgressBar progressBar;
|
||||
|
||||
public VolumeToast(Context context)
|
||||
{
|
||||
super(context);
|
||||
setDuration(Toast.LENGTH_SHORT);
|
||||
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
View view = inflater.inflate(R.layout.jukebox_volume, null);
|
||||
progressBar = (ProgressBar) view.findViewById(R.id.jukebox_volume_progress_bar);
|
||||
setView(view);
|
||||
setGravity(Gravity.TOP, 0, 0);
|
||||
}
|
||||
|
||||
public void setVolume(float volume)
|
||||
{
|
||||
progressBar.setProgress(Math.round(100 * volume));
|
||||
show();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,125 +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 org.moire.ultrasonic.data.ActiveServerProvider;
|
||||
import org.moire.ultrasonic.domain.MusicDirectory;
|
||||
import org.moire.ultrasonic.domain.Track;
|
||||
import org.moire.ultrasonic.service.MusicService;
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* @author Sindre Mehus
|
||||
* @version $Id$
|
||||
*/
|
||||
public class ShufflePlayBuffer
|
||||
{
|
||||
private static final int CAPACITY = 50;
|
||||
private static final int REFILL_THRESHOLD = 40;
|
||||
|
||||
private final List<Track> buffer = new ArrayList<>();
|
||||
private ScheduledExecutorService executorService;
|
||||
private int currentServer;
|
||||
|
||||
public boolean isEnabled = false;
|
||||
|
||||
public ShufflePlayBuffer()
|
||||
{
|
||||
}
|
||||
|
||||
public void onCreate()
|
||||
{
|
||||
executorService = Executors.newSingleThreadScheduledExecutor();
|
||||
Runnable runnable = this::refill;
|
||||
executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS);
|
||||
Timber.i("ShufflePlayBuffer created");
|
||||
}
|
||||
|
||||
public void onDestroy()
|
||||
{
|
||||
executorService.shutdown();
|
||||
Timber.i("ShufflePlayBuffer destroyed");
|
||||
}
|
||||
|
||||
public List<Track> get(int size)
|
||||
{
|
||||
clearBufferIfNecessary();
|
||||
|
||||
List<Track> result = new ArrayList<>(size);
|
||||
synchronized (buffer)
|
||||
{
|
||||
while (!buffer.isEmpty() && result.size() < size)
|
||||
{
|
||||
result.add(buffer.remove(buffer.size() - 1));
|
||||
}
|
||||
}
|
||||
Timber.i("Taking %d songs from shuffle play buffer. %d remaining.", result.size(), buffer.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void refill()
|
||||
{
|
||||
if (!isEnabled) return;
|
||||
|
||||
// Check if active server has changed.
|
||||
clearBufferIfNecessary();
|
||||
|
||||
if (buffer.size() > REFILL_THRESHOLD || (!Util.isNetworkConnected() && !ActiveServerProvider.Companion.isOffline()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MusicService service = MusicServiceFactory.getMusicService();
|
||||
int n = CAPACITY - buffer.size();
|
||||
MusicDirectory songs = service.getRandomSongs(n);
|
||||
|
||||
synchronized (buffer)
|
||||
{
|
||||
buffer.addAll(songs.getTracks());
|
||||
Timber.i("Refilled shuffle play buffer with %d songs.", songs.getTracks().size());
|
||||
}
|
||||
}
|
||||
catch (Exception x)
|
||||
{
|
||||
Timber.w(x, "Failed to refill shuffle play buffer.");
|
||||
}
|
||||
}
|
||||
|
||||
private void clearBufferIfNecessary()
|
||||
{
|
||||
synchronized (buffer)
|
||||
{
|
||||
if (currentServer != ActiveServerProvider.Companion.getActiveServerId())
|
||||
{
|
||||
currentServer = ActiveServerProvider.Companion.getActiveServerId();
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,290 +0,0 @@
|
||||
package org.moire.ultrasonic.util;
|
||||
|
||||
import org.moire.ultrasonic.domain.Track;
|
||||
import org.moire.ultrasonic.service.DownloadFile;
|
||||
import org.moire.ultrasonic.service.Supplier;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
public class StreamProxy implements Runnable
|
||||
{
|
||||
private Thread thread;
|
||||
private boolean isRunning;
|
||||
private ServerSocket socket;
|
||||
private int port;
|
||||
private Supplier<DownloadFile> currentPlaying;
|
||||
|
||||
public StreamProxy(Supplier<DownloadFile> currentPlaying)
|
||||
{
|
||||
|
||||
// Create listening socket
|
||||
try
|
||||
{
|
||||
socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1}));
|
||||
socket.setSoTimeout(5000);
|
||||
port = socket.getLocalPort();
|
||||
this.currentPlaying = currentPlaying;
|
||||
}
|
||||
catch (UnknownHostException e)
|
||||
{ // impossible
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
Timber.e(e, "IOException initializing server");
|
||||
}
|
||||
}
|
||||
|
||||
public int getPort()
|
||||
{
|
||||
return port;
|
||||
}
|
||||
|
||||
public void start()
|
||||
{
|
||||
thread = new Thread(this);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void stop()
|
||||
{
|
||||
isRunning = false;
|
||||
thread.interrupt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
isRunning = true;
|
||||
while (isRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
Socket client = socket.accept();
|
||||
if (client == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Timber.i("Client connected");
|
||||
|
||||
StreamToMediaPlayerTask task = new StreamToMediaPlayerTask(client);
|
||||
if (task.processRequest())
|
||||
{
|
||||
new Thread(task).start();
|
||||
}
|
||||
|
||||
}
|
||||
catch (SocketTimeoutException e)
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
Timber.e(e, "Error connecting to client");
|
||||
}
|
||||
}
|
||||
Timber.i("Proxy interrupted. Shutting down.");
|
||||
}
|
||||
|
||||
private class StreamToMediaPlayerTask implements Runnable {
|
||||
String localPath;
|
||||
Socket client;
|
||||
int cbSkip;
|
||||
|
||||
StreamToMediaPlayerTask(Socket client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
private String readRequest() {
|
||||
InputStream is;
|
||||
String firstLine;
|
||||
try {
|
||||
is = client.getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is), 8192);
|
||||
firstLine = reader.readLine();
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "Error parsing request");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (firstLine == null) {
|
||||
Timber.i("Proxy client closed connection without a request.");
|
||||
return null;
|
||||
}
|
||||
|
||||
StringTokenizer st = new StringTokenizer(firstLine);
|
||||
st.nextToken(); // method
|
||||
String uri = st.nextToken();
|
||||
String realUri = uri.substring(1);
|
||||
Timber.i(realUri);
|
||||
|
||||
return realUri;
|
||||
}
|
||||
|
||||
boolean processRequest() {
|
||||
final String uri = readRequest();
|
||||
if (uri == null || uri.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read HTTP headers
|
||||
Timber.i("Processing request: %s", uri);
|
||||
|
||||
try {
|
||||
localPath = URLDecoder.decode(uri, Constants.UTF_8);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
Timber.e(e, "Unsupported encoding");
|
||||
return false;
|
||||
}
|
||||
|
||||
Timber.i("Processing request for file %s", localPath);
|
||||
if (Storage.INSTANCE.isPathExists(localPath)) return true;
|
||||
|
||||
// Usually the .partial file will be requested here, but sometimes it has already
|
||||
// been renamed, so check if it is completed since
|
||||
String saveFileName = FileUtil.INSTANCE.getSaveFile(localPath);
|
||||
String completeFileName = FileUtil.INSTANCE.getCompleteFile(saveFileName);
|
||||
|
||||
if (Storage.INSTANCE.isPathExists(saveFileName)) {
|
||||
localPath = saveFileName;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Storage.INSTANCE.isPathExists(completeFileName)) {
|
||||
localPath = completeFileName;
|
||||
return true;
|
||||
}
|
||||
|
||||
Timber.e("File %s does not exist", localPath);
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
Timber.i("Streaming song in background");
|
||||
DownloadFile downloadFile = currentPlaying == null? null : currentPlaying.get();
|
||||
Track song = downloadFile.getTrack();
|
||||
long fileSize = downloadFile.getBitRate() * ((song.getDuration() != null) ? song.getDuration() : 0) * 1000 / 8;
|
||||
Timber.i("Streaming fileSize: %d", fileSize);
|
||||
|
||||
// Create HTTP header
|
||||
String headers = "HTTP/1.0 200 OK\r\n";
|
||||
headers += "Content-Type: application/octet-stream\r\n";
|
||||
headers += "Connection: close\r\n";
|
||||
headers += "\r\n";
|
||||
|
||||
long cbToSend = fileSize - cbSkip;
|
||||
OutputStream output = null;
|
||||
byte[] buff = new byte[64 * 1024];
|
||||
|
||||
try
|
||||
{
|
||||
output = new BufferedOutputStream(client.getOutputStream(), 32 * 1024);
|
||||
output.write(headers.getBytes());
|
||||
|
||||
if (!downloadFile.isWorkDone())
|
||||
{
|
||||
// Loop as long as there's stuff to send
|
||||
while (isRunning && !client.isClosed())
|
||||
{
|
||||
// See if there's more to send
|
||||
String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile();
|
||||
int cbSentThisBatch = 0;
|
||||
|
||||
AbstractFile storageFile = Storage.INSTANCE.getFromPath(file);
|
||||
if (storageFile != null)
|
||||
{
|
||||
InputStream input = storageFile.getFileInputStream();
|
||||
|
||||
try
|
||||
{
|
||||
long skip = input.skip(cbSkip);
|
||||
int cbToSendThisBatch = input.available();
|
||||
|
||||
while (cbToSendThisBatch > 0)
|
||||
{
|
||||
int cbToRead = Math.min(cbToSendThisBatch, buff.length);
|
||||
int cbRead = input.read(buff, 0, cbToRead);
|
||||
|
||||
if (cbRead == -1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
cbToSendThisBatch -= cbRead;
|
||||
cbToSend -= cbRead;
|
||||
output.write(buff, 0, cbRead);
|
||||
output.flush();
|
||||
cbSkip += cbRead;
|
||||
cbSentThisBatch += cbRead;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
input.close();
|
||||
}
|
||||
|
||||
// Done regardless of whether or not it thinks it is
|
||||
if (downloadFile.isWorkDone() && cbSkip >= file.length())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we did nothing this batch, block for a second
|
||||
if (cbSentThisBatch == 0)
|
||||
{
|
||||
Timber.d("Blocking until more data appears (%d)", cbToSend);
|
||||
Util.sleepQuietly(1000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Timber.w("Requesting data for completely downloaded file");
|
||||
}
|
||||
}
|
||||
catch (SocketException socketException)
|
||||
{
|
||||
Timber.e("SocketException() thrown, proxy client has probably closed. This can exit harmlessly");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Timber.e("Exception thrown from streaming task:");
|
||||
Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage());
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
try
|
||||
{
|
||||
if (output != null)
|
||||
{
|
||||
output.close();
|
||||
}
|
||||
client.close();
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
Timber.e("IOException while cleaning up streaming task:");
|
||||
Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -130,7 +130,7 @@ public class VisualizerView extends View
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaPlayerControllerLazy.getValue().getPlayerState() != PlayerState.STARTED)
|
||||
if (mediaPlayerControllerLazy.getValue().getLegacyPlayerState() != PlayerState.STARTED)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
@ -27,6 +27,8 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.media3.common.Player.STATE_BUFFERING
|
||||
import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
@ -414,9 +416,9 @@ class NavigationActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
if (nowPlayingView != null) {
|
||||
val playerState: PlayerState = mediaPlayerController.playerState
|
||||
if (playerState == PlayerState.PAUSED || playerState == PlayerState.STARTED) {
|
||||
val file: DownloadFile? = mediaPlayerController.currentPlaying
|
||||
val playerState: Int = mediaPlayerController.playbackState
|
||||
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
|
||||
val file: DownloadFile? = mediaPlayerController.currentPlayingLegacy
|
||||
if (file != null) {
|
||||
nowPlayingView?.visibility = View.VISIBLE
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ import org.moire.ultrasonic.service.DownloadFile
|
||||
import org.moire.ultrasonic.service.Downloader
|
||||
|
||||
class TrackViewBinder(
|
||||
val onItemClick: (DownloadFile) -> Unit,
|
||||
val onItemClick: (DownloadFile, Int) -> Unit,
|
||||
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
|
||||
val checkable: Boolean,
|
||||
val draggable: Boolean,
|
||||
@ -29,7 +29,7 @@ class TrackViewBinder(
|
||||
|
||||
// Set our layout files
|
||||
val layout = R.layout.list_item_track
|
||||
val contextMenuLayout = R.menu.context_menu_track
|
||||
private val contextMenuLayout = R.menu.context_menu_track
|
||||
|
||||
private val downloader: Downloader by inject()
|
||||
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
|
||||
@ -41,15 +41,14 @@ class TrackViewBinder(
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@Suppress("LongMethod")
|
||||
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
|
||||
val downloadFile: DownloadFile?
|
||||
val diffAdapter = adapter as BaseAdapter<*>
|
||||
|
||||
when (item) {
|
||||
val downloadFile: DownloadFile = when (item) {
|
||||
is Track -> {
|
||||
downloadFile = downloader.getDownloadFileForSong(item)
|
||||
downloader.getDownloadFileForSong(item)
|
||||
}
|
||||
is DownloadFile -> {
|
||||
downloadFile = item
|
||||
item
|
||||
}
|
||||
else -> {
|
||||
return
|
||||
@ -90,7 +89,7 @@ class TrackViewBinder(
|
||||
val nowChecked = !holder.check.isChecked
|
||||
holder.isChecked = nowChecked
|
||||
} else {
|
||||
onItemClick(downloadFile)
|
||||
onItemClick(downloadFile, holder.bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,41 +102,37 @@ class TrackViewBinder(
|
||||
|
||||
// Notify the adapter of selection changes
|
||||
holder.observableChecked.observe(
|
||||
lifecycleOwner,
|
||||
{ isCheckedNow ->
|
||||
if (isCheckedNow) {
|
||||
diffAdapter.notifySelected(holder.entry!!.longId)
|
||||
} else {
|
||||
diffAdapter.notifyUnselected(holder.entry!!.longId)
|
||||
}
|
||||
lifecycleOwner
|
||||
) { isCheckedNow ->
|
||||
if (isCheckedNow) {
|
||||
diffAdapter.notifySelected(holder.entry!!.longId)
|
||||
} else {
|
||||
diffAdapter.notifyUnselected(holder.entry!!.longId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Listen to changes in selection status and update ourselves
|
||||
diffAdapter.selectionRevision.observe(
|
||||
lifecycleOwner,
|
||||
{
|
||||
val newStatus = diffAdapter.isSelected(item.longId)
|
||||
lifecycleOwner
|
||||
) {
|
||||
val newStatus = diffAdapter.isSelected(item.longId)
|
||||
|
||||
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
|
||||
}
|
||||
)
|
||||
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
|
||||
}
|
||||
|
||||
// Observe download status
|
||||
downloadFile.status.observe(
|
||||
lifecycleOwner,
|
||||
{
|
||||
holder.updateStatus(it)
|
||||
diffAdapter.notifyChanged()
|
||||
}
|
||||
)
|
||||
lifecycleOwner
|
||||
) {
|
||||
holder.updateStatus(it)
|
||||
diffAdapter.notifyChanged()
|
||||
}
|
||||
|
||||
downloadFile.progress.observe(
|
||||
lifecycleOwner,
|
||||
{
|
||||
holder.updateProgress(it)
|
||||
}
|
||||
)
|
||||
lifecycleOwner
|
||||
) {
|
||||
holder.updateProgress(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: TrackViewHolder) {
|
||||
|
@ -109,7 +109,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
||||
}
|
||||
|
||||
rxSubscription = RxBus.playerStateObservable.subscribe {
|
||||
setPlayIcon(it.track == downloadFile)
|
||||
setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,6 @@ 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.MediaSessionHandler
|
||||
|
||||
/**
|
||||
* This Koin module contains the registration of general classes needed for Ultrasonic
|
||||
@ -12,5 +11,4 @@ import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
val applicationModule = module {
|
||||
single { ActiveServerProvider(get()) }
|
||||
single { ImageLoaderProvider(androidContext()) }
|
||||
single { MediaSessionHandler() }
|
||||
}
|
||||
|
@ -1,15 +1,13 @@
|
||||
package org.moire.ultrasonic.di
|
||||
|
||||
import org.koin.dsl.module
|
||||
import org.moire.ultrasonic.service.AudioFocusHandler
|
||||
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
||||
import org.moire.ultrasonic.service.Downloader
|
||||
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
||||
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
||||
import org.moire.ultrasonic.service.LocalMediaPlayer
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||
import org.moire.ultrasonic.service.PlaybackStateSerializer
|
||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
||||
|
||||
/**
|
||||
* This Koin module contains the registration of classes related to the media player
|
||||
@ -19,10 +17,8 @@ val mediaPlayerModule = module {
|
||||
single { MediaPlayerLifecycleSupport() }
|
||||
single { PlaybackStateSerializer() }
|
||||
single { ExternalStorageMonitor() }
|
||||
single { ShufflePlayBuffer() }
|
||||
single { Downloader(get(), get(), get()) }
|
||||
single { LocalMediaPlayer() }
|
||||
single { AudioFocusHandler(get()) }
|
||||
single { LegacyPlaylistManager() }
|
||||
single { Downloader(get(), get()) }
|
||||
|
||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
||||
single { MediaPlayerController(get(), get(), get(), get(), get()) }
|
||||
|
@ -54,7 +54,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
|
||||
|
||||
viewAdapter.register(
|
||||
TrackViewBinder(
|
||||
{ },
|
||||
{ _, _ -> },
|
||||
{ _, _ -> true },
|
||||
checkable = false,
|
||||
draggable = false,
|
||||
|
@ -47,7 +47,7 @@ class NowPlayingFragment : Fragment() {
|
||||
private var nowPlayingTrack: TextView? = null
|
||||
private var nowPlayingArtist: TextView? = null
|
||||
|
||||
private var playerStateSubscription: Disposable? = null
|
||||
private var rxBusSubscription: Disposable? = null
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val imageLoader: ImageLoaderProvider by inject()
|
||||
|
||||
@ -69,8 +69,7 @@ class NowPlayingFragment : Fragment() {
|
||||
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() }
|
||||
rxBusSubscription = RxBus.playerStateObservable.subscribe { update() }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@ -80,13 +79,13 @@ class NowPlayingFragment : Fragment() {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
playerStateSubscription!!.dispose()
|
||||
rxBusSubscription!!.dispose()
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun update() {
|
||||
try {
|
||||
val playerState = mediaPlayerController.playerState
|
||||
val playerState = mediaPlayerController.legacyPlayerState
|
||||
|
||||
if (playerState === PlayerState.PAUSED) {
|
||||
playButton!!.setImageDrawable(
|
||||
@ -102,7 +101,7 @@ class NowPlayingFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
|
||||
val file = mediaPlayerController.currentPlaying
|
||||
val file = mediaPlayerController.currentPlayingLegacy
|
||||
|
||||
if (file != null) {
|
||||
val song = file.track
|
||||
|
@ -13,6 +13,7 @@ import android.graphics.Point
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.ContextMenu
|
||||
import android.view.ContextMenu.ContextMenuInfo
|
||||
import android.view.GestureDetector
|
||||
@ -35,24 +36,15 @@ import android.widget.TextView
|
||||
import android.widget.ViewFlipper
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
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.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 io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
@ -66,15 +58,13 @@ import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
import org.moire.ultrasonic.audiofx.VisualizerController
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.domain.RepeatMode
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
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.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
@ -86,9 +76,20 @@ import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.view.AutoRepeatButton
|
||||
import org.moire.ultrasonic.view.VisualizerView
|
||||
import timber.log.Timber
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
|
||||
* TODO: Add timeline lister -> updateProgressBar().
|
||||
*/
|
||||
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
||||
class PlayerFragment :
|
||||
@ -113,14 +114,13 @@ class PlayerFragment :
|
||||
// Data & Services
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val localMediaPlayer: LocalMediaPlayer by inject()
|
||||
private val shareHandler: ShareHandler by inject()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
private lateinit var executorService: ScheduledExecutorService
|
||||
private var currentPlaying: DownloadFile? = null
|
||||
private var currentSong: Track? = null
|
||||
private lateinit var viewManager: LinearLayoutManager
|
||||
private var rxBusSubscription: Disposable? = null
|
||||
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
private lateinit var executorService: ScheduledExecutorService
|
||||
private var ioScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
// Views and UI Elements
|
||||
@ -148,7 +148,7 @@ class PlayerFragment :
|
||||
private lateinit var durationTextView: TextView
|
||||
private lateinit var pauseButton: View
|
||||
private lateinit var stopButton: View
|
||||
private lateinit var startButton: View
|
||||
private lateinit var playButton: View
|
||||
private lateinit var repeatButton: ImageView
|
||||
private lateinit var hollowStar: Drawable
|
||||
private lateinit var fullStar: Drawable
|
||||
@ -189,7 +189,7 @@ class PlayerFragment :
|
||||
|
||||
pauseButton = view.findViewById(R.id.button_pause)
|
||||
stopButton = view.findViewById(R.id.button_stop)
|
||||
startButton = view.findViewById(R.id.button_start)
|
||||
playButton = view.findViewById(R.id.button_start)
|
||||
repeatButton = view.findViewById(R.id.button_repeat)
|
||||
visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout)
|
||||
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
|
||||
@ -216,13 +216,6 @@ class PlayerFragment :
|
||||
swipeVelocity = swipeDistance
|
||||
gestureScanner = GestureDetector(context, this)
|
||||
|
||||
// The secondary progress is an indicator of how far the song is cached.
|
||||
localMediaPlayer.secondaryProgress.observe(
|
||||
viewLifecycleOwner,
|
||||
{
|
||||
progressBar.secondaryProgress = it
|
||||
}
|
||||
)
|
||||
|
||||
findViews(view)
|
||||
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
|
||||
@ -291,34 +284,40 @@ class PlayerFragment :
|
||||
}
|
||||
}
|
||||
|
||||
startButton.setOnClickListener {
|
||||
playButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
start()
|
||||
mediaPlayerController.play()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
shuffleButton.setOnClickListener {
|
||||
mediaPlayerController.shuffle()
|
||||
mediaPlayerController.toggleShuffle()
|
||||
Util.toast(activity, R.string.download_menu_shuffle_notification)
|
||||
}
|
||||
|
||||
repeatButton.setOnClickListener {
|
||||
val repeatMode = mediaPlayerController.repeatMode.next()
|
||||
mediaPlayerController.repeatMode = repeatMode
|
||||
var newRepeat = mediaPlayerController.repeatMode + 1
|
||||
if (newRepeat == 3) {
|
||||
newRepeat = 0
|
||||
}
|
||||
|
||||
mediaPlayerController.repeatMode = newRepeat
|
||||
|
||||
onPlaylistChanged()
|
||||
when (repeatMode) {
|
||||
RepeatMode.OFF -> Util.toast(
|
||||
|
||||
when (newRepeat) {
|
||||
0 -> Util.toast(
|
||||
context, R.string.download_repeat_off
|
||||
)
|
||||
RepeatMode.ALL -> Util.toast(
|
||||
context, R.string.download_repeat_all
|
||||
)
|
||||
RepeatMode.SINGLE -> Util.toast(
|
||||
1 -> Util.toast(
|
||||
context, R.string.download_repeat_single
|
||||
)
|
||||
2 -> Util.toast(
|
||||
context, R.string.download_repeat_all
|
||||
)
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
@ -351,53 +350,62 @@ class PlayerFragment :
|
||||
|
||||
visualizerViewLayout.isVisible = false
|
||||
VisualizerController.get().observe(
|
||||
requireActivity(),
|
||||
{ visualizerController ->
|
||||
if (visualizerController != null) {
|
||||
Timber.d("VisualizerController Observer.onChanged received controller")
|
||||
visualizerView = VisualizerView(context)
|
||||
visualizerViewLayout.addView(
|
||||
visualizerView,
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
requireActivity()
|
||||
) { visualizerController ->
|
||||
if (visualizerController != null) {
|
||||
Timber.d("VisualizerController Observer.onChanged received controller")
|
||||
visualizerView = VisualizerView(context)
|
||||
visualizerViewLayout.addView(
|
||||
visualizerView,
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
|
||||
visualizerViewLayout.isVisible = visualizerView.isActive
|
||||
visualizerViewLayout.isVisible = visualizerView.isActive
|
||||
|
||||
visualizerView.setOnTouchListener { _, _ ->
|
||||
visualizerView.isActive = !visualizerView.isActive
|
||||
mediaPlayerController.showVisualization = visualizerView.isActive
|
||||
true
|
||||
}
|
||||
isVisualizerAvailable = true
|
||||
} else {
|
||||
Timber.d("VisualizerController Observer.onChanged has no controller")
|
||||
visualizerViewLayout.isVisible = false
|
||||
isVisualizerAvailable = false
|
||||
visualizerView.setOnTouchListener { _, _ ->
|
||||
visualizerView.isActive = !visualizerView.isActive
|
||||
mediaPlayerController.showVisualization = visualizerView.isActive
|
||||
true
|
||||
}
|
||||
isVisualizerAvailable = true
|
||||
} else {
|
||||
Timber.d("VisualizerController Observer.onChanged has no controller")
|
||||
visualizerViewLayout.isVisible = false
|
||||
isVisualizerAvailable = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
EqualizerController.get().observe(
|
||||
requireActivity(),
|
||||
{ equalizerController ->
|
||||
isEqualizerAvailable = if (equalizerController != null) {
|
||||
Timber.d("EqualizerController Observer.onChanged received controller")
|
||||
true
|
||||
} else {
|
||||
Timber.d("EqualizerController Observer.onChanged has no controller")
|
||||
false
|
||||
}
|
||||
requireActivity()
|
||||
) { equalizerController ->
|
||||
isEqualizerAvailable = if (equalizerController != null) {
|
||||
Timber.d("EqualizerController Observer.onChanged received controller")
|
||||
true
|
||||
} else {
|
||||
Timber.d("EqualizerController Observer.onChanged has no controller")
|
||||
false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Observe playlist changes and update the UI
|
||||
rxBusSubscription = RxBus.playlistObservable.subscribe {
|
||||
// FIXME
|
||||
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
||||
onPlaylistChanged()
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.playerStateObservable.subscribe {
|
||||
update()
|
||||
}
|
||||
|
||||
mediaPlayerController.controller?.addListener(object : Player.Listener {
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
})
|
||||
|
||||
// Query the Jukebox state in an IO Context
|
||||
ioScope.launch(CommunicationError.getHandler(context)) {
|
||||
try {
|
||||
@ -412,16 +420,15 @@ class PlayerFragment :
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (mediaPlayerController.currentPlaying == null) {
|
||||
if (mediaPlayerController.currentPlayingLegacy == null) {
|
||||
playlistFlipper.displayedChild = 1
|
||||
} else {
|
||||
// Download list and Album art must be updated when Resumed
|
||||
// Download list and Album art must be updated when resumed
|
||||
onPlaylistChanged()
|
||||
onCurrentChanged()
|
||||
}
|
||||
val handler = Handler()
|
||||
|
||||
// TODO Use Rx for Update instead of polling!
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
val runnable = Runnable { handler.post { update(cancellationToken) } }
|
||||
executorService = Executors.newSingleThreadScheduledExecutor()
|
||||
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
|
||||
@ -441,7 +448,7 @@ class PlayerFragment :
|
||||
|
||||
// Scroll to current playing.
|
||||
private fun scrollToCurrent() {
|
||||
val index = mediaPlayerController.playList.indexOf(currentPlaying)
|
||||
val index = mediaPlayerController.currentMediaItemIndex
|
||||
|
||||
if (index != -1) {
|
||||
val smoothScroller = LinearSmoothScroller(context)
|
||||
@ -459,7 +466,7 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
rxBusSubscription?.dispose()
|
||||
rxBusSubscription.dispose()
|
||||
cancel("CoroutineScope cancelled because the view was destroyed")
|
||||
cancellationToken.cancel()
|
||||
super.onDestroyView()
|
||||
@ -504,7 +511,7 @@ class PlayerFragment :
|
||||
visualizerMenuItem.isVisible = isVisualizerAvailable
|
||||
}
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
val downloadFile = mediaPlayerController.currentPlaying
|
||||
val downloadFile = mediaPlayerController.currentPlayingLegacy
|
||||
|
||||
if (downloadFile != null) {
|
||||
currentSong = downloadFile.track
|
||||
@ -631,7 +638,7 @@ class PlayerFragment :
|
||||
return true
|
||||
}
|
||||
R.id.menu_shuffle -> {
|
||||
mediaPlayerController.shuffle()
|
||||
mediaPlayerController.toggleShuffle()
|
||||
Util.toast(context, R.string.download_menu_shuffle_notification)
|
||||
return true
|
||||
}
|
||||
@ -768,10 +775,10 @@ class PlayerFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun update(cancel: CancellationToken?) {
|
||||
if (cancel!!.isCancellationRequested) return
|
||||
private fun update(cancel: CancellationToken? = null) {
|
||||
if (cancel?.isCancellationRequested == true) return
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
if (currentPlaying != mediaPlayerController.currentPlaying) {
|
||||
if (currentPlaying != mediaPlayerController.currentPlayingLegacy) {
|
||||
onCurrentChanged()
|
||||
}
|
||||
onSliderProgressChanged()
|
||||
@ -822,23 +829,6 @@ class PlayerFragment :
|
||||
scrollToCurrent()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
val service = mediaPlayerController
|
||||
val state = service.playerState
|
||||
if (state === PlayerState.PAUSED ||
|
||||
state === PlayerState.COMPLETED || state === PlayerState.STOPPED
|
||||
) {
|
||||
service.start()
|
||||
} else if (state === PlayerState.IDLE) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
val current = mediaPlayerController.currentPlayingNumberOnPlaylist
|
||||
if (current == -1) {
|
||||
service.play(0)
|
||||
} else {
|
||||
service.play(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initPlaylistDisplay() {
|
||||
// Create a View Manager
|
||||
@ -852,17 +842,17 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
// Create listener
|
||||
val listener: ((DownloadFile) -> Unit) = { file ->
|
||||
val list = mediaPlayerController.playList
|
||||
val index = list.indexOf(file)
|
||||
mediaPlayerController.play(index)
|
||||
val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos ->
|
||||
mediaPlayerController.seekTo(pos, 0)
|
||||
mediaPlayerController.prepare()
|
||||
mediaPlayerController.play()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
|
||||
viewAdapter.register(
|
||||
TrackViewBinder(
|
||||
onItemClick = listener,
|
||||
onItemClick = clickHandler,
|
||||
checkable = false,
|
||||
draggable = true,
|
||||
context = requireContext(),
|
||||
@ -879,62 +869,63 @@ class PlayerFragment :
|
||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||
) {
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
|
||||
val from = viewHolder.bindingAdapterPosition
|
||||
val to = target.bindingAdapterPosition
|
||||
val from = viewHolder.bindingAdapterPosition
|
||||
val to = target.bindingAdapterPosition
|
||||
|
||||
// Move it in the data set
|
||||
mediaPlayerController.moveItemInPlaylist(from, to)
|
||||
viewAdapter.submitList(mediaPlayerController.playList)
|
||||
// Move it in the data set
|
||||
mediaPlayerController.moveItemInPlaylist(from, to)
|
||||
viewAdapter.submitList(mediaPlayerController.playList)
|
||||
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Swipe to delete from playlist
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val pos = viewHolder.bindingAdapterPosition
|
||||
val file = mediaPlayerController.playList[pos]
|
||||
mediaPlayerController.removeFromPlaylist(file)
|
||||
// Swipe to delete from playlist
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val pos = viewHolder.bindingAdapterPosition
|
||||
val file = mediaPlayerController.playList[pos]
|
||||
mediaPlayerController.removeFromPlaylist(file)
|
||||
|
||||
val songRemoved = String.format(
|
||||
resources.getString(R.string.download_song_removed),
|
||||
file.track.title
|
||||
)
|
||||
Util.toast(context, songRemoved)
|
||||
val songRemoved = String.format(
|
||||
resources.getString(R.string.download_song_removed),
|
||||
file.track.title
|
||||
)
|
||||
Util.toast(context, songRemoved)
|
||||
|
||||
viewAdapter.submitList(mediaPlayerController.playList)
|
||||
viewAdapter.notifyDataSetChanged()
|
||||
}
|
||||
viewAdapter.submitList(mediaPlayerController.playList)
|
||||
viewAdapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onSelectedChanged(
|
||||
viewHolder: RecyclerView.ViewHolder?,
|
||||
actionState: Int
|
||||
) {
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
override fun onSelectedChanged(
|
||||
viewHolder: RecyclerView.ViewHolder?,
|
||||
actionState: Int
|
||||
) {
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
|
||||
if (actionState == ACTION_STATE_DRAG) {
|
||||
viewHolder?.itemView?.alpha = 0.6f
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearView(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
|
||||
viewHolder.itemView.alpha = 1.0f
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean {
|
||||
return false
|
||||
if (actionState == ACTION_STATE_DRAG) {
|
||||
viewHolder?.itemView?.alpha = 0.6f
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearView(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
|
||||
viewHolder.itemView.alpha = 1.0f
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
dragTouchHelper.attachToRecyclerView(playlistView)
|
||||
@ -950,32 +941,33 @@ class PlayerFragment :
|
||||
emptyTextView.isVisible = list.isEmpty()
|
||||
|
||||
when (mediaPlayerController.repeatMode) {
|
||||
RepeatMode.OFF -> repeatButton.setImageDrawable(
|
||||
0 -> repeatButton.setImageDrawable(
|
||||
Util.getDrawableFromAttribute(
|
||||
requireContext(), R.attr.media_repeat_off
|
||||
)
|
||||
)
|
||||
RepeatMode.ALL -> repeatButton.setImageDrawable(
|
||||
Util.getDrawableFromAttribute(
|
||||
requireContext(), R.attr.media_repeat_all
|
||||
)
|
||||
)
|
||||
RepeatMode.SINGLE -> repeatButton.setImageDrawable(
|
||||
1 -> repeatButton.setImageDrawable(
|
||||
Util.getDrawableFromAttribute(
|
||||
requireContext(), R.attr.media_repeat_single
|
||||
)
|
||||
)
|
||||
2 -> repeatButton.setImageDrawable(
|
||||
Util.getDrawableFromAttribute(
|
||||
requireContext(), R.attr.media_repeat_all
|
||||
)
|
||||
)
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCurrentChanged() {
|
||||
currentPlaying = mediaPlayerController.currentPlaying
|
||||
currentPlaying = mediaPlayerController.currentPlayingLegacy
|
||||
|
||||
scrollToCurrent()
|
||||
val totalDuration = mediaPlayerController.playListDuration
|
||||
val totalSongs = mediaPlayerController.playlistSize.toLong()
|
||||
val currentSongIndex = mediaPlayerController.currentPlayingNumberOnPlaylist + 1
|
||||
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
|
||||
val duration = Util.formatTotalDuration(totalDuration)
|
||||
val trackFormat =
|
||||
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
||||
@ -992,7 +984,7 @@ class PlayerFragment :
|
||||
genreTextView.isVisible =
|
||||
(currentSong!!.genre != null && currentSong!!.genre!!.isNotBlank())
|
||||
|
||||
var bitRate: String = ""
|
||||
var bitRate = ""
|
||||
if (currentSong!!.bitRate != null && currentSong!!.bitRate!! > 0)
|
||||
bitRate = String.format(
|
||||
Util.appContext().getString(R.string.song_details_kbps),
|
||||
@ -1027,14 +1019,14 @@ class PlayerFragment :
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "ComplexMethod")
|
||||
@Synchronized
|
||||
private fun onSliderProgressChanged() {
|
||||
|
||||
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
||||
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
||||
val duration: Int = mediaPlayerController.playerDuration
|
||||
val playerState: PlayerState = mediaPlayerController.playerState
|
||||
val playbackState: Int = mediaPlayerController.playbackState
|
||||
val isPlaying = mediaPlayerController.isPlaying
|
||||
|
||||
if (cancellationToken.isCancellationRequested) return
|
||||
if (currentPlaying != null) {
|
||||
@ -1043,7 +1035,7 @@ class PlayerFragment :
|
||||
progressBar.max =
|
||||
if (duration == 0) 100 else duration // Work-around for apparent bug.
|
||||
progressBar.progress = millisPlayed
|
||||
progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled
|
||||
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
|
||||
} else {
|
||||
positionTextView.setText(R.string.util_zero_time)
|
||||
durationTextView.setText(R.string.util_no_time)
|
||||
@ -1052,21 +1044,20 @@ class PlayerFragment :
|
||||
progressBar.isEnabled = false
|
||||
}
|
||||
|
||||
when (playerState) {
|
||||
PlayerState.DOWNLOADING -> {
|
||||
val progress =
|
||||
if (currentPlaying != null) currentPlaying!!.progress.value!! else 0
|
||||
val progress = mediaPlayerController.bufferedPercentage
|
||||
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
|
||||
val downloadStatus = resources.getString(
|
||||
R.string.download_playerstate_downloading,
|
||||
Util.formatPercentage(progress)
|
||||
)
|
||||
progressBar.secondaryProgress = progress
|
||||
setTitle(this@PlayerFragment, downloadStatus)
|
||||
}
|
||||
PlayerState.PREPARING -> setTitle(
|
||||
this@PlayerFragment,
|
||||
R.string.download_playerstate_buffering
|
||||
)
|
||||
PlayerState.STARTED -> {
|
||||
Player.STATE_READY -> {
|
||||
progressBar.secondaryProgress = progress
|
||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
||||
setTitle(
|
||||
this@PlayerFragment,
|
||||
@ -1076,30 +1067,28 @@ class PlayerFragment :
|
||||
setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
}
|
||||
PlayerState.IDLE,
|
||||
PlayerState.PREPARED,
|
||||
PlayerState.STOPPED,
|
||||
PlayerState.PAUSED,
|
||||
PlayerState.COMPLETED -> {
|
||||
Player.STATE_IDLE,
|
||||
Player.STATE_ENDED,
|
||||
-> {
|
||||
}
|
||||
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
|
||||
when (playerState) {
|
||||
PlayerState.STARTED -> {
|
||||
pauseButton.isVisible = true
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
pauseButton.isVisible = isPlaying
|
||||
stopButton.isVisible = false
|
||||
startButton.isVisible = false
|
||||
playButton.isVisible = !isPlaying
|
||||
}
|
||||
PlayerState.DOWNLOADING, PlayerState.PREPARING -> {
|
||||
Player.STATE_BUFFERING -> {
|
||||
pauseButton.isVisible = false
|
||||
stopButton.isVisible = true
|
||||
startButton.isVisible = false
|
||||
playButton.isVisible = false
|
||||
}
|
||||
else -> {
|
||||
pauseButton.isVisible = false
|
||||
stopButton.isVisible = false
|
||||
startButton.isVisible = true
|
||||
playButton.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,7 +109,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
|
||||
viewAdapter.register(
|
||||
TrackViewBinder(
|
||||
onItemClick = ::onItemClick,
|
||||
onItemClick = { file, _ -> onItemClick(file) },
|
||||
onContextMenuClick = ::onContextMenuItemSelected,
|
||||
checkable = false,
|
||||
draggable = false,
|
||||
@ -151,7 +151,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
|
||||
val arguments = arguments
|
||||
val autoPlay = arguments != null &&
|
||||
arguments.getBoolean(Constants.INTENT_AUTOPLAY, false)
|
||||
arguments.getBoolean(Constants.INTENT_AUTOPLAY, false)
|
||||
val query = arguments?.getString(Constants.INTENT_QUERY)
|
||||
|
||||
// If started with a query, enter it to the searchView
|
||||
@ -303,13 +303,12 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
}
|
||||
mediaPlayerController.addToPlaylist(
|
||||
listOf(song),
|
||||
save = false,
|
||||
cachePermanently = false,
|
||||
autoPlay = false,
|
||||
playNext = false,
|
||||
shuffle = false,
|
||||
newPlaylist = false
|
||||
insertionMode = MediaPlayerController.InsertionMode.APPEND
|
||||
)
|
||||
mediaPlayerController.play(mediaPlayerController.playlistSize - 1)
|
||||
mediaPlayerController.play(mediaPlayerController.mediaItemCount - 1)
|
||||
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ class ServerSelectorFragment : Fragment() {
|
||||
|
||||
private var listView: ListView? = null
|
||||
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
||||
private val service: MediaPlayerController by inject()
|
||||
private val controller: MediaPlayerController by inject()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private var serverRowAdapter: ServerRowAdapter? = null
|
||||
|
||||
@ -117,14 +117,14 @@ class ServerSelectorFragment : Fragment() {
|
||||
// TODO this is still a blocking call - we shouldn't leave this activity before the active server is updated.
|
||||
// Maybe this can be refactored by using LiveData, or this can be made more user friendly with a ProgressDialog
|
||||
runBlocking {
|
||||
controller.clearIncomplete()
|
||||
withContext(Dispatchers.IO) {
|
||||
if (activeServerProvider.getActiveServer().index != index) {
|
||||
service.clearIncomplete()
|
||||
activeServerProvider.setActiveServerByIndex(index)
|
||||
service.isJukeboxEnabled =
|
||||
activeServerProvider.getActiveServer().jukeboxByDefault
|
||||
}
|
||||
}
|
||||
controller.isJukeboxEnabled =
|
||||
activeServerProvider.getActiveServer().jukeboxByDefault
|
||||
}
|
||||
Timber.i("Active server was set to: $index")
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import androidx.preference.PreferenceFragmentCompat
|
||||
import java.io.File
|
||||
import kotlin.math.ceil
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
@ -40,7 +40,6 @@ import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.ErrorDialog
|
||||
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
||||
import org.moire.ultrasonic.util.InfoDialog
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Settings.preferences
|
||||
import org.moire.ultrasonic.util.Settings.shareGreeting
|
||||
@ -89,12 +88,7 @@ class SettingsFragment :
|
||||
private var debugLogToFile: CheckBoxPreference? = null
|
||||
private var customCacheLocation: CheckBoxPreference? = null
|
||||
|
||||
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
|
||||
MediaPlayerController::class.java
|
||||
)
|
||||
private val mediaSessionHandler = inject<MediaSessionHandler>(
|
||||
MediaSessionHandler::class.java
|
||||
)
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings, rootKey)
|
||||
@ -221,9 +215,6 @@ class SettingsFragment :
|
||||
Constants.PREFERENCES_KEY_HIDE_MEDIA -> {
|
||||
setHideMedia(sharedPreferences.getBoolean(key, false))
|
||||
}
|
||||
Constants.PREFERENCES_KEY_MEDIA_BUTTONS -> {
|
||||
setMediaButtonsEnabled(sharedPreferences.getBoolean(key, true))
|
||||
}
|
||||
Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS -> {
|
||||
setBluetoothPreferences(sharedPreferences.getBoolean(key, true))
|
||||
}
|
||||
@ -433,11 +424,6 @@ class SettingsFragment :
|
||||
toast(activity, R.string.settings_hide_media_toast, false)
|
||||
}
|
||||
|
||||
private fun setMediaButtonsEnabled(enabled: Boolean) {
|
||||
lockScreenEnabled!!.isEnabled = enabled
|
||||
mediaSessionHandler.value.updateMediaButtonReceiver()
|
||||
}
|
||||
|
||||
private fun setBluetoothPreferences(enabled: Boolean) {
|
||||
sendBluetoothAlbumArt!!.isEnabled = enabled
|
||||
}
|
||||
@ -451,8 +437,8 @@ class SettingsFragment :
|
||||
Settings.cacheLocationUri = path
|
||||
|
||||
// Clear download queue.
|
||||
mediaPlayerControllerLazy.value.clear()
|
||||
mediaPlayerControllerLazy.value.clearCaches()
|
||||
mediaPlayerController.clear()
|
||||
mediaPlayerController.clearCaches()
|
||||
Storage.reset()
|
||||
}
|
||||
|
||||
|
@ -122,7 +122,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
||||
|
||||
viewAdapter.register(
|
||||
TrackViewBinder(
|
||||
onItemClick = { onItemClick(it.track) },
|
||||
onItemClick = { file, _ -> onItemClick(file.track) },
|
||||
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
|
||||
checkable = true,
|
||||
draggable = false,
|
||||
|
@ -0,0 +1,398 @@
|
||||
/*
|
||||
* APIDataSource.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaLibraryInfo
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.util.Assertions
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.datasource.BaseDataSource
|
||||
import androidx.media3.datasource.DataSourceException
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
|
||||
import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException
|
||||
import androidx.media3.datasource.HttpDataSource.RequestProperties
|
||||
import androidx.media3.datasource.HttpUtil
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import com.google.common.net.HttpHeaders
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Call
|
||||
import okhttp3.ResponseBody
|
||||
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
|
||||
import org.moire.ultrasonic.api.subsonic.response.StreamResponse
|
||||
import org.moire.ultrasonic.api.subsonic.throwOnFailure
|
||||
import org.moire.ultrasonic.api.subsonic.toStreamResponse
|
||||
import org.moire.ultrasonic.util.AbstractFile
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.InterruptedIOException
|
||||
|
||||
/**
|
||||
* An [HttpDataSource] that delegates to Square's [Call.Factory].
|
||||
*
|
||||
*
|
||||
* Note: HTTP request headers will be set using all parameters passed via (in order of decreasing
|
||||
* priority) the `dataSpec`, [.setRequestProperty] and the default parameters used to
|
||||
* construct the instance.
|
||||
*/
|
||||
@UnstableApi
|
||||
open class OkHttpDataSource private constructor(
|
||||
subsonicAPIClient: SubsonicAPIClient,
|
||||
userAgent: String?,
|
||||
cacheControl: CacheControl?,
|
||||
defaultRequestProperties: RequestProperties?
|
||||
) : BaseDataSource(true),
|
||||
HttpDataSource {
|
||||
companion object {
|
||||
init {
|
||||
MediaLibraryInfo.registerModule("media3.datasource.okhttp")
|
||||
}
|
||||
}
|
||||
|
||||
/** [DataSource.Factory] for [OkHttpDataSource] instances. */
|
||||
class Factory(private val subsonicAPIClient: SubsonicAPIClient) : HttpDataSource.Factory {
|
||||
private val defaultRequestProperties: RequestProperties = RequestProperties()
|
||||
private var userAgent: String? = null
|
||||
private var transferListener: TransferListener? = null
|
||||
private var cacheControl: CacheControl? = null
|
||||
|
||||
override fun setDefaultRequestProperties(defaultRequestProperties: Map<String, String>): Factory {
|
||||
this.defaultRequestProperties.clearAndSet(defaultRequestProperties)
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sets the [TransferListener] that will be used.
|
||||
*
|
||||
*
|
||||
* The default is `null`.
|
||||
*
|
||||
*
|
||||
* See [DataSource.addTransferListener].
|
||||
*
|
||||
* @param transferListener The listener that will be used.
|
||||
* @return This factory.
|
||||
*/
|
||||
fun setTransferListener(transferListener: TransferListener?): Factory {
|
||||
this.transferListener = transferListener
|
||||
return this
|
||||
}
|
||||
|
||||
override fun createDataSource(): OkHttpDataSource {
|
||||
val dataSource = OkHttpDataSource(
|
||||
subsonicAPIClient,
|
||||
userAgent,
|
||||
cacheControl,
|
||||
defaultRequestProperties
|
||||
)
|
||||
if (transferListener != null) {
|
||||
dataSource.addTransferListener(transferListener!!)
|
||||
}
|
||||
return dataSource
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private val subsonicAPIClient: SubsonicAPIClient = Assertions.checkNotNull(subsonicAPIClient)
|
||||
private val requestProperties: RequestProperties
|
||||
private val userAgent: String?
|
||||
private val cacheControl: CacheControl?
|
||||
private val defaultRequestProperties: RequestProperties?
|
||||
private var dataSpec: DataSpec? = null
|
||||
private var response: retrofit2.Response<ResponseBody>? = null
|
||||
private var responseByteStream: InputStream? = null
|
||||
private var openedNetwork = false
|
||||
private var openedFile = false
|
||||
private var cachePath: String? = null
|
||||
private var cacheFile: AbstractFile? = null
|
||||
private var bytesToRead: Long = 0
|
||||
private var bytesRead: Long = 0
|
||||
|
||||
override fun getUri(): Uri? {
|
||||
return when {
|
||||
cachePath != null -> cachePath!!.toUri()
|
||||
response == null -> null
|
||||
else -> response!!.raw().request.url.toString().toUri()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getResponseCode(): Int {
|
||||
return if (response == null) -1 else response!!.code()
|
||||
}
|
||||
|
||||
override fun getResponseHeaders(): Map<String, List<String>> {
|
||||
return if (response == null) emptyMap() else response!!.headers().toMultimap()
|
||||
}
|
||||
|
||||
override fun setRequestProperty(name: String, value: String) {
|
||||
Assertions.checkNotNull(name)
|
||||
Assertions.checkNotNull(value)
|
||||
requestProperties[name] = value
|
||||
}
|
||||
|
||||
override fun clearRequestProperty(name: String) {
|
||||
Assertions.checkNotNull(name)
|
||||
requestProperties.remove(name)
|
||||
}
|
||||
|
||||
override fun clearAllRequestProperties() {
|
||||
requestProperties.clear()
|
||||
}
|
||||
|
||||
@Throws(HttpDataSourceException::class)
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
this.dataSpec = dataSpec
|
||||
bytesRead = 0
|
||||
bytesToRead = 0
|
||||
|
||||
transferInitializing(dataSpec)
|
||||
val components = dataSpec.uri.toString().split('|')
|
||||
val id = components[0]
|
||||
val bitrate = components[1].toInt()
|
||||
val path = components[2]
|
||||
|
||||
val cacheLength = checkCache(path)
|
||||
|
||||
// We have found an item in the cache, return early
|
||||
if (cacheLength > 0) {
|
||||
bytesToRead = cacheLength
|
||||
return bytesToRead
|
||||
}
|
||||
|
||||
Timber.i("DATASOURCE: %s", "Start")
|
||||
val request = subsonicAPIClient.api.stream(id, bitrate, offset = 0)
|
||||
val response: retrofit2.Response<ResponseBody>?
|
||||
val streamResponse: StreamResponse
|
||||
Timber.i("DATASOURCE: %s", "Start2")
|
||||
try {
|
||||
this.response = request.execute()
|
||||
Timber.i("DATASOURCE: %s", "Start3")
|
||||
response = this.response
|
||||
streamResponse = response!!.toStreamResponse()
|
||||
Timber.i("DATASOURCE: %s", "Start4")
|
||||
responseByteStream = streamResponse.stream
|
||||
Timber.i("DATASOURCE: %s", "Start5")
|
||||
} catch (e: IOException) {
|
||||
throw HttpDataSourceException.createForIOException(
|
||||
e, dataSpec, HttpDataSourceException.TYPE_OPEN
|
||||
)
|
||||
}
|
||||
|
||||
streamResponse.throwOnFailure()
|
||||
|
||||
val responseCode = response.code()
|
||||
|
||||
// Check for a valid response code.
|
||||
if (!response.isSuccessful) {
|
||||
if (responseCode == 416) {
|
||||
val documentSize =
|
||||
HttpUtil.getDocumentSize(response.headers()[HttpHeaders.CONTENT_RANGE])
|
||||
if (dataSpec.position == documentSize) {
|
||||
openedNetwork = true
|
||||
transferStarted(dataSpec)
|
||||
return if (dataSpec.length != C.LENGTH_UNSET.toLong()) dataSpec.length else 0
|
||||
}
|
||||
}
|
||||
val errorResponseBody: ByteArray = try {
|
||||
Util.toByteArray(Assertions.checkNotNull(responseByteStream))
|
||||
} catch (e: IOException) {
|
||||
Util.EMPTY_BYTE_ARRAY
|
||||
}
|
||||
val headers = response.headers().toMultimap()
|
||||
closeConnectionQuietly()
|
||||
val cause: IOException? =
|
||||
if (responseCode == 416) DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) else null
|
||||
throw InvalidResponseCodeException(
|
||||
responseCode, response.message(), cause, headers, dataSpec, errorResponseBody
|
||||
)
|
||||
}
|
||||
|
||||
Timber.i("DATASOURCE: %s", "Start6")
|
||||
|
||||
// If we requested a range starting from a non-zero position and received a 200 rather than a
|
||||
// 206, then the server does not support partial requests. We'll need to manually skip to the
|
||||
// requested position.
|
||||
val bytesToSkip =
|
||||
if (responseCode == 200 && dataSpec.position != 0L) dataSpec.position else 0
|
||||
|
||||
// Determine the length of the data to be read, after skipping.
|
||||
bytesToRead = if (dataSpec.length != C.LENGTH_UNSET.toLong()) {
|
||||
dataSpec.length
|
||||
} else {
|
||||
val contentLength = response.body()!!.contentLength()
|
||||
if (contentLength != -1L) contentLength - bytesToSkip else C.LENGTH_UNSET.toLong()
|
||||
}
|
||||
openedNetwork = true
|
||||
transferStarted(dataSpec)
|
||||
try {
|
||||
skipFully(bytesToSkip, dataSpec)
|
||||
} catch (e: HttpDataSourceException) {
|
||||
closeConnectionQuietly()
|
||||
throw e
|
||||
}
|
||||
Timber.i("DATASOURCE: %s", "Start7")
|
||||
|
||||
return bytesToRead
|
||||
}
|
||||
|
||||
@Throws(HttpDataSourceException::class)
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
return try {
|
||||
readInternal(buffer, offset, length)
|
||||
} catch (e: IOException) {
|
||||
throw HttpDataSourceException.createForIOException(
|
||||
e, Util.castNonNull(dataSpec), HttpDataSourceException.TYPE_READ
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (openedNetwork) {
|
||||
openedNetwork = false
|
||||
transferEnded()
|
||||
closeConnectionQuietly()
|
||||
} else if (openedFile) {
|
||||
openedFile = false
|
||||
responseByteStream?.close()
|
||||
responseByteStream = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks our cache for a matching media file
|
||||
*/
|
||||
private fun checkCache(path: String): Long {
|
||||
var filePath: String = path
|
||||
var found = Storage.isPathExists(path)
|
||||
|
||||
if (!found) {
|
||||
filePath = FileUtil.getCompleteFile(path)
|
||||
found = Storage.isPathExists(filePath)
|
||||
}
|
||||
|
||||
if (!found) return -1
|
||||
|
||||
cachePath = filePath
|
||||
|
||||
cacheFile = Storage.getFromPath(filePath)!!
|
||||
responseByteStream = cacheFile!!.getFileInputStream()
|
||||
|
||||
return cacheFile!!.getDocumentFileDescriptor("r")!!.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to skip the specified number of bytes in full.
|
||||
*
|
||||
* @param bytesToSkip The number of bytes to skip.
|
||||
* @param dataSpec The [DataSpec].
|
||||
* @throws HttpDataSourceException If the thread is interrupted during the operation, or an error
|
||||
* occurs while reading from the source, or if the data ended before skipping the specified
|
||||
* number of bytes.
|
||||
*/
|
||||
@Throws(HttpDataSourceException::class)
|
||||
private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) {
|
||||
var bytesToSkip = bytesToSkip
|
||||
if (bytesToSkip == 0L) {
|
||||
return
|
||||
}
|
||||
val skipBuffer = ByteArray(4096)
|
||||
try {
|
||||
while (bytesToSkip > 0) {
|
||||
val readLength =
|
||||
bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt()
|
||||
val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength)
|
||||
if (Thread.currentThread().isInterrupted) {
|
||||
throw InterruptedIOException()
|
||||
}
|
||||
if (read == -1) {
|
||||
throw HttpDataSourceException(
|
||||
dataSpec,
|
||||
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
||||
HttpDataSourceException.TYPE_OPEN
|
||||
)
|
||||
}
|
||||
bytesToSkip -= read.toLong()
|
||||
bytesTransferred(read)
|
||||
}
|
||||
return
|
||||
} catch (e: IOException) {
|
||||
if (e is HttpDataSourceException) {
|
||||
throw e
|
||||
} else {
|
||||
throw HttpDataSourceException(
|
||||
dataSpec,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
HttpDataSourceException.TYPE_OPEN
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads up to `length` bytes of data and stores them into `buffer`, starting at index
|
||||
* `offset`.
|
||||
*
|
||||
*
|
||||
* This method blocks until at least one byte of data can be read, the end of the opened range
|
||||
* is detected, or an exception is thrown.
|
||||
*
|
||||
* @param buffer The buffer into which the read data should be stored.
|
||||
* @param offset The start offset into `buffer` at which data should be written.
|
||||
* @param readLength The maximum number of bytes to read.
|
||||
* @return The number of bytes read, or [C.RESULT_END_OF_INPUT] if the end of the opened
|
||||
* range is reached.
|
||||
* @throws IOException If an error occurs reading from the source.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||
var readLength = readLength
|
||||
if (readLength == 0) {
|
||||
return 0
|
||||
}
|
||||
if (bytesToRead != C.LENGTH_UNSET.toLong()) {
|
||||
val bytesRemaining = bytesToRead - bytesRead
|
||||
if (bytesRemaining == 0L) {
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt()
|
||||
}
|
||||
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength)
|
||||
if (read == -1) {
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
bytesRead += read.toLong()
|
||||
// bytesTransferred(read)
|
||||
return read
|
||||
}
|
||||
|
||||
/** Closes the current connection quietly, if there is one. */
|
||||
private fun closeConnectionQuietly() {
|
||||
if (response != null) {
|
||||
Assertions.checkNotNull(response!!.body()).close()
|
||||
response = null
|
||||
}
|
||||
responseByteStream = null
|
||||
}
|
||||
|
||||
init {
|
||||
this.userAgent = userAgent
|
||||
this.cacheControl = cacheControl
|
||||
this.defaultRequestProperties = defaultRequestProperties
|
||||
requestProperties = RequestProperties()
|
||||
}
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
/*
|
||||
* LegacyPlaylist.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.session.MediaController
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.DownloadFile
|
||||
import org.moire.ultrasonic.service.Downloader
|
||||
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.util.LRUCache
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class keeps a legacy playlist maintained which
|
||||
* reflects the internal timeline of the Media3.Player
|
||||
*/
|
||||
class LegacyPlaylistManager : KoinComponent {
|
||||
|
||||
private val _playlist = mutableListOf<DownloadFile>()
|
||||
|
||||
@JvmField
|
||||
var currentPlaying: DownloadFile? = null
|
||||
|
||||
private val mediaItemCache = LRUCache<String, DownloadFile>(1000)
|
||||
|
||||
val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
||||
val downloader: Downloader by inject()
|
||||
|
||||
private var playlistUpdateRevision: Long = 0
|
||||
private set(value) {
|
||||
field = value
|
||||
RxBus.playlistPublisher.onNext(_playlist)
|
||||
}
|
||||
|
||||
fun rebuildPlaylist(controller: MediaController) {
|
||||
_playlist.clear()
|
||||
|
||||
val n = controller.mediaItemCount
|
||||
|
||||
for (i in 0 until n) {
|
||||
val item = controller.getMediaItemAt(i)
|
||||
val file = mediaItemCache[item.mediaMetadata.mediaUri.toString()]
|
||||
if (file != null)
|
||||
_playlist.add(file)
|
||||
}
|
||||
|
||||
playlistUpdateRevision++
|
||||
}
|
||||
|
||||
fun addToCache(item: MediaItem, file: DownloadFile) {
|
||||
mediaItemCache.put(item.mediaMetadata.mediaUri.toString(), file)
|
||||
}
|
||||
|
||||
fun updateCurrentPlaying(item: MediaItem?) {
|
||||
currentPlaying = mediaItemCache[item?.mediaMetadata?.mediaUri.toString()]
|
||||
}
|
||||
|
||||
@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 clearPlaylist() {
|
||||
_playlist.clear()
|
||||
playlistUpdateRevision++
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
clearPlaylist()
|
||||
Timber.i("PlaylistManager destroyed")
|
||||
}
|
||||
|
||||
// Public facing playlist (immutable)
|
||||
val playlist: List<DownloadFile>
|
||||
get() = _playlist
|
||||
|
||||
// FIXME: Returns wrong count if item is twice in queue
|
||||
@get:Synchronized
|
||||
val currentPlayingIndex: Int
|
||||
get() = _playlist.indexOf(currentPlaying)
|
||||
|
||||
@get:Synchronized
|
||||
val playlistDuration: Long
|
||||
get() {
|
||||
var totalDuration: Long = 0
|
||||
for (downloadFile in _playlist) {
|
||||
val song = downloadFile.track
|
||||
if (!song.isDirectory) {
|
||||
if (song.artist != null) {
|
||||
if (song.duration != null) {
|
||||
totalDuration += song.duration!!.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalDuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function
|
||||
* Gathers the download file for a given song, and modifies shouldSave if provided.
|
||||
*/
|
||||
fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
|
||||
return downloader.getDownloadFileForSong(this).apply {
|
||||
if (save != null) this.shouldSave = save
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import android.content.res.AssetManager
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
|
||||
import androidx.media3.common.util.Util
|
||||
import com.google.common.collect.ImmutableList
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A sample media catalog that represents media items as a tree.
|
||||
*
|
||||
* It fetched the data from {@code catalog.json}. The root's children are folders containing media
|
||||
* items from the same album/artist/genre.
|
||||
*
|
||||
* Each app should have their own way of representing the tree. MediaItemTree is used for
|
||||
* demonstration purpose only.
|
||||
*/
|
||||
object MediaItemTree {
|
||||
private var treeNodes: MutableMap<String, MediaItemNode> = mutableMapOf()
|
||||
private var titleMap: MutableMap<String, MediaItemNode> = mutableMapOf()
|
||||
private var isInitialized = false
|
||||
private const val ROOT_ID = "[rootID]"
|
||||
private const val ALBUM_ID = "[albumID]"
|
||||
private const val GENRE_ID = "[genreID]"
|
||||
private const val ARTIST_ID = "[artistID]"
|
||||
private const val ALBUM_PREFIX = "[album]"
|
||||
private const val GENRE_PREFIX = "[genre]"
|
||||
private const val ARTIST_PREFIX = "[artist]"
|
||||
private const val ITEM_PREFIX = "[item]"
|
||||
|
||||
private class MediaItemNode(val item: MediaItem) {
|
||||
private val children: MutableList<MediaItem> = ArrayList()
|
||||
|
||||
fun addChild(childID: String) {
|
||||
this.children.add(treeNodes[childID]!!.item)
|
||||
}
|
||||
|
||||
fun getChildren(): List<MediaItem> {
|
||||
return ImmutableList.copyOf(children)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMediaItem(
|
||||
title: String,
|
||||
mediaId: String,
|
||||
isPlayable: Boolean,
|
||||
@MediaMetadata.FolderType folderType: Int,
|
||||
album: String? = null,
|
||||
artist: String? = null,
|
||||
genre: String? = null,
|
||||
sourceUri: Uri? = null,
|
||||
imageUri: Uri? = null,
|
||||
): MediaItem {
|
||||
// TODO(b/194280027): add artwork
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setAlbumTitle(album)
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setGenre(genre)
|
||||
.setFolderType(folderType)
|
||||
.setIsPlayable(isPlayable)
|
||||
.setArtworkUri(imageUri)
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setUri(sourceUri)
|
||||
.build()
|
||||
}
|
||||
|
||||
@androidx.media3.common.util.UnstableApi
|
||||
private fun loadJSONFromAsset(assets: AssetManager): String {
|
||||
val buffer = assets.open("catalog.json").use { Util.toByteArray(it) }
|
||||
return String(buffer, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun initialize(assets: AssetManager) {
|
||||
if (isInitialized) return
|
||||
isInitialized = true
|
||||
// create root and folders for album/artist/genre.
|
||||
treeNodes[ROOT_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Root Folder",
|
||||
mediaId = ROOT_ID,
|
||||
isPlayable = false,
|
||||
folderType = FOLDER_TYPE_MIXED
|
||||
)
|
||||
)
|
||||
treeNodes[ALBUM_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Album Folder",
|
||||
mediaId = ALBUM_ID,
|
||||
isPlayable = false,
|
||||
folderType = FOLDER_TYPE_MIXED
|
||||
)
|
||||
)
|
||||
treeNodes[ARTIST_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Artist Folder",
|
||||
mediaId = ARTIST_ID,
|
||||
isPlayable = false,
|
||||
folderType = FOLDER_TYPE_MIXED
|
||||
)
|
||||
)
|
||||
treeNodes[GENRE_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Genre Folder",
|
||||
mediaId = GENRE_ID,
|
||||
isPlayable = false,
|
||||
folderType = FOLDER_TYPE_MIXED
|
||||
)
|
||||
)
|
||||
treeNodes[ROOT_ID]!!.addChild(ALBUM_ID)
|
||||
treeNodes[ROOT_ID]!!.addChild(ARTIST_ID)
|
||||
treeNodes[ROOT_ID]!!.addChild(GENRE_ID)
|
||||
|
||||
// Here, parse the json file in asset for media list.
|
||||
// We use a file in asset for demo purpose
|
||||
// val jsonObject = JSONObject(loadJSONFromAsset(assets))
|
||||
// val mediaList = jsonObject.getJSONArray("media")
|
||||
//
|
||||
// // create subfolder with same artist, album, etc.
|
||||
// for (i in 0 until mediaList.length()) {
|
||||
// addNodeToTree(mediaList.getJSONObject(i))
|
||||
// }
|
||||
}
|
||||
|
||||
private fun addNodeToTree(mediaObject: JSONObject) {
|
||||
|
||||
val id = mediaObject.getString("id")
|
||||
val album = mediaObject.getString("album")
|
||||
val title = mediaObject.getString("title")
|
||||
val artist = mediaObject.getString("artist")
|
||||
val genre = mediaObject.getString("genre")
|
||||
val sourceUri = Uri.parse(mediaObject.getString("source"))
|
||||
val imageUri = Uri.parse(mediaObject.getString("image"))
|
||||
// key of such items in tree
|
||||
val idInTree = ITEM_PREFIX + id
|
||||
val albumFolderIdInTree = ALBUM_PREFIX + album
|
||||
val artistFolderIdInTree = ARTIST_PREFIX + artist
|
||||
val genreFolderIdInTree = GENRE_PREFIX + genre
|
||||
|
||||
treeNodes[idInTree] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = title,
|
||||
mediaId = idInTree,
|
||||
isPlayable = true,
|
||||
album = album,
|
||||
artist = artist,
|
||||
genre = genre,
|
||||
sourceUri = sourceUri,
|
||||
imageUri = imageUri,
|
||||
folderType = FOLDER_TYPE_NONE
|
||||
)
|
||||
)
|
||||
|
||||
titleMap[title.lowercase()] = treeNodes[idInTree]!!
|
||||
|
||||
if (!treeNodes.containsKey(albumFolderIdInTree)) {
|
||||
treeNodes[albumFolderIdInTree] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = album,
|
||||
mediaId = albumFolderIdInTree,
|
||||
isPlayable = true,
|
||||
folderType = FOLDER_TYPE_PLAYLISTS
|
||||
)
|
||||
)
|
||||
treeNodes[ALBUM_ID]!!.addChild(albumFolderIdInTree)
|
||||
}
|
||||
treeNodes[albumFolderIdInTree]!!.addChild(idInTree)
|
||||
|
||||
// add into artist folder
|
||||
if (!treeNodes.containsKey(artistFolderIdInTree)) {
|
||||
treeNodes[artistFolderIdInTree] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = artist,
|
||||
mediaId = artistFolderIdInTree,
|
||||
isPlayable = true,
|
||||
folderType = FOLDER_TYPE_PLAYLISTS
|
||||
)
|
||||
)
|
||||
treeNodes[ARTIST_ID]!!.addChild(artistFolderIdInTree)
|
||||
}
|
||||
treeNodes[artistFolderIdInTree]!!.addChild(idInTree)
|
||||
|
||||
// add into genre folder
|
||||
if (!treeNodes.containsKey(genreFolderIdInTree)) {
|
||||
treeNodes[genreFolderIdInTree] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = genre,
|
||||
mediaId = genreFolderIdInTree,
|
||||
isPlayable = true,
|
||||
folderType = FOLDER_TYPE_PLAYLISTS
|
||||
)
|
||||
)
|
||||
treeNodes[GENRE_ID]!!.addChild(genreFolderIdInTree)
|
||||
}
|
||||
treeNodes[genreFolderIdInTree]!!.addChild(idInTree)
|
||||
}
|
||||
|
||||
fun getItem(id: String): MediaItem? {
|
||||
return treeNodes[id]?.item
|
||||
}
|
||||
|
||||
fun getRootItem(): MediaItem {
|
||||
return treeNodes[ROOT_ID]!!.item
|
||||
}
|
||||
|
||||
fun getChildren(id: String): List<MediaItem>? {
|
||||
return treeNodes[id]?.getChildren()
|
||||
}
|
||||
|
||||
fun getRandomItem(): MediaItem {
|
||||
var curRoot = getRootItem()
|
||||
while (curRoot.mediaMetadata.folderType != FOLDER_TYPE_NONE) {
|
||||
val children = getChildren(curRoot.mediaId)!!
|
||||
curRoot = children.random()
|
||||
}
|
||||
return curRoot
|
||||
}
|
||||
|
||||
fun getItemFromTitle(title: String): MediaItem? {
|
||||
return titleMap[title]?.item
|
||||
}
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
/*
|
||||
* MediaNotificationProvider.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.Assertions
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaNotification.ActionFactory
|
||||
import org.moire.ultrasonic.R
|
||||
|
||||
/*
|
||||
* This is a copy of DefaultMediaNotificationProvider.java with some small changes
|
||||
* I have opened a bug https://github.com/androidx/media/issues/65 to make it easier to customize
|
||||
* the icons and actions without creating our own copy of this class..
|
||||
*/
|
||||
@UnstableApi
|
||||
/* package */
|
||||
internal class MediaNotificationProvider(context: Context) :
|
||||
MediaNotification.Provider {
|
||||
private val context: Context = context.applicationContext
|
||||
private val notificationManager: NotificationManager = Assertions.checkStateNotNull(
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
)
|
||||
|
||||
override fun createNotification(
|
||||
mediaController: MediaController,
|
||||
actionFactory: ActionFactory,
|
||||
onNotificationChangedCallback: MediaNotification.Provider.Callback
|
||||
): MediaNotification {
|
||||
ensureNotificationChannel()
|
||||
val builder: NotificationCompat.Builder = NotificationCompat.Builder(
|
||||
context,
|
||||
NOTIFICATION_CHANNEL_ID
|
||||
)
|
||||
// TODO(b/193193926): Filter actions depending on the player's available commands.
|
||||
// Skip to previous action.
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
IconCompat.createWithResource(
|
||||
context,
|
||||
R.drawable.media3_notification_seek_to_previous
|
||||
),
|
||||
context.getString(R.string.media3_controls_seek_to_previous_description),
|
||||
ActionFactory.COMMAND_SKIP_TO_PREVIOUS
|
||||
)
|
||||
)
|
||||
if (mediaController.playbackState == Player.STATE_ENDED
|
||||
|| !mediaController.playWhenReady
|
||||
) {
|
||||
// Play action.
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
IconCompat.createWithResource(context, R.drawable.media3_notification_play),
|
||||
context.getString(R.string.media3_controls_play_description),
|
||||
ActionFactory.COMMAND_PLAY
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Pause action.
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
IconCompat.createWithResource(context, R.drawable.media3_notification_pause),
|
||||
context.getString(R.string.media3_controls_pause_description),
|
||||
ActionFactory.COMMAND_PAUSE
|
||||
)
|
||||
)
|
||||
}
|
||||
// Skip to next action.
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next),
|
||||
context.getString(R.string.media3_controls_seek_to_next_description),
|
||||
ActionFactory.COMMAND_SKIP_TO_NEXT
|
||||
)
|
||||
)
|
||||
|
||||
// Set metadata info in the notification.
|
||||
val metadata = mediaController.mediaMetadata
|
||||
builder.setContentTitle(metadata.title).setContentText(metadata.artist)
|
||||
if (metadata.artworkData != null) {
|
||||
val artworkBitmap =
|
||||
BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData!!.size)
|
||||
builder.setLargeIcon(artworkBitmap)
|
||||
}
|
||||
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setCancelButtonIntent(
|
||||
actionFactory.createMediaActionPendingIntent(
|
||||
ActionFactory.COMMAND_STOP
|
||||
)
|
||||
)
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
val notification: Notification = builder
|
||||
.setContentIntent(mediaController.sessionActivity)
|
||||
.setDeleteIntent(
|
||||
actionFactory.createMediaActionPendingIntent(
|
||||
ActionFactory.COMMAND_STOP
|
||||
)
|
||||
)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSmallIcon(getSmallIconResId())
|
||||
.setStyle(mediaStyle)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(false)
|
||||
.build()
|
||||
return MediaNotification(
|
||||
NOTIFICATION_ID,
|
||||
notification
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleCustomAction(
|
||||
mediaController: MediaController,
|
||||
action: String,
|
||||
extras: Bundle
|
||||
) {
|
||||
// We don't handle custom commands.
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Util.SDK_INT < 26
|
||||
|| notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NOTIFICATION_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val NOTIFICATION_CHANNEL_ID = "default_channel_id"
|
||||
private const val NOTIFICATION_CHANNEL_NAME = "Now playing"
|
||||
private fun getSmallIconResId(): Int {
|
||||
return R.drawable.ic_stat_ultrasonic
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
|
||||
|
||||
|
||||
UI:
|
||||
[x] Display tracks
|
||||
[x] On selection: Translate Tracks to MediaItems
|
||||
[x] Move playlist val to Controller: Keep it around for easier migration!!
|
||||
[x] Also make a LRU Cache to help with translation between MediaItem and DownloadFile
|
||||
[x] Hand MediaItems to Service
|
||||
[] If wanted also hand them to Downloader.kt
|
||||
[x] Service plays MediaItem through OkHttp
|
||||
[x] UI needs to receive info from service
|
||||
[] Create a Cache Layer
|
||||
[] Translate AutoMediaBrowserService
|
||||
[] Add new shuffle icon....
|
||||
|
||||
DownloadNotificationHelper
|
||||
convertToPlaybackStateCompatState()
|
@ -0,0 +1,244 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.C.CONTENT_TYPE_MUSIC
|
||||
import androidx.media3.common.C.USAGE_MEDIA
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.LibraryResult
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.activity.NavigationActivity
|
||||
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
|
||||
|
||||
class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
private lateinit var dataSourceFactory: DataSource.Factory
|
||||
|
||||
private val librarySessionCallback = CustomMediaLibrarySessionCallback()
|
||||
|
||||
companion object {
|
||||
private const val SEARCH_QUERY_PREFIX_COMPAT = "androidx://media3-session/playFromSearch"
|
||||
private const val SEARCH_QUERY_PREFIX = "androidx://media3-session/setMediaUri"
|
||||
}
|
||||
|
||||
private inner class CustomMediaLibrarySessionCallback :
|
||||
MediaLibrarySession.MediaLibrarySessionCallback {
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
return Futures.immediateFuture(
|
||||
LibraryResult.ofItem(
|
||||
MediaItemTree.getRootItem(),
|
||||
params
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val item =
|
||||
MediaItemTree.getItem(mediaId)
|
||||
?: return Futures.immediateFuture(
|
||||
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
)
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(item, /* params= */ null))
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val children =
|
||||
MediaItemTree.getChildren(parentId)
|
||||
?: return Futures.immediateFuture(
|
||||
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
)
|
||||
|
||||
return Futures.immediateFuture(LibraryResult.ofItemList(children, params))
|
||||
}
|
||||
|
||||
private fun setMediaItemFromSearchQuery(query: String) {
|
||||
// Only accept query with pattern "play [Title]" or "[Title]"
|
||||
// Where [Title]: must be exactly matched
|
||||
// If no media with exact name found, play a random media instead
|
||||
val mediaTitle =
|
||||
if (query.startsWith("play ", ignoreCase = true)) {
|
||||
query.drop(5)
|
||||
} else {
|
||||
query
|
||||
}
|
||||
|
||||
val item = MediaItemTree.getItemFromTitle(mediaTitle) ?: MediaItemTree.getRandomItem()
|
||||
player.setMediaItem(item)
|
||||
}
|
||||
|
||||
override fun onSetMediaUri(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
uri: Uri,
|
||||
extras: Bundle
|
||||
): Int {
|
||||
|
||||
if (uri.toString().startsWith(SEARCH_QUERY_PREFIX) ||
|
||||
uri.toString().startsWith(SEARCH_QUERY_PREFIX_COMPAT)
|
||||
) {
|
||||
val searchQuery =
|
||||
uri.getQueryParameter("query")
|
||||
?: return SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
setMediaItemFromSearchQuery(searchQuery)
|
||||
|
||||
return SessionResult.RESULT_SUCCESS
|
||||
} else {
|
||||
return SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer,
|
||||
* and thereby customarily it is required to rebuild it..
|
||||
*/
|
||||
private class CustomMediaItemFiller : MediaSession.MediaItemFiller {
|
||||
override fun fillInLocalConfiguration(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItem: MediaItem
|
||||
): MediaItem {
|
||||
// Again, set the Uri, so that it will get a LocalConfiguration
|
||||
val item = mediaItem.buildUpon()
|
||||
.setUri(mediaItem.mediaMetadata.mediaUri)
|
||||
.build()
|
||||
|
||||
return item
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
initializeSessionAndPlayer()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
player.release()
|
||||
mediaLibrarySession.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession {
|
||||
return mediaLibrarySession
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
private fun initializeSessionAndPlayer() {
|
||||
/*
|
||||
* TODO:
|
||||
* * Could be refined to use WAKE_MODE_LOCAL when offline....
|
||||
*/
|
||||
|
||||
|
||||
setMediaNotificationProvider(MediaNotificationProvider(UApp.applicationContext()))
|
||||
|
||||
|
||||
val subsonicAPIClient: SubsonicAPIClient by inject()
|
||||
|
||||
// Create a MediaSource which passes calls through our OkHttp Stack
|
||||
dataSourceFactory = OkHttpDataSource.Factory(subsonicAPIClient)
|
||||
|
||||
|
||||
// A download cache should not evict media, so should use a NoopCacheEvictor.
|
||||
// A download cache should not evict media, so should use a NoopCacheEvictor.
|
||||
// TODO: Add cache: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer
|
||||
// var cache = UltrasonicCache()
|
||||
//
|
||||
// val cacheDataSourceFactory: DataSource.Factory = CacheDataSource.Factory()
|
||||
// .setCache(cache)
|
||||
// .setUpstreamDataSourceFactory(dataSourceFactory)
|
||||
// .setCacheWriteDataSinkFactory(null) // Disable writing.
|
||||
|
||||
|
||||
// Create a renderer with HW rendering support
|
||||
val renderer = DefaultRenderersFactory(this)
|
||||
renderer.setEnableAudioOffload(true)
|
||||
|
||||
// Create the player
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setAudioAttributes(getAudioAttributes(), true)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
||||
//.setRenderersFactory(renderer)
|
||||
.build()
|
||||
|
||||
// Enable audio offload
|
||||
//player.experimentalSetOffloadSchedulingEnabled(true)
|
||||
|
||||
MediaItemTree.initialize(assets)
|
||||
|
||||
// THIS Will need to use the AutoCalls
|
||||
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
||||
.setMediaItemFiller(CustomMediaItemFiller())
|
||||
.setSessionActivity(getPendingIntentForContent())
|
||||
.build()
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedImmutableFlag")
|
||||
private fun getPendingIntentForContent(): PendingIntent {
|
||||
val intent = Intent(this, NavigationActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
|
||||
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||
}
|
||||
|
||||
private fun getAudioAttributes(): AudioAttributes {
|
||||
return AudioAttributes.Builder()
|
||||
.setUsage(USAGE_MEDIA)
|
||||
.setContentType(CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
/*
|
||||
* UltrasonicCache.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import androidx.media3.datasource.cache.CacheSpan
|
||||
import androidx.media3.datasource.cache.ContentMetadata
|
||||
import androidx.media3.datasource.cache.ContentMetadataMutations
|
||||
import java.io.File
|
||||
import java.util.NavigableSet
|
||||
|
||||
@UnstableApi
|
||||
|
||||
class UltrasonicCache : Cache {
|
||||
override fun getUid(): Long {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
// R/O Cache Implementation
|
||||
}
|
||||
|
||||
override fun addListener(key: String, listener: Cache.Listener): NavigableSet<CacheSpan> {
|
||||
// Not (yet?) implemented
|
||||
return emptySet<CacheSpan>() as NavigableSet<CacheSpan>
|
||||
}
|
||||
|
||||
override fun removeListener(key: String, listener: Cache.Listener) {
|
||||
// Not (yet?) implemented
|
||||
}
|
||||
|
||||
override fun getCachedSpans(key: String): NavigableSet<CacheSpan> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getKeys(): MutableSet<String> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getCacheSpace(): Long {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun startReadWrite(key: String, position: Long, length: Long): CacheSpan {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun startReadWriteNonBlocking(key: String, position: Long, length: Long): CacheSpan? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun startFile(key: String, position: Long, length: Long): File {
|
||||
// R/O Cache Implementation
|
||||
return File("NONE")
|
||||
}
|
||||
|
||||
override fun commitFile(file: File, length: Long) {
|
||||
// R/O Cache Implementation
|
||||
}
|
||||
|
||||
override fun releaseHoleSpan(holeSpan: CacheSpan) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun removeResource(key: String) {
|
||||
// R/O Cache Implementation
|
||||
}
|
||||
|
||||
override fun removeSpan(span: CacheSpan) {
|
||||
// R/O Cache Implementation
|
||||
}
|
||||
|
||||
override fun isCached(key: String, position: Long, length: Long): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getCachedLength(key: String, position: Long, length: Long): Long {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getCachedBytes(key: String, position: Long, length: Long): Long {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun applyContentMetadataMutations(key: String, mutations: ContentMetadataMutations) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getContentMetadata(key: String): ContentMetadata {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
@ -1,118 +0,0 @@
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioManager.OnAudioFocusChangeListener
|
||||
import androidx.media.AudioAttributesCompat
|
||||
import androidx.media.AudioFocusRequestCompat
|
||||
import androidx.media.AudioManagerCompat
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import timber.log.Timber
|
||||
|
||||
class AudioFocusHandler(private val context: Context) {
|
||||
// TODO: This is a circular reference, try to remove it
|
||||
// This should be doable by using the native MediaController framework
|
||||
private val mediaPlayerControllerLazy =
|
||||
inject<MediaPlayerController>(MediaPlayerController::class.java)
|
||||
|
||||
private val audioManager by lazy {
|
||||
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
}
|
||||
|
||||
private val lossPref: Int
|
||||
get() = Settings.tempLoss
|
||||
|
||||
private val audioAttributesCompat by lazy {
|
||||
AudioAttributesCompat.Builder()
|
||||
.setUsage(AudioAttributesCompat.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
|
||||
.setLegacyStreamType(AudioManager.STREAM_MUSIC)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun requestAudioFocus() {
|
||||
if (!hasFocus) {
|
||||
hasFocus = true
|
||||
AudioManagerCompat.requestAudioFocus(audioManager, focusRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private val listener = OnAudioFocusChangeListener { focusChange ->
|
||||
|
||||
val mediaPlayerController = mediaPlayerControllerLazy.value
|
||||
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
Timber.v("Regained Audio Focus")
|
||||
if (pauseFocus) {
|
||||
pauseFocus = false
|
||||
mediaPlayerController.start()
|
||||
} else if (lowerFocus) {
|
||||
lowerFocus = false
|
||||
mediaPlayerController.setVolume(1.0f)
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
||||
hasFocus = false
|
||||
mediaPlayerController.pause()
|
||||
AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest)
|
||||
Timber.v("Abandoned Audio Focus")
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
||||
Timber.v("Lost Audio Focus")
|
||||
|
||||
if (mediaPlayerController.playerState === PlayerState.STARTED) {
|
||||
if (lossPref == 0 || lossPref == 1) {
|
||||
pauseFocus = true
|
||||
mediaPlayerController.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
||||
Timber.v("Lost Audio Focus")
|
||||
|
||||
if (mediaPlayerController.playerState === PlayerState.STARTED) {
|
||||
if (lossPref == 2 || lossPref == 1) {
|
||||
lowerFocus = true
|
||||
mediaPlayerController.setVolume(0.1f)
|
||||
} else if (lossPref == 0 || lossPref == 1) {
|
||||
pauseFocus = true
|
||||
mediaPlayerController.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val focusRequest: AudioFocusRequestCompat by lazy {
|
||||
AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(audioAttributesCompat)
|
||||
.setWillPauseWhenDucked(true)
|
||||
.setOnAudioFocusChangeListener(listener)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var hasFocus = false
|
||||
private var pauseFocus = false
|
||||
private var lowerFocus = false
|
||||
|
||||
// TODO: This can be removed if we switch to androidx.media2.player
|
||||
fun getAudioAttributes(): AudioAttributes {
|
||||
return AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.setLegacyStreamType(AudioManager.STREAM_MUSIC)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
@ -9,6 +9,7 @@ package org.moire.ultrasonic.service
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
@ -26,7 +27,6 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.SearchCriteria
|
||||
import org.moire.ultrasonic.domain.SearchResult
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
@ -73,7 +73,6 @@ private const val SEARCH_LIMIT = 10
|
||||
class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
|
||||
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
|
||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private val musicService = MusicServiceFactory.getMusicService()
|
||||
@ -108,9 +107,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
playFromSearchCommand(it.first)
|
||||
}
|
||||
|
||||
mediaSessionHandler.initialize()
|
||||
|
||||
val handler = Handler()
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
handler.postDelayed(
|
||||
{
|
||||
// Ultrasonic may be started from Android Auto. This boots up the necessary components.
|
||||
@ -118,7 +116,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
"AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService..."
|
||||
)
|
||||
lifecycleSupport.onCreate()
|
||||
MediaPlayerService.getInstance()
|
||||
DownloadService.getInstance()
|
||||
},
|
||||
100
|
||||
)
|
||||
@ -186,7 +184,6 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
rxBusSubscription.dispose()
|
||||
mediaSessionHandler.release()
|
||||
serviceJob.cancel()
|
||||
|
||||
Timber.i("AutoMediaBrowserService onDestroy finished")
|
||||
@ -662,7 +659,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
|
||||
playlistCache = content?.getTracks()
|
||||
}
|
||||
if (playlistCache != null) playSongs(playlistCache)
|
||||
if (playlistCache != null) playSongs(playlistCache!!)
|
||||
}
|
||||
}
|
||||
|
||||
@ -905,7 +902,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
val content = listStarredSongsInMusicService()
|
||||
starredSongsCache = content?.songs
|
||||
}
|
||||
if (starredSongsCache != null) playSongs(starredSongsCache)
|
||||
if (starredSongsCache != null) playSongs(starredSongsCache!!)
|
||||
}
|
||||
}
|
||||
|
||||
@ -959,7 +956,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
|
||||
randomSongsCache = content?.getTracks()
|
||||
}
|
||||
if (randomSongsCache != null) playSongs(randomSongsCache)
|
||||
if (randomSongsCache != null) playSongs(randomSongsCache!!)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1071,27 +1068,25 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
return section.toString()
|
||||
}
|
||||
|
||||
private fun playSongs(songs: List<Track?>?) {
|
||||
private fun playSongs(songs: List<Track>) {
|
||||
mediaPlayerController.addToPlaylist(
|
||||
songs,
|
||||
save = false,
|
||||
cachePermanently = false,
|
||||
autoPlay = true,
|
||||
playNext = false,
|
||||
shuffle = false,
|
||||
newPlaylist = true
|
||||
insertionMode = MediaPlayerController.InsertionMode.CLEAR
|
||||
)
|
||||
}
|
||||
|
||||
private fun playSong(song: Track) {
|
||||
mediaPlayerController.addToPlaylist(
|
||||
listOf(song),
|
||||
save = false,
|
||||
cachePermanently = false,
|
||||
autoPlay = false,
|
||||
playNext = true,
|
||||
shuffle = false,
|
||||
newPlaylist = false
|
||||
insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
)
|
||||
if (mediaPlayerController.playlistSize > 1) mediaPlayerController.next()
|
||||
if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next()
|
||||
else mediaPlayerController.play()
|
||||
}
|
||||
|
||||
|
@ -7,27 +7,18 @@
|
||||
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.media3.common.MediaItem
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Locale
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
import org.moire.ultrasonic.util.CancellableTask
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.Util.safeClose
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
@ -44,29 +35,22 @@ class DownloadFile(
|
||||
) : KoinComponent, Identifiable {
|
||||
val partialFile: String
|
||||
lateinit var completeFile: String
|
||||
val saveFile: String = FileUtil.getSongFile(track)
|
||||
val pinnedFile: String = FileUtil.getSongFile(track)
|
||||
var shouldSave = save
|
||||
private var downloadTask: CancellableTask? = null
|
||||
internal var downloadTask: CancellableTask? = null
|
||||
var isFailed = false
|
||||
private var retryCount = MAX_RETRIES
|
||||
internal var retryCount = MAX_RETRIES
|
||||
|
||||
private val desiredBitRate: Int = Settings.maxBitRate
|
||||
val desiredBitRate: Int = Settings.maxBitRate
|
||||
|
||||
var priority = 100
|
||||
var downloadPrepared = false
|
||||
|
||||
@Volatile
|
||||
private var isPlaying = false
|
||||
internal var saveWhenDone = false
|
||||
|
||||
@Volatile
|
||||
private var saveWhenDone = false
|
||||
|
||||
@Volatile
|
||||
private var completeWhenDone = false
|
||||
|
||||
private val downloader: Downloader by inject()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
var completeWhenDone = false
|
||||
|
||||
val progress: MutableLiveData<Int> = MutableLiveData(0)
|
||||
|
||||
@ -78,7 +62,7 @@ class DownloadFile(
|
||||
|
||||
private val lazyInitialStatus: Lazy<DownloadStatus> = lazy {
|
||||
when {
|
||||
Storage.isPathExists(saveFile) -> {
|
||||
Storage.isPathExists(pinnedFile) -> {
|
||||
DownloadStatus.PINNED
|
||||
}
|
||||
Storage.isPathExists(completeFile) -> {
|
||||
@ -95,10 +79,10 @@ class DownloadFile(
|
||||
}
|
||||
|
||||
init {
|
||||
partialFile = FileUtil.getParentPath(saveFile) + "/" +
|
||||
FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile))
|
||||
completeFile = FileUtil.getParentPath(saveFile) + "/" +
|
||||
FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile))
|
||||
partialFile = FileUtil.getParentPath(pinnedFile) + "/" +
|
||||
FileUtil.getPartialFile(FileUtil.getNameFromPath(pinnedFile))
|
||||
completeFile = FileUtil.getParentPath(pinnedFile) + "/" +
|
||||
FileUtil.getCompleteFile(FileUtil.getNameFromPath(pinnedFile))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,13 +99,6 @@ class DownloadFile(
|
||||
downloadPrepared = true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun download() {
|
||||
FileUtil.createDirectoryForParent(saveFile)
|
||||
isFailed = false
|
||||
downloadTask = DownloadTask()
|
||||
downloadTask!!.start()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelDownload() {
|
||||
@ -129,30 +106,23 @@ class DownloadFile(
|
||||
}
|
||||
|
||||
val completeOrSaveFile: String
|
||||
get() = if (Storage.isPathExists(saveFile)) {
|
||||
saveFile
|
||||
get() = if (Storage.isPathExists(pinnedFile)) {
|
||||
pinnedFile
|
||||
} else {
|
||||
completeFile
|
||||
}
|
||||
|
||||
val completeOrPartialFile: String
|
||||
get() = if (isCompleteFileAvailable) {
|
||||
completeOrSaveFile
|
||||
} else {
|
||||
partialFile
|
||||
}
|
||||
|
||||
val isSaved: Boolean
|
||||
get() = Storage.isPathExists(saveFile)
|
||||
get() = Storage.isPathExists(pinnedFile)
|
||||
|
||||
@get:Synchronized
|
||||
val isCompleteFileAvailable: Boolean
|
||||
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)
|
||||
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)
|
||||
|
||||
@get:Synchronized
|
||||
val isWorkDone: Boolean
|
||||
get() = Storage.isPathExists(completeFile) && !shouldSave ||
|
||||
Storage.isPathExists(saveFile) || saveWhenDone || completeWhenDone
|
||||
Storage.isPathExists(pinnedFile) || saveWhenDone || completeWhenDone
|
||||
|
||||
@get:Synchronized
|
||||
val isDownloading: Boolean
|
||||
@ -170,54 +140,66 @@ class DownloadFile(
|
||||
cancelDownload()
|
||||
Storage.delete(partialFile)
|
||||
Storage.delete(completeFile)
|
||||
Storage.delete(saveFile)
|
||||
Storage.delete(pinnedFile)
|
||||
|
||||
status.postValue(DownloadStatus.IDLE)
|
||||
|
||||
Util.scanMedia(saveFile)
|
||||
Util.scanMedia(pinnedFile)
|
||||
}
|
||||
|
||||
fun unpin() {
|
||||
val file = Storage.getFromPath(saveFile) ?: return
|
||||
Timber.e("CLEANING")
|
||||
val file = Storage.getFromPath(pinnedFile) ?: return
|
||||
Storage.rename(file, completeFile)
|
||||
status.postValue(DownloadStatus.DONE)
|
||||
}
|
||||
|
||||
fun cleanup(): Boolean {
|
||||
Timber.e("CLEANING")
|
||||
var ok = true
|
||||
if (Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)) {
|
||||
if (Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)) {
|
||||
ok = Storage.delete(partialFile)
|
||||
}
|
||||
|
||||
if (Storage.isPathExists(saveFile)) {
|
||||
if (Storage.isPathExists(pinnedFile)) {
|
||||
ok = ok and Storage.delete(completeFile)
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
fun setPlaying(isPlaying: Boolean) {
|
||||
if (!isPlaying) doPendingRename()
|
||||
this.isPlaying = isPlaying
|
||||
/**
|
||||
* Create a MediaItem instance representing the data inside this DownloadFile
|
||||
*/
|
||||
val mediaItem: MediaItem by lazy {
|
||||
track.toMediaItem()
|
||||
}
|
||||
|
||||
var isPlaying: Boolean = false
|
||||
get() = field
|
||||
set(isPlaying) {
|
||||
if (!isPlaying) doPendingRename()
|
||||
field = isPlaying
|
||||
}
|
||||
|
||||
// Do a pending rename after the song has stopped playing
|
||||
private fun doPendingRename() {
|
||||
try {
|
||||
Timber.e("CLEANING")
|
||||
if (saveWhenDone) {
|
||||
Storage.rename(completeFile, saveFile)
|
||||
Storage.rename(completeFile, pinnedFile)
|
||||
saveWhenDone = false
|
||||
} else if (completeWhenDone) {
|
||||
if (shouldSave) {
|
||||
Storage.rename(partialFile, saveFile)
|
||||
Util.scanMedia(saveFile)
|
||||
Storage.rename(partialFile, pinnedFile)
|
||||
Util.scanMedia(pinnedFile)
|
||||
} else {
|
||||
Storage.rename(partialFile, completeFile)
|
||||
}
|
||||
completeWhenDone = false
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.w(e, "Failed to rename file %s to %s", completeFile, saveFile)
|
||||
Timber.w(e, "Failed to rename file %s to %s", completeFile, pinnedFile)
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,176 +207,7 @@ class DownloadFile(
|
||||
return String.format(Locale.ROOT, "DownloadFile (%s)", track)
|
||||
}
|
||||
|
||||
private inner class DownloadTask : CancellableTask() {
|
||||
val musicService = getMusicService()
|
||||
|
||||
override fun execute() {
|
||||
|
||||
downloadPrepared = false
|
||||
var inputStream: InputStream? = null
|
||||
var outputStream: OutputStream? = null
|
||||
try {
|
||||
if (Storage.isPathExists(saveFile)) {
|
||||
Timber.i("%s already exists. Skipping.", saveFile)
|
||||
status.postValue(DownloadStatus.PINNED)
|
||||
return
|
||||
}
|
||||
|
||||
if (Storage.isPathExists(completeFile)) {
|
||||
var newStatus: DownloadStatus = DownloadStatus.DONE
|
||||
if (shouldSave) {
|
||||
if (isPlaying) {
|
||||
saveWhenDone = true
|
||||
} else {
|
||||
Storage.rename(completeFile, saveFile)
|
||||
newStatus = DownloadStatus.PINNED
|
||||
}
|
||||
} else {
|
||||
Timber.i("%s already exists. Skipping.", completeFile)
|
||||
}
|
||||
status.postValue(newStatus)
|
||||
return
|
||||
}
|
||||
|
||||
status.postValue(DownloadStatus.DOWNLOADING)
|
||||
|
||||
// Some devices seem to throw error on partial file which doesn't exist
|
||||
val needsDownloading: Boolean
|
||||
val duration = track.duration
|
||||
val fileLength = Storage.getFromPath(partialFile)?.length ?: 0
|
||||
|
||||
needsDownloading = (
|
||||
desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L
|
||||
)
|
||||
|
||||
if (needsDownloading) {
|
||||
// Attempt partial HTTP GET, appending to the file if it exists.
|
||||
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
||||
track, fileLength, desiredBitRate, shouldSave
|
||||
)
|
||||
|
||||
inputStream = inStream
|
||||
|
||||
if (isPartial) {
|
||||
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
|
||||
}
|
||||
|
||||
outputStream = Storage.getOrCreateFileFromPath(partialFile)
|
||||
.getFileOutputStream(isPartial)
|
||||
|
||||
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
|
||||
setProgress(totalBytesCopied)
|
||||
}
|
||||
|
||||
Timber.i("Downloaded %d bytes to %s", len, partialFile)
|
||||
|
||||
inputStream.close()
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
|
||||
if (isCancelled) {
|
||||
status.postValue(DownloadStatus.CANCELLED)
|
||||
throw RuntimeException(
|
||||
String.format(Locale.ROOT, "Download of '%s' was cancelled", track)
|
||||
)
|
||||
}
|
||||
|
||||
if (track.artistId != null) {
|
||||
cacheMetadata(track.artistId!!)
|
||||
}
|
||||
|
||||
downloadAndSaveCoverArt()
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
completeWhenDone = true
|
||||
} else {
|
||||
if (shouldSave) {
|
||||
Storage.rename(partialFile, saveFile)
|
||||
status.postValue(DownloadStatus.PINNED)
|
||||
Util.scanMedia(saveFile)
|
||||
} else {
|
||||
Storage.rename(partialFile, completeFile)
|
||||
status.postValue(DownloadStatus.DONE)
|
||||
}
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
outputStream.safeClose()
|
||||
Storage.delete(completeFile)
|
||||
Storage.delete(saveFile)
|
||||
if (!isCancelled) {
|
||||
isFailed = true
|
||||
if (retryCount > 1) {
|
||||
status.postValue(DownloadStatus.RETRYING)
|
||||
--retryCount
|
||||
} else if (retryCount == 1) {
|
||||
status.postValue(DownloadStatus.FAILED)
|
||||
--retryCount
|
||||
}
|
||||
Timber.w(all, "Failed to download '%s'.", track)
|
||||
}
|
||||
} finally {
|
||||
inputStream.safeClose()
|
||||
outputStream.safeClose()
|
||||
CacheCleaner().cleanSpace()
|
||||
downloader.checkDownloads()
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return String.format(Locale.ROOT, "DownloadTask (%s)", track)
|
||||
}
|
||||
|
||||
private fun cacheMetadata(artistId: String) {
|
||||
// TODO: Right now it's caching the track artist.
|
||||
// Once the albums are cached in db, we should retrieve the album,
|
||||
// and then cache the album artist.
|
||||
if (artistId.isEmpty()) return
|
||||
var artist: Artist? =
|
||||
activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId)
|
||||
|
||||
// If we are downloading a new album, and the user has not visited the Artists list
|
||||
// recently, then the artist won't be in the database.
|
||||
if (artist == null) {
|
||||
val artists: List<Artist> = musicService.getArtists(true)
|
||||
artist = artists.find {
|
||||
it.id == artistId
|
||||
}
|
||||
}
|
||||
|
||||
// If we have found an artist, catch it.
|
||||
if (artist != null) {
|
||||
activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAndSaveCoverArt() {
|
||||
try {
|
||||
if (!TextUtils.isEmpty(track.coverArt)) {
|
||||
// Download the largest size that we can display in the UI
|
||||
imageLoaderProvider.getImageLoader().cacheCoverArt(track)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all, "Failed to get cover art.")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytes = read(buffer)
|
||||
while (!isCancelled && bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
onCopy(bytesCopied)
|
||||
bytes = read(buffer)
|
||||
}
|
||||
return bytesCopied
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProgress(totalBytesCopied: Long) {
|
||||
internal fun setProgress(totalBytesCopied: Long) {
|
||||
if (track.size != null) {
|
||||
progress.postValue((totalBytesCopied * 100 / track.size!!).toInt())
|
||||
}
|
||||
|
@ -0,0 +1,256 @@
|
||||
/*
|
||||
* MediaPlayerService.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.activity.NavigationActivity
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.SimpleServiceBinder
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Android Foreground service which is used to download tracks even when the app is not visible
|
||||
*
|
||||
* "A foreground service is a service that the user is
|
||||
* actively aware of and isn’t a candidate for the system to kill when low on memory."
|
||||
*
|
||||
* TODO: Migrate this to use the Media3 DownloadHelper
|
||||
*/
|
||||
class DownloadService : Service() {
|
||||
private val binder: IBinder = SimpleServiceBinder(this)
|
||||
|
||||
private val downloader by inject<Downloader>()
|
||||
|
||||
private var mediaSession: MediaSessionCompat? = null
|
||||
|
||||
private var isInForeground = false
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
||||
// Create Notification Channel
|
||||
createNotificationChannel()
|
||||
updateNotification()
|
||||
|
||||
instance = this
|
||||
Timber.i("DownloadService created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
instance = null
|
||||
try {
|
||||
downloader.stop()
|
||||
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
} catch (ignored: Throwable) {
|
||||
}
|
||||
Timber.i("DownloadService stopped")
|
||||
}
|
||||
|
||||
fun notifyDownloaderStopped() {
|
||||
isInForeground = false
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun setupOnSongCompletedHandler() {
|
||||
// localMediaPlayer.onSongCompleted = { currentPlaying: DownloadFile? ->
|
||||
// val index = downloader.currentPlayingIndex
|
||||
//
|
||||
// if (currentPlaying != null) {
|
||||
// val song = currentPlaying.track
|
||||
// if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) {
|
||||
// val musicService = getMusicService()
|
||||
// try {
|
||||
// musicService.deleteBookmark(song.id)
|
||||
// } catch (ignored: Exception) {
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (index != -1) {
|
||||
//
|
||||
// if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) {
|
||||
// if (Settings.shouldClearPlaylist) {
|
||||
// clear(true)
|
||||
// jukeboxMediaPlayer.updatePlaylist()
|
||||
// }
|
||||
// resetPlayback()
|
||||
// } else {
|
||||
// play(index + 1)
|
||||
// }
|
||||
// }
|
||||
// null
|
||||
// }
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
// The suggested importance of a startForeground service notification is IMPORTANCE_LOW
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NOTIFICATION_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
channel.lightColor = android.R.color.holo_blue_dark
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
channel.setShowBadge(false)
|
||||
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
// We should use a single notification builder, otherwise the notification may not be updated
|
||||
// Set some values that never change
|
||||
private val notificationBuilder: NotificationCompat.Builder by lazy {
|
||||
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_ultrasonic)
|
||||
.setAutoCancel(false)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setShowWhen(false)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentIntent(getPendingIntentForContent())
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
|
||||
val notification = buildForegroundNotification()
|
||||
|
||||
if (isInForeground) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
} else {
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
Timber.v("Updated notification")
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
isInForeground = true
|
||||
Timber.v("Created Foreground notification")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method builds a notification, reusing the Notification Builder if possible
|
||||
*/
|
||||
@Suppress("SpreadOperator")
|
||||
private fun buildForegroundNotification(): Notification {
|
||||
|
||||
if (downloader.started) {
|
||||
// No song is playing, but Ultrasonic is downloading files
|
||||
notificationBuilder.setContentTitle(
|
||||
getString(R.string.notification_downloading_title)
|
||||
)
|
||||
}
|
||||
|
||||
return notificationBuilder.build()
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedImmutableFlag")
|
||||
private fun getPendingIntentForContent(): PendingIntent {
|
||||
val intent = Intent(this, NavigationActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
|
||||
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
companion object {
|
||||
|
||||
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
|
||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
|
||||
private const val NOTIFICATION_ID = 3033
|
||||
|
||||
@Volatile
|
||||
private var instance: DownloadService? = null
|
||||
private val instanceLock = Any()
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(): DownloadService? {
|
||||
val context = UApp.applicationContext()
|
||||
// Try for twenty times to retrieve a running service,
|
||||
// sleep 100 millis between each try,
|
||||
// and run the block that creates a service only synchronized.
|
||||
for (i in 0..19) {
|
||||
if (instance != null) return instance
|
||||
synchronized(instanceLock) {
|
||||
if (instance != null) return instance
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(
|
||||
Intent(context, DownloadService::class.java)
|
||||
)
|
||||
} else {
|
||||
context.startService(Intent(context, DownloadService::class.java))
|
||||
}
|
||||
}
|
||||
Util.sleepQuietly(100L)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val runningInstance: DownloadService?
|
||||
get() {
|
||||
synchronized(instanceLock) { return instance }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun executeOnStartedMediaPlayerService(
|
||||
taskToExecute: (DownloadService) -> Unit
|
||||
) {
|
||||
|
||||
val t: Thread = object : Thread() {
|
||||
override fun run() {
|
||||
val instance = getInstance()
|
||||
if (instance == null) {
|
||||
Timber.e("ExecuteOnStarted.. failed to get a DownloadService instance!")
|
||||
return
|
||||
} else {
|
||||
taskToExecute(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.start()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,119 +1,130 @@
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import java.util.ArrayList
|
||||
import java.util.PriorityQueue
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
import org.moire.ultrasonic.util.CancellableTask
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.LRUCache
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.Util.safeClose
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Locale
|
||||
import java.util.PriorityQueue
|
||||
|
||||
/**
|
||||
* This class is responsible for maintaining the playlist and downloading
|
||||
* its items from the network to the filesystem.
|
||||
*
|
||||
* TODO: Move away from managing the queue with scheduled checks, instead use callbacks when
|
||||
* Downloads are finished
|
||||
* TODO: Move entirely to subclass the Media3.DownloadService
|
||||
*/
|
||||
class Downloader(
|
||||
private val shufflePlayBuffer: ShufflePlayBuffer,
|
||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||
private val localMediaPlayer: LocalMediaPlayer
|
||||
private val storageMonitor: ExternalStorageMonitor,
|
||||
private val legacyPlaylistManager: LegacyPlaylistManager,
|
||||
) : KoinComponent {
|
||||
|
||||
private val playlist = mutableListOf<DownloadFile>()
|
||||
// Dependencies
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private val mediaController: MediaPlayerController by inject()
|
||||
|
||||
var started: Boolean = false
|
||||
var shouldStop: Boolean = false
|
||||
|
||||
private val downloadQueue = PriorityQueue<DownloadFile>()
|
||||
private val activelyDownloading = mutableListOf<DownloadFile>()
|
||||
|
||||
// TODO: The playlist is now published with RX, while the observableDownloads is using LiveData.
|
||||
// Use the same for both
|
||||
// The generic list models expect a LiveData, so even though we are using Rx for many events
|
||||
// surrounding playback the list of Downloads is published as LiveData.
|
||||
val observableDownloads = MutableLiveData<List<DownloadFile>>()
|
||||
|
||||
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
||||
|
||||
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
|
||||
private val downloadFileCache = LRUCache<Track, DownloadFile>(100)
|
||||
private val downloadFileCache = LRUCache<Track, DownloadFile>(500)
|
||||
|
||||
private var executorService: ScheduledExecutorService? = null
|
||||
private var handler: Handler = Handler(Looper.getMainLooper())
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
|
||||
private var playlistUpdateRevision: Long = 0
|
||||
private set(value) {
|
||||
field = value
|
||||
RxBus.playlistPublisher.onNext(playlist)
|
||||
}
|
||||
private var backgroundPriorityCounter = 100
|
||||
|
||||
var backgroundPriorityCounter = 100
|
||||
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
val downloadChecker = Runnable {
|
||||
try {
|
||||
Timber.w("Checking Downloads")
|
||||
checkDownloadsInternal()
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all, "checkDownloads() failed.")
|
||||
var downloadChecker = object : Runnable {
|
||||
override fun run() {
|
||||
try {
|
||||
Timber.w("Checking Downloads")
|
||||
checkDownloadsInternal()
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all, "checkDownloads() failed.")
|
||||
} finally {
|
||||
if (!shouldStop) {
|
||||
Handler(Looper.getMainLooper()).postDelayed(this, CHECK_INTERVAL)
|
||||
} else {
|
||||
shouldStop = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
stop()
|
||||
clearPlaylist()
|
||||
rxBusSubscription.dispose()
|
||||
clearBackground()
|
||||
observableDownloads.value = listOf()
|
||||
Timber.i("Downloader destroyed")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
started = true
|
||||
if (executorService == null) {
|
||||
executorService = Executors.newSingleThreadScheduledExecutor()
|
||||
executorService!!.scheduleWithFixedDelay(
|
||||
downloadChecker, 0L, CHECK_INTERVAL, TimeUnit.SECONDS
|
||||
)
|
||||
Timber.i("Downloader started")
|
||||
}
|
||||
|
||||
// Start our loop
|
||||
handler.postDelayed(downloadChecker, 100)
|
||||
|
||||
if (wifiLock == null) {
|
||||
wifiLock = Util.createWifiLock(toString())
|
||||
wifiLock?.acquire()
|
||||
}
|
||||
|
||||
// Check downloads if the playlist changed
|
||||
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
||||
checkDownloads()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
started = false
|
||||
executorService?.shutdown()
|
||||
executorService = null
|
||||
shouldStop = true
|
||||
wifiLock?.release()
|
||||
wifiLock = null
|
||||
MediaPlayerService.runningInstance?.notifyDownloaderStopped()
|
||||
DownloadService.runningInstance?.notifyDownloaderStopped()
|
||||
Timber.i("Downloader stopped")
|
||||
}
|
||||
|
||||
fun checkDownloads() {
|
||||
if (
|
||||
executorService == null ||
|
||||
executorService!!.isTerminated ||
|
||||
executorService!!.isShutdown
|
||||
) {
|
||||
if (!started) {
|
||||
start()
|
||||
} else {
|
||||
try {
|
||||
executorService?.execute(downloadChecker)
|
||||
} catch (exception: RejectedExecutionException) {
|
||||
handler.postDelayed(downloadChecker, 100)
|
||||
} catch (all: Exception) {
|
||||
Timber.w(
|
||||
exception,
|
||||
all,
|
||||
"checkDownloads() can't run, maybe the Downloader is shutting down..."
|
||||
)
|
||||
}
|
||||
@ -121,22 +132,17 @@ class Downloader(
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@Suppress("ComplexMethod", "ComplexCondition")
|
||||
fun checkDownloadsInternal() {
|
||||
if (
|
||||
!Util.isExternalStoragePresent() ||
|
||||
!externalStorageMonitor.isExternalStorageAvailable
|
||||
) {
|
||||
if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) {
|
||||
return
|
||||
}
|
||||
if (shufflePlayBuffer.isEnabled) {
|
||||
checkShufflePlay()
|
||||
}
|
||||
if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
|
||||
|
||||
if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
|
||||
return
|
||||
}
|
||||
|
||||
Timber.v("Downloader checkDownloadsInternal checking downloads")
|
||||
|
||||
// Check the active downloads for failures or completions and remove them
|
||||
// Store the result in a flag to know if changes have occurred
|
||||
var listChanged = cleanupActiveDownloads()
|
||||
@ -145,13 +151,14 @@ class Downloader(
|
||||
val preloadCount = Settings.preloadCount
|
||||
|
||||
// Start preloading at the current playing song
|
||||
var start = currentPlayingIndex
|
||||
var start = mediaController.currentMediaItemIndex
|
||||
|
||||
if (start == -1) start = 0
|
||||
|
||||
val end = (start + preloadCount).coerceAtMost(playlist.size)
|
||||
val end = (start + preloadCount).coerceAtMost(mediaController.mediaItemCount)
|
||||
|
||||
for (i in start until end) {
|
||||
val download = playlist[i]
|
||||
val download = legacyPlaylistManager.playlist[i]
|
||||
|
||||
// Set correct priority (the lower the number, the higher the priority)
|
||||
download.priority = i
|
||||
@ -173,10 +180,6 @@ class Downloader(
|
||||
activelyDownloading.add(task)
|
||||
startDownloadOnService(task)
|
||||
|
||||
// The next file on the playlist is currently downloading
|
||||
if (playlist.indexOf(task) == 1) {
|
||||
localMediaPlayer.setNextPlayerState(PlayerState.DOWNLOADING)
|
||||
}
|
||||
listChanged = true
|
||||
}
|
||||
|
||||
@ -194,10 +197,14 @@ class Downloader(
|
||||
observableDownloads.postValue(downloads)
|
||||
}
|
||||
|
||||
private fun startDownloadOnService(task: DownloadFile) {
|
||||
task.prepare()
|
||||
MediaPlayerService.executeOnStartedMediaPlayerService {
|
||||
task.download()
|
||||
private fun startDownloadOnService(file: DownloadFile) {
|
||||
if (file.isDownloading) return
|
||||
file.prepare()
|
||||
DownloadService.executeOnStartedMediaPlayerService {
|
||||
FileUtil.createDirectoryForParent(file.pinnedFile)
|
||||
file.isFailed = false
|
||||
file.downloadTask = DownloadTask(file)
|
||||
file.downloadTask!!.start()
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,26 +232,6 @@ class Downloader(
|
||||
return (oldSize != activelyDownloading.size)
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val currentPlayingIndex: Int
|
||||
get() = playlist.indexOf(localMediaPlayer.currentPlaying)
|
||||
|
||||
@get:Synchronized
|
||||
val downloadListDuration: Long
|
||||
get() {
|
||||
var totalDuration: Long = 0
|
||||
for (downloadFile in playlist) {
|
||||
val song = downloadFile.track
|
||||
if (!song.isDirectory) {
|
||||
if (song.artist != null) {
|
||||
if (song.duration != null) {
|
||||
totalDuration += song.duration!!.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalDuration
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val all: List<DownloadFile>
|
||||
@ -252,7 +239,7 @@ class Downloader(
|
||||
val temp: MutableList<DownloadFile> = ArrayList()
|
||||
temp.addAll(activelyDownloading)
|
||||
temp.addAll(downloadQueue)
|
||||
temp.addAll(playlist)
|
||||
temp.addAll(legacyPlaylistManager.playlist)
|
||||
return temp.distinct().sorted()
|
||||
}
|
||||
|
||||
@ -267,7 +254,7 @@ class Downloader(
|
||||
temp.addAll(activelyDownloading)
|
||||
temp.addAll(downloadQueue)
|
||||
temp.addAll(
|
||||
playlist.filter {
|
||||
legacyPlaylistManager.playlist.filter {
|
||||
if (!it.isStatusInitialized) false
|
||||
else when (it.status.value) {
|
||||
DownloadStatus.DOWNLOADING -> true
|
||||
@ -278,37 +265,13 @@ class Downloader(
|
||||
return temp.distinct().sorted()
|
||||
}
|
||||
|
||||
// Public facing playlist (immutable)
|
||||
@Synchronized
|
||||
fun getPlaylist(): List<DownloadFile> = playlist
|
||||
|
||||
@Synchronized
|
||||
fun clearDownloadFileCache() {
|
||||
downloadFileCache.clear()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearPlaylist() {
|
||||
playlist.clear()
|
||||
|
||||
val toRemove = mutableListOf<DownloadFile>()
|
||||
|
||||
// Cancel all active downloads with a high priority
|
||||
for (download in activelyDownloading) {
|
||||
if (download.priority < 100) {
|
||||
download.cancelDownload()
|
||||
toRemove.add(download)
|
||||
}
|
||||
}
|
||||
|
||||
activelyDownloading.removeAll(toRemove)
|
||||
|
||||
playlistUpdateRevision++
|
||||
updateLiveData()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun clearBackground() {
|
||||
fun clearBackground() {
|
||||
// Clear the pending queue
|
||||
downloadQueue.clear()
|
||||
|
||||
@ -333,78 +296,6 @@ class Downloader(
|
||||
updateLiveData()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeFromPlaylist(downloadFile: DownloadFile) {
|
||||
if (activelyDownloading.contains(downloadFile)) {
|
||||
downloadFile.cancelDownload()
|
||||
}
|
||||
playlist.remove(downloadFile)
|
||||
playlistUpdateRevision++
|
||||
checkDownloads()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addToPlaylist(
|
||||
songs: List<Track>,
|
||||
save: Boolean,
|
||||
autoPlay: Boolean,
|
||||
playNext: Boolean,
|
||||
newPlaylist: Boolean
|
||||
) {
|
||||
shufflePlayBuffer.isEnabled = false
|
||||
var offset = 1
|
||||
if (songs.isEmpty()) {
|
||||
return
|
||||
}
|
||||
if (newPlaylist) {
|
||||
playlist.clear()
|
||||
}
|
||||
if (playNext) {
|
||||
if (autoPlay && currentPlayingIndex >= 0) {
|
||||
offset = 0
|
||||
}
|
||||
for (song in songs) {
|
||||
val downloadFile = song.getDownloadFile(save)
|
||||
playlist.add(currentPlayingIndex + offset, downloadFile)
|
||||
offset++
|
||||
}
|
||||
} else {
|
||||
for (song in songs) {
|
||||
val downloadFile = song.getDownloadFile(save)
|
||||
playlist.add(downloadFile)
|
||||
}
|
||||
}
|
||||
playlistUpdateRevision++
|
||||
checkDownloads()
|
||||
}
|
||||
|
||||
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
||||
val item = playlist[oldPos]
|
||||
playlist.remove(item)
|
||||
|
||||
if (newPos < oldPos) {
|
||||
playlist.add(newPos + 1, item)
|
||||
} else {
|
||||
playlist.add(newPos - 1, item)
|
||||
}
|
||||
|
||||
playlistUpdateRevision++
|
||||
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<Track>, save: Boolean) {
|
||||
@ -413,30 +304,19 @@ class Downloader(
|
||||
for (song in songs) {
|
||||
val file = song.getDownloadFile()
|
||||
file.shouldSave = save
|
||||
file.priority = backgroundPriorityCounter++
|
||||
downloadQueue.add(file)
|
||||
if (!file.isDownloading) {
|
||||
file.priority = backgroundPriorityCounter++
|
||||
downloadQueue.add(file)
|
||||
}
|
||||
}
|
||||
|
||||
checkDownloads()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun shuffle() {
|
||||
playlist.shuffle()
|
||||
|
||||
// Move the current song to the top..
|
||||
if (localMediaPlayer.currentPlaying != null) {
|
||||
playlist.remove(localMediaPlayer.currentPlaying)
|
||||
playlist.add(0, localMediaPlayer.currentPlaying!!)
|
||||
}
|
||||
|
||||
playlistUpdateRevision++
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@Suppress("ReturnCount")
|
||||
fun getDownloadFileForSong(song: Track): DownloadFile {
|
||||
for (downloadFile in playlist) {
|
||||
for (downloadFile in legacyPlaylistManager.playlist) {
|
||||
if (downloadFile.track == song) {
|
||||
return downloadFile
|
||||
}
|
||||
@ -459,63 +339,205 @@ class Downloader(
|
||||
return downloadFile
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun checkShufflePlay() {
|
||||
// Get users desired random playlist size
|
||||
val listSize = Settings.maxSongs
|
||||
val wasEmpty = playlist.isEmpty()
|
||||
val revisionBefore = playlistUpdateRevision
|
||||
|
||||
// First, ensure that list is at least 20 songs long.
|
||||
val size = playlist.size
|
||||
if (size < listSize) {
|
||||
for (song in shufflePlayBuffer[listSize - size]) {
|
||||
val downloadFile = song.getDownloadFile(false)
|
||||
playlist.add(downloadFile)
|
||||
playlistUpdateRevision++
|
||||
}
|
||||
}
|
||||
|
||||
val currIndex = if (localMediaPlayer.currentPlaying == null) 0 else currentPlayingIndex
|
||||
|
||||
// Only shift playlist if playing song #5 or later.
|
||||
if (currIndex > SHUFFLE_BUFFER_LIMIT) {
|
||||
val songsToShift = currIndex - 2
|
||||
for (song in shufflePlayBuffer[songsToShift]) {
|
||||
playlist.add(song.getDownloadFile(false))
|
||||
playlist[0].cancelDownload()
|
||||
playlist.removeAt(0)
|
||||
playlistUpdateRevision++
|
||||
}
|
||||
}
|
||||
|
||||
if (revisionBefore != playlistUpdateRevision) {
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
}
|
||||
|
||||
if (wasEmpty && playlist.isNotEmpty()) {
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.skip(0, 0)
|
||||
localMediaPlayer.setPlayerState(PlayerState.STARTED, playlist[0])
|
||||
} else {
|
||||
localMediaPlayer.play(playlist[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PARALLEL_DOWNLOADS = 3
|
||||
const val CHECK_INTERVAL = 5L
|
||||
const val SHUFFLE_BUFFER_LIMIT = 4
|
||||
const val CHECK_INTERVAL = 5000L
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function
|
||||
* Gathers the download file for a given song, and modifies shouldSave if provided.
|
||||
*/
|
||||
fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
|
||||
private fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
|
||||
return getDownloadFileForSong(this).apply {
|
||||
if (save != null) this.shouldSave = save
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
|
||||
override fun execute() {
|
||||
|
||||
downloadFile.downloadPrepared = false
|
||||
var inputStream: InputStream? = null
|
||||
var outputStream: OutputStream? = null
|
||||
try {
|
||||
if (Storage.isPathExists(downloadFile.pinnedFile)) {
|
||||
Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile)
|
||||
downloadFile.status.postValue(DownloadStatus.PINNED)
|
||||
return
|
||||
}
|
||||
|
||||
if (Storage.isPathExists(downloadFile.completeFile)) {
|
||||
var newStatus: DownloadStatus = DownloadStatus.DONE
|
||||
if (downloadFile.shouldSave) {
|
||||
if (downloadFile.isPlaying) {
|
||||
downloadFile.saveWhenDone = true
|
||||
} else {
|
||||
Storage.rename(
|
||||
downloadFile.completeFile,
|
||||
downloadFile.pinnedFile
|
||||
)
|
||||
newStatus = DownloadStatus.PINNED
|
||||
}
|
||||
} else {
|
||||
Timber.i(
|
||||
"%s already exists. Skipping.",
|
||||
downloadFile.completeFile
|
||||
)
|
||||
}
|
||||
downloadFile.status.postValue(newStatus)
|
||||
return
|
||||
}
|
||||
|
||||
downloadFile.status.postValue(DownloadStatus.DOWNLOADING)
|
||||
|
||||
// Some devices seem to throw error on partial file which doesn't exist
|
||||
val needsDownloading: Boolean
|
||||
val duration = downloadFile.track.duration
|
||||
val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0
|
||||
|
||||
needsDownloading = (
|
||||
downloadFile.desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L
|
||||
)
|
||||
|
||||
if (needsDownloading) {
|
||||
// Attempt partial HTTP GET, appending to the file if it exists.
|
||||
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
||||
downloadFile.track, fileLength,
|
||||
downloadFile.desiredBitRate,
|
||||
downloadFile.shouldSave
|
||||
)
|
||||
|
||||
inputStream = inStream
|
||||
|
||||
if (isPartial) {
|
||||
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
|
||||
}
|
||||
|
||||
outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile)
|
||||
.getFileOutputStream(isPartial)
|
||||
|
||||
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
|
||||
downloadFile.setProgress(totalBytesCopied)
|
||||
}
|
||||
|
||||
Timber.i("Downloaded %d bytes to %s", len, downloadFile.partialFile)
|
||||
|
||||
inputStream.close()
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
|
||||
if (isCancelled) {
|
||||
downloadFile.status.postValue(DownloadStatus.CANCELLED)
|
||||
throw RuntimeException(
|
||||
String.format(
|
||||
Locale.ROOT, "Download of '%s' was cancelled",
|
||||
downloadFile.track
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (downloadFile.track.artistId != null) {
|
||||
cacheMetadata(downloadFile.track.artistId!!)
|
||||
}
|
||||
|
||||
downloadAndSaveCoverArt()
|
||||
}
|
||||
|
||||
if (downloadFile.isPlaying) {
|
||||
downloadFile.completeWhenDone = true
|
||||
} else {
|
||||
if (downloadFile.shouldSave) {
|
||||
Storage.rename(
|
||||
downloadFile.partialFile,
|
||||
downloadFile.pinnedFile
|
||||
)
|
||||
downloadFile.status.postValue(DownloadStatus.PINNED)
|
||||
Util.scanMedia(downloadFile.pinnedFile)
|
||||
} else {
|
||||
Storage.rename(
|
||||
downloadFile.partialFile,
|
||||
downloadFile.completeFile
|
||||
)
|
||||
downloadFile.status.postValue(DownloadStatus.DONE)
|
||||
}
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
outputStream.safeClose()
|
||||
Storage.delete(downloadFile.completeFile)
|
||||
Storage.delete(downloadFile.pinnedFile)
|
||||
if (!isCancelled) {
|
||||
downloadFile.isFailed = true
|
||||
if (downloadFile.retryCount > 1) {
|
||||
downloadFile.status.postValue(DownloadStatus.RETRYING)
|
||||
--downloadFile.retryCount
|
||||
} else if (downloadFile.retryCount == 1) {
|
||||
downloadFile.status.postValue(DownloadStatus.FAILED)
|
||||
--downloadFile.retryCount
|
||||
}
|
||||
Timber.w(all, "Failed to download '%s'.", downloadFile.track)
|
||||
}
|
||||
} finally {
|
||||
inputStream.safeClose()
|
||||
outputStream.safeClose()
|
||||
CacheCleaner().cleanSpace()
|
||||
checkDownloads()
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track)
|
||||
}
|
||||
|
||||
private fun cacheMetadata(artistId: String) {
|
||||
// TODO: Right now it's caching the track artist.
|
||||
// Once the albums are cached in db, we should retrieve the album,
|
||||
// and then cache the album artist.
|
||||
if (artistId.isEmpty()) return
|
||||
var artist: Artist? =
|
||||
activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId)
|
||||
|
||||
// If we are downloading a new album, and the user has not visited the Artists list
|
||||
// recently, then the artist won't be in the database.
|
||||
if (artist == null) {
|
||||
val artists: List<Artist> = musicService.getArtists(true)
|
||||
artist = artists.find {
|
||||
it.id == artistId
|
||||
}
|
||||
}
|
||||
|
||||
// If we have found an artist, catch it.
|
||||
if (artist != null) {
|
||||
activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAndSaveCoverArt() {
|
||||
try {
|
||||
if (!TextUtils.isEmpty(downloadFile.track.coverArt)) {
|
||||
// Download the largest size that we can display in the UI
|
||||
imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all, "Failed to get cover art.")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytes = read(buffer)
|
||||
while (!isCancelled && bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
onCopy(bytesCopied)
|
||||
bytes = read(buffer)
|
||||
}
|
||||
return bytesCopied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,337 @@
|
||||
/*
|
||||
* JukeboxMediaPlayer.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
|
||||
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.domain.JukeboxStatus
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.Util.sleepQuietly
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Provides an asynchronous interface to the remote jukebox on the Subsonic server.
|
||||
*
|
||||
* TODO: Report warning if queue fills up.
|
||||
* TODO: Create shutdown method?
|
||||
* TODO: Disable repeat.
|
||||
* TODO: Persist RC state?
|
||||
* TODO: Minimize status updates.
|
||||
*/
|
||||
class JukeboxMediaPlayer(private val downloader: Downloader) {
|
||||
private val tasks = TaskQueue()
|
||||
private val executorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private var statusUpdateFuture: ScheduledFuture<*>? = null
|
||||
private val timeOfLastUpdate = AtomicLong()
|
||||
private var jukeboxStatus: JukeboxStatus? = null
|
||||
private var gain = 0.5f
|
||||
private var volumeToast: VolumeToast? = null
|
||||
private val running = AtomicBoolean()
|
||||
private var serviceThread: Thread? = null
|
||||
private var enabled = false
|
||||
|
||||
// TODO: These create circular references, try to refactor
|
||||
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
|
||||
MediaPlayerController::class.java
|
||||
)
|
||||
|
||||
fun startJukeboxService() {
|
||||
if (running.get()) {
|
||||
return
|
||||
}
|
||||
running.set(true)
|
||||
startProcessTasks()
|
||||
Timber.d("Started Jukebox Service")
|
||||
}
|
||||
|
||||
fun stopJukeboxService() {
|
||||
running.set(false)
|
||||
sleepQuietly(1000)
|
||||
if (serviceThread != null) {
|
||||
serviceThread!!.interrupt()
|
||||
}
|
||||
Timber.d("Stopped Jukebox Service")
|
||||
}
|
||||
|
||||
private fun startProcessTasks() {
|
||||
serviceThread = object : Thread() {
|
||||
override fun run() {
|
||||
processTasks()
|
||||
}
|
||||
}
|
||||
(serviceThread as Thread).start()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startStatusUpdate() {
|
||||
stopStatusUpdate()
|
||||
val updateTask = Runnable {
|
||||
tasks.remove(GetStatus::class.java)
|
||||
tasks.add(GetStatus())
|
||||
}
|
||||
statusUpdateFuture = executorService.scheduleWithFixedDelay(
|
||||
updateTask,
|
||||
STATUS_UPDATE_INTERVAL_SECONDS,
|
||||
STATUS_UPDATE_INTERVAL_SECONDS,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopStatusUpdate() {
|
||||
if (statusUpdateFuture != null) {
|
||||
statusUpdateFuture!!.cancel(false)
|
||||
statusUpdateFuture = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun processTasks() {
|
||||
while (running.get()) {
|
||||
var task: JukeboxTask? = null
|
||||
try {
|
||||
if (!isOffline()) {
|
||||
task = tasks.take()
|
||||
val status = task.execute()
|
||||
onStatusUpdate(status)
|
||||
}
|
||||
} catch (ignored: InterruptedException) {
|
||||
} catch (x: Throwable) {
|
||||
onError(task, x)
|
||||
}
|
||||
sleepQuietly(1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) {
|
||||
timeOfLastUpdate.set(System.currentTimeMillis())
|
||||
this.jukeboxStatus = jukeboxStatus
|
||||
}
|
||||
|
||||
private fun onError(task: JukeboxTask?, x: Throwable) {
|
||||
if (x is ApiNotSupportedException && task !is Stop) {
|
||||
disableJukeboxOnError(x, R.string.download_jukebox_server_too_old)
|
||||
} else if (x is OfflineException && task !is Stop) {
|
||||
disableJukeboxOnError(x, R.string.download_jukebox_offline)
|
||||
} else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) {
|
||||
disableJukeboxOnError(x, R.string.download_jukebox_not_authorized)
|
||||
} else {
|
||||
Timber.e(x, "Failed to process jukebox task")
|
||||
}
|
||||
}
|
||||
|
||||
private fun disableJukeboxOnError(x: Throwable, resourceId: Int) {
|
||||
Timber.w(x.toString())
|
||||
val context = applicationContext()
|
||||
Handler().post { toast(context, resourceId, false) }
|
||||
mediaPlayerControllerLazy.value.isJukeboxEnabled = false
|
||||
}
|
||||
|
||||
fun updatePlaylist() {
|
||||
if (!enabled) return
|
||||
tasks.remove(Skip::class.java)
|
||||
tasks.remove(Stop::class.java)
|
||||
tasks.remove(Start::class.java)
|
||||
val ids: MutableList<String> = ArrayList()
|
||||
for (file in downloader.all) {
|
||||
ids.add(file.track.id)
|
||||
}
|
||||
tasks.add(SetPlaylist(ids))
|
||||
}
|
||||
|
||||
fun skip(index: Int, offsetSeconds: Int) {
|
||||
tasks.remove(Skip::class.java)
|
||||
tasks.remove(Stop::class.java)
|
||||
tasks.remove(Start::class.java)
|
||||
startStatusUpdate()
|
||||
if (jukeboxStatus != null) {
|
||||
jukeboxStatus!!.positionSeconds = offsetSeconds
|
||||
}
|
||||
tasks.add(Skip(index, offsetSeconds))
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
tasks.remove(Stop::class.java)
|
||||
tasks.remove(Start::class.java)
|
||||
stopStatusUpdate()
|
||||
tasks.add(Stop())
|
||||
}
|
||||
|
||||
fun start() {
|
||||
tasks.remove(Stop::class.java)
|
||||
tasks.remove(Start::class.java)
|
||||
startStatusUpdate()
|
||||
tasks.add(Start())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun adjustVolume(up: Boolean) {
|
||||
val delta = if (up) 0.05f else -0.05f
|
||||
gain += delta
|
||||
gain = gain.coerceAtLeast(0.0f)
|
||||
gain = gain.coerceAtMost(1.0f)
|
||||
tasks.remove(SetGain::class.java)
|
||||
tasks.add(SetGain(gain))
|
||||
val context = applicationContext()
|
||||
if (volumeToast == null) volumeToast = VolumeToast(context)
|
||||
volumeToast!!.setVolume(gain)
|
||||
}
|
||||
|
||||
private val musicService: MusicService
|
||||
get() = getMusicService()
|
||||
|
||||
val positionSeconds: Int
|
||||
get() {
|
||||
if (jukeboxStatus == null || jukeboxStatus!!.positionSeconds == null || timeOfLastUpdate.get() == 0L) {
|
||||
return 0
|
||||
}
|
||||
if (jukeboxStatus!!.isPlaying) {
|
||||
val secondsSinceLastUpdate =
|
||||
((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L).toInt()
|
||||
return jukeboxStatus!!.positionSeconds!! + secondsSinceLastUpdate
|
||||
}
|
||||
return jukeboxStatus!!.positionSeconds!!
|
||||
}
|
||||
|
||||
var isEnabled: Boolean
|
||||
set(enabled) {
|
||||
Timber.d("Jukebox Service setting enabled to %b", enabled)
|
||||
this.enabled = enabled
|
||||
tasks.clear()
|
||||
if (enabled) {
|
||||
updatePlaylist()
|
||||
}
|
||||
stop()
|
||||
}
|
||||
get() {
|
||||
return enabled
|
||||
}
|
||||
|
||||
private class TaskQueue {
|
||||
private val queue = LinkedBlockingQueue<JukeboxTask>()
|
||||
fun add(jukeboxTask: JukeboxTask) {
|
||||
queue.add(jukeboxTask)
|
||||
}
|
||||
|
||||
@Throws(InterruptedException::class)
|
||||
fun take(): JukeboxTask {
|
||||
return queue.take()
|
||||
}
|
||||
|
||||
fun remove(taskClass: Class<out JukeboxTask?>) {
|
||||
try {
|
||||
val iterator = queue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val task = iterator.next()
|
||||
if (taskClass == task.javaClass) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
} catch (x: Throwable) {
|
||||
Timber.w(x, "Failed to clean-up task queue.")
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
queue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class JukeboxTask {
|
||||
@Throws(Exception::class)
|
||||
abstract fun execute(): JukeboxStatus
|
||||
override fun toString(): String {
|
||||
return javaClass.simpleName
|
||||
}
|
||||
}
|
||||
|
||||
private inner class GetStatus : JukeboxTask() {
|
||||
@Throws(Exception::class)
|
||||
override fun execute(): JukeboxStatus {
|
||||
return musicService.getJukeboxStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SetPlaylist(private val ids: List<String>) :
|
||||
JukeboxTask() {
|
||||
@Throws(Exception::class)
|
||||
override fun execute(): JukeboxStatus {
|
||||
return musicService.updateJukeboxPlaylist(ids)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Skip(
|
||||
private val index: Int,
|
||||
private val offsetSeconds: Int
|
||||
) : JukeboxTask() {
|
||||
@Throws(Exception::class)
|
||||
override fun execute(): JukeboxStatus {
|
||||
return musicService.skipJukebox(index, offsetSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Stop : JukeboxTask() {
|
||||
@Throws(Exception::class)
|
||||
override fun execute(): JukeboxStatus {
|
||||
return musicService.stopJukebox()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Start : JukeboxTask() {
|
||||
@Throws(Exception::class)
|
||||
override fun execute(): JukeboxStatus {
|
||||
return musicService.startJukebox()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SetGain(private val gain: Float) : JukeboxTask() {
|
||||
@Throws(Exception::class)
|
||||
override fun execute(): JukeboxStatus {
|
||||
return musicService.setJukeboxGain(gain)
|
||||
}
|
||||
}
|
||||
|
||||
private class VolumeToast(context: Context) : Toast(context) {
|
||||
private val progressBar: ProgressBar
|
||||
fun setVolume(volume: Float) {
|
||||
progressBar.progress = (100 * volume).roundToInt()
|
||||
show()
|
||||
}
|
||||
|
||||
init {
|
||||
duration = LENGTH_SHORT
|
||||
val inflater =
|
||||
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
||||
val view = inflater.inflate(R.layout.jukebox_volume, null)
|
||||
progressBar = view.findViewById<View>(R.id.jukebox_volume_progress_bar) as ProgressBar
|
||||
setView(view)
|
||||
setGravity(Gravity.TOP, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L
|
||||
}
|
||||
}
|
@ -1,745 +0,0 @@
|
||||
/*
|
||||
* LocalMediaPlayer.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.POWER_SERVICE
|
||||
import android.content.Intent
|
||||
import android.media.MediaPlayer
|
||||
import android.media.MediaPlayer.OnCompletionListener
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.PowerManager.PARTIAL_WAKE_LOCK
|
||||
import android.os.PowerManager.WakeLock
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
import org.moire.ultrasonic.audiofx.VisualizerController
|
||||
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.Settings
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import org.moire.ultrasonic.util.StreamProxy
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Represents a Media Player which uses the mobile's resources for playback
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class LocalMediaPlayer : KoinComponent {
|
||||
|
||||
private val audioFocusHandler by inject<AudioFocusHandler>()
|
||||
private val context by inject<Context>()
|
||||
|
||||
@JvmField
|
||||
var onSongCompleted: ((DownloadFile?) -> Unit?)? = null
|
||||
|
||||
@JvmField
|
||||
var onPrepared: (() -> Any?)? = null
|
||||
|
||||
@JvmField
|
||||
var onNextSongRequested: Runnable? = null
|
||||
|
||||
@JvmField
|
||||
@Volatile
|
||||
var playerState = PlayerState.IDLE
|
||||
|
||||
@JvmField
|
||||
var currentPlaying: DownloadFile? = null
|
||||
|
||||
@JvmField
|
||||
var nextPlaying: DownloadFile? = null
|
||||
|
||||
private var nextPlayerState = PlayerState.IDLE
|
||||
private var nextSetup = false
|
||||
private var nextPlayingTask: CancellableTask? = null
|
||||
private var mediaPlayer: MediaPlayer = MediaPlayer()
|
||||
private var nextMediaPlayer: MediaPlayer? = null
|
||||
private var mediaPlayerLooper: Looper? = null
|
||||
private var mediaPlayerHandler: Handler? = null
|
||||
private var cachedPosition = 0
|
||||
private var proxy: StreamProxy? = null
|
||||
private var bufferTask: CancellableTask? = null
|
||||
private var positionCache: PositionCache? = null
|
||||
|
||||
private val pm = context.getSystemService(POWER_SERVICE) as PowerManager
|
||||
private val wakeLock: WakeLock = pm.newWakeLock(PARTIAL_WAKE_LOCK, this.javaClass.name)
|
||||
|
||||
val secondaryProgress: MutableLiveData<Int> = MutableLiveData(0)
|
||||
|
||||
fun init() {
|
||||
Thread {
|
||||
Thread.currentThread().name = "MediaPlayerThread"
|
||||
Looper.prepare()
|
||||
mediaPlayer.setWakeMode(context, PARTIAL_WAKE_LOCK)
|
||||
mediaPlayer.setOnErrorListener { _, what, more ->
|
||||
handleError(
|
||||
Exception(
|
||||
String.format(
|
||||
Locale.getDefault(),
|
||||
"MediaPlayer error: %d (%d)", what, more
|
||||
)
|
||||
)
|
||||
)
|
||||
false
|
||||
}
|
||||
try {
|
||||
val i = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId)
|
||||
i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
||||
context.sendBroadcast(i)
|
||||
} catch (ignored: Throwable) {
|
||||
// Froyo or lower
|
||||
}
|
||||
mediaPlayerLooper = Looper.myLooper()
|
||||
mediaPlayerHandler = Handler(mediaPlayerLooper!!)
|
||||
Looper.loop()
|
||||
}.start()
|
||||
|
||||
// Create Equalizer and Visualizer on a new thread as this can potentially take some time
|
||||
Thread {
|
||||
EqualizerController.create(context, mediaPlayer)
|
||||
VisualizerController.create(mediaPlayer)
|
||||
}.start()
|
||||
|
||||
wakeLock.setReferenceCounted(false)
|
||||
Timber.i("LocalMediaPlayer created")
|
||||
}
|
||||
|
||||
fun release() {
|
||||
// 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.
|
||||
reset()
|
||||
try {
|
||||
val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId)
|
||||
i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
||||
context.sendBroadcast(i)
|
||||
EqualizerController.release()
|
||||
VisualizerController.release()
|
||||
mediaPlayer.release()
|
||||
|
||||
mediaPlayer = MediaPlayer()
|
||||
|
||||
if (nextMediaPlayer != null) {
|
||||
nextMediaPlayer!!.release()
|
||||
}
|
||||
mediaPlayerLooper!!.quit()
|
||||
if (bufferTask != null) {
|
||||
bufferTask!!.cancel()
|
||||
}
|
||||
if (nextPlayingTask != null) {
|
||||
nextPlayingTask!!.cancel()
|
||||
}
|
||||
|
||||
wakeLock.release()
|
||||
} catch (exception: Throwable) {
|
||||
Timber.w(exception, "LocalMediaPlayer onDestroy exception: ")
|
||||
}
|
||||
Timber.i("LocalMediaPlayer destroyed")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
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()
|
||||
}
|
||||
|
||||
RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, track))
|
||||
|
||||
if (playerState === PlayerState.STARTED && positionCache == null) {
|
||||
positionCache = PositionCache()
|
||||
val thread = Thread(positionCache)
|
||||
thread.start()
|
||||
} else if (playerState !== PlayerState.STARTED && positionCache != null) {
|
||||
positionCache!!.stop()
|
||||
positionCache = null
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Set the current playing file. It's called with null to reset the player.
|
||||
*/
|
||||
@Synchronized
|
||||
fun setCurrentPlaying(currentPlaying: DownloadFile?) {
|
||||
// In some cases this function is called twice
|
||||
if (this.currentPlaying == currentPlaying) return
|
||||
this.currentPlaying = currentPlaying
|
||||
RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, currentPlaying))
|
||||
}
|
||||
|
||||
/*
|
||||
* Set the next playing file. nextToPlay cannot be null
|
||||
*/
|
||||
@Synchronized
|
||||
fun setNextPlaying(nextToPlay: DownloadFile) {
|
||||
nextPlaying = nextToPlay
|
||||
nextPlayingTask = CheckCompletionTask(nextPlaying)
|
||||
nextPlayingTask?.start()
|
||||
}
|
||||
|
||||
/*
|
||||
* Clear the next playing file. setIdle controls whether the playerState is affected as well
|
||||
*/
|
||||
@Synchronized
|
||||
fun clearNextPlaying(setIdle: Boolean) {
|
||||
nextSetup = false
|
||||
nextPlaying = null
|
||||
if (nextPlayingTask != null) {
|
||||
nextPlayingTask!!.cancel()
|
||||
nextPlayingTask = null
|
||||
}
|
||||
|
||||
if (setIdle) {
|
||||
setNextPlayerState(PlayerState.IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setNextPlayerState(playerState: PlayerState) {
|
||||
Timber.i("Next: %s -> %s (%s)", nextPlayerState.name, playerState.name, nextPlaying)
|
||||
nextPlayerState = playerState
|
||||
}
|
||||
|
||||
/*
|
||||
* Public method to play a given file.
|
||||
* Optionally specify a position to start at.
|
||||
*/
|
||||
@Synchronized
|
||||
@JvmOverloads
|
||||
fun play(fileToPlay: DownloadFile?, position: Int = 0, autoStart: Boolean = true) {
|
||||
if (nextPlayingTask != null) {
|
||||
nextPlayingTask!!.cancel()
|
||||
nextPlayingTask = null
|
||||
}
|
||||
setCurrentPlaying(fileToPlay)
|
||||
|
||||
if (fileToPlay != null) {
|
||||
bufferAndPlay(fileToPlay, position, autoStart)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun playNext() {
|
||||
if (nextMediaPlayer == null || nextPlaying == null) return
|
||||
|
||||
mediaPlayer = nextMediaPlayer!!
|
||||
|
||||
setCurrentPlaying(nextPlaying)
|
||||
setPlayerState(PlayerState.STARTED, currentPlaying)
|
||||
|
||||
attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false)
|
||||
|
||||
postRunnable(onNextSongRequested)
|
||||
|
||||
// Proxy should not be being used here since the next player was already setup to play
|
||||
proxy?.stop()
|
||||
proxy = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pause() {
|
||||
try {
|
||||
mediaPlayer.pause()
|
||||
} catch (x: Exception) {
|
||||
handleError(x)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
try {
|
||||
mediaPlayer.start()
|
||||
} catch (x: Exception) {
|
||||
handleError(x)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun seekTo(position: Int) {
|
||||
try {
|
||||
mediaPlayer.seekTo(position)
|
||||
cachedPosition = position
|
||||
} catch (x: Exception) {
|
||||
handleError(x)
|
||||
}
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val playerPosition: Int
|
||||
get() = try {
|
||||
when (playerState) {
|
||||
PlayerState.IDLE -> 0
|
||||
PlayerState.DOWNLOADING -> 0
|
||||
PlayerState.PREPARING -> 0
|
||||
else -> cachedPosition
|
||||
}
|
||||
} catch (x: Exception) {
|
||||
handleError(x)
|
||||
0
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val playerDuration: Int
|
||||
get() {
|
||||
if (currentPlaying != null) {
|
||||
val duration = currentPlaying!!.track.duration
|
||||
if (duration != null) {
|
||||
return duration * 1000
|
||||
}
|
||||
}
|
||||
if (playerState !== PlayerState.IDLE &&
|
||||
playerState !== PlayerState.DOWNLOADING &&
|
||||
playerState !== PlayerState.PREPARING
|
||||
) {
|
||||
try {
|
||||
return mediaPlayer.duration
|
||||
} catch (x: Exception) {
|
||||
handleError(x)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
mediaPlayer.setVolume(volume, volume)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) {
|
||||
if (playerState !== PlayerState.PREPARED && !fileToPlay.isWorkDone) {
|
||||
reset()
|
||||
bufferTask = BufferTask(fileToPlay, position, autoStart)
|
||||
bufferTask!!.start()
|
||||
} else {
|
||||
doPlay(fileToPlay, position, autoStart)
|
||||
}
|
||||
}
|
||||
|
||||
@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...
|
||||
resetMediaPlayer()
|
||||
|
||||
try {
|
||||
downloadFile.setPlaying(false)
|
||||
|
||||
val file = Storage.getFromPath(downloadFile.completeOrPartialFile)
|
||||
val partial = !downloadFile.isCompleteFileAvailable
|
||||
|
||||
// TODO this won't work with SAF, we should use something else, e.g. a recent list
|
||||
// downloadFile.updateModificationDate()
|
||||
mediaPlayer.setOnCompletionListener(null)
|
||||
|
||||
setAudioAttributes(mediaPlayer)
|
||||
|
||||
var streamUrl: String? = null
|
||||
if (partial) {
|
||||
if (proxy == null) {
|
||||
proxy = StreamProxy(object : Supplier<DownloadFile?>() {
|
||||
override fun get(): DownloadFile {
|
||||
return currentPlaying!!
|
||||
}
|
||||
})
|
||||
proxy!!.start()
|
||||
}
|
||||
streamUrl = String.format(
|
||||
Locale.getDefault(), "http://127.0.0.1:%d/%s",
|
||||
proxy!!.port, URLEncoder.encode(file!!.path, Constants.UTF_8)
|
||||
)
|
||||
Timber.i("Data Source: %s", streamUrl)
|
||||
} else if (proxy != null) {
|
||||
proxy?.stop()
|
||||
proxy = null
|
||||
}
|
||||
|
||||
Timber.i("Preparing media player")
|
||||
|
||||
if (streamUrl != null) {
|
||||
Timber.v("LocalMediaPlayer doPlay dataSource: %s", streamUrl)
|
||||
mediaPlayer.setDataSource(streamUrl)
|
||||
} else {
|
||||
Timber.v("LocalMediaPlayer doPlay Path: %s", file!!.path)
|
||||
val descriptor = file.getDocumentFileDescriptor("r")!!
|
||||
mediaPlayer.setDataSource(descriptor.fileDescriptor)
|
||||
descriptor.close()
|
||||
}
|
||||
|
||||
setPlayerState(PlayerState.PREPARING, downloadFile)
|
||||
|
||||
mediaPlayer.setOnBufferingUpdateListener { mp, percent ->
|
||||
val song = downloadFile.track
|
||||
|
||||
if (percent == 100) {
|
||||
mp.setOnBufferingUpdateListener(null)
|
||||
}
|
||||
|
||||
// The secondary progress is an indicator of how far the song is cached.
|
||||
if (song.transcodedContentType == null && Settings.maxBitRate == 0) {
|
||||
val progress = (percent.toDouble() / 100.toDouble() * playerDuration).toInt()
|
||||
secondaryProgress.postValue(progress)
|
||||
}
|
||||
}
|
||||
|
||||
mediaPlayer.setOnPreparedListener {
|
||||
Timber.i("Media player prepared")
|
||||
setPlayerState(PlayerState.PREPARED, downloadFile)
|
||||
|
||||
// Populate seek bar secondary progress if we have a complete file for consistency
|
||||
if (downloadFile.isWorkDone) {
|
||||
secondaryProgress.postValue(playerDuration)
|
||||
}
|
||||
|
||||
synchronized(this@LocalMediaPlayer) {
|
||||
if (position != 0) {
|
||||
Timber.i("Restarting player from position %d", position)
|
||||
seekTo(position)
|
||||
}
|
||||
cachedPosition = position
|
||||
if (start) {
|
||||
mediaPlayer.start()
|
||||
setPlayerState(PlayerState.STARTED, downloadFile)
|
||||
} else {
|
||||
setPlayerState(PlayerState.PAUSED, downloadFile)
|
||||
}
|
||||
}
|
||||
|
||||
postRunnable {
|
||||
onPrepared
|
||||
}
|
||||
}
|
||||
|
||||
attachHandlersToPlayer(mediaPlayer, downloadFile, partial)
|
||||
mediaPlayer.prepareAsync()
|
||||
} catch (x: Exception) {
|
||||
handleError(x)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAudioAttributes(player: MediaPlayer) {
|
||||
player.setAudioAttributes(AudioFocusHandler.getAudioAttributes())
|
||||
}
|
||||
|
||||
@Suppress("ComplexCondition")
|
||||
@Synchronized
|
||||
private fun setupNext(downloadFile: DownloadFile) {
|
||||
try {
|
||||
val file = Storage.getFromPath(downloadFile.completeOrPartialFile)
|
||||
|
||||
// Release the media player if it is not our active player
|
||||
if (nextMediaPlayer != null && nextMediaPlayer != mediaPlayer) {
|
||||
nextMediaPlayer!!.setOnCompletionListener(null)
|
||||
nextMediaPlayer!!.release()
|
||||
nextMediaPlayer = null
|
||||
}
|
||||
nextMediaPlayer = MediaPlayer()
|
||||
nextMediaPlayer!!.setWakeMode(context, PARTIAL_WAKE_LOCK)
|
||||
|
||||
setAudioAttributes(nextMediaPlayer!!)
|
||||
|
||||
// This has nothing to do with the MediaSession, it is used to associate
|
||||
// the equalizer or visualizer with the player
|
||||
try {
|
||||
nextMediaPlayer!!.audioSessionId = mediaPlayer.audioSessionId
|
||||
} catch (ignored: Throwable) {
|
||||
}
|
||||
|
||||
Timber.v("LocalMediaPlayer setupNext Path: %s", file!!.path)
|
||||
val descriptor = file.getDocumentFileDescriptor("r")!!
|
||||
nextMediaPlayer!!.setDataSource(descriptor.fileDescriptor)
|
||||
descriptor.close()
|
||||
|
||||
setNextPlayerState(PlayerState.PREPARING)
|
||||
nextMediaPlayer!!.setOnPreparedListener {
|
||||
try {
|
||||
setNextPlayerState(PlayerState.PREPARED)
|
||||
if (Settings.gaplessPlayback &&
|
||||
(playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED)
|
||||
) {
|
||||
mediaPlayer.setNextMediaPlayer(nextMediaPlayer)
|
||||
nextSetup = true
|
||||
}
|
||||
} catch (x: Exception) {
|
||||
handleErrorNext(x)
|
||||
}
|
||||
}
|
||||
nextMediaPlayer!!.setOnErrorListener { _, what, extra ->
|
||||
Timber.w("Error on playing next (%d, %d): %s", what, extra, downloadFile)
|
||||
true
|
||||
}
|
||||
nextMediaPlayer!!.prepareAsync()
|
||||
} catch (x: Exception) {
|
||||
handleErrorNext(x)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachHandlersToPlayer(
|
||||
mediaPlayer: MediaPlayer,
|
||||
downloadFile: DownloadFile,
|
||||
isPartial: Boolean
|
||||
) {
|
||||
mediaPlayer.setOnErrorListener { _, what, extra ->
|
||||
Timber.w("Error on playing file (%d, %d): %s", what, extra, downloadFile)
|
||||
val pos = cachedPosition
|
||||
reset()
|
||||
downloadFile.setPlaying(false)
|
||||
doPlay(downloadFile, pos, true)
|
||||
downloadFile.setPlaying(true)
|
||||
true
|
||||
}
|
||||
|
||||
var duration = 0
|
||||
if (downloadFile.track.duration != null) {
|
||||
duration = downloadFile.track.duration!! * 1000
|
||||
}
|
||||
|
||||
mediaPlayer.setOnCompletionListener(object : OnCompletionListener {
|
||||
override fun onCompletion(mediaPlayer: MediaPlayer) {
|
||||
// Acquire a temporary wakelock, since when we return from
|
||||
// this callback the MediaPlayer will release its wakelock
|
||||
// and allow the device to go to sleep.
|
||||
wakeLock.acquire(60000)
|
||||
val pos = cachedPosition
|
||||
Timber.i("Ending position %d of %d", pos, duration)
|
||||
|
||||
if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) {
|
||||
setPlayerState(PlayerState.COMPLETED, downloadFile)
|
||||
if (Settings.gaplessPlayback &&
|
||||
nextPlaying != null &&
|
||||
nextPlayerState === PlayerState.PREPARED
|
||||
) {
|
||||
if (nextSetup) {
|
||||
nextSetup = false
|
||||
}
|
||||
playNext()
|
||||
} else {
|
||||
if (onSongCompleted != null) {
|
||||
val mainHandler = Handler(context.mainLooper)
|
||||
val myRunnable = Runnable { onSongCompleted!!(currentPlaying) }
|
||||
mainHandler.post(myRunnable)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
synchronized(this) {
|
||||
if (downloadFile.isWorkDone) {
|
||||
// Complete was called early even though file is fully buffered
|
||||
Timber.i("Requesting restart from %d of %d", pos, duration)
|
||||
reset()
|
||||
downloadFile.setPlaying(false)
|
||||
doPlay(downloadFile, pos, true)
|
||||
downloadFile.setPlaying(true)
|
||||
} else {
|
||||
Timber.i("Requesting restart from %d of %d", pos, duration)
|
||||
reset()
|
||||
bufferTask = BufferTask(downloadFile, pos)
|
||||
bufferTask!!.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
if (bufferTask != null) {
|
||||
bufferTask!!.cancel()
|
||||
}
|
||||
|
||||
resetMediaPlayer()
|
||||
|
||||
try {
|
||||
setPlayerState(PlayerState.IDLE, currentPlaying)
|
||||
mediaPlayer.setOnErrorListener(null)
|
||||
mediaPlayer.setOnCompletionListener(null)
|
||||
} catch (x: Exception) {
|
||||
handleError(x)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun resetMediaPlayer() {
|
||||
try {
|
||||
mediaPlayer.reset()
|
||||
} catch (x: Exception) {
|
||||
Timber.w(x, "MediaPlayer was released but LocalMediaPlayer was not destroyed")
|
||||
|
||||
// Recreate MediaPlayer
|
||||
mediaPlayer = MediaPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class BufferTask(
|
||||
private val downloadFile: DownloadFile,
|
||||
private val position: Int,
|
||||
private val autoStart: Boolean = true
|
||||
) : CancellableTask() {
|
||||
private val expectedFileSize: Long
|
||||
private val partialFile: String = downloadFile.partialFile
|
||||
|
||||
override fun execute() {
|
||||
setPlayerState(PlayerState.DOWNLOADING, downloadFile)
|
||||
while (!bufferComplete() && !isOffline()) {
|
||||
Util.sleepQuietly(1000L)
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
doPlay(downloadFile, position, autoStart)
|
||||
}
|
||||
|
||||
private fun bufferComplete(): Boolean {
|
||||
val completeFileAvailable = downloadFile.isWorkDone
|
||||
val size = Storage.getFromPath(partialFile)?.length ?: 0
|
||||
|
||||
Timber.i(
|
||||
"Buffering %s (%d/%d, %s)",
|
||||
partialFile, size, expectedFileSize, completeFileAvailable
|
||||
)
|
||||
|
||||
return completeFileAvailable || size >= expectedFileSize
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return String.format("BufferTask (%s)", downloadFile)
|
||||
}
|
||||
|
||||
init {
|
||||
var bufferLength = Settings.bufferLength.toLong()
|
||||
if (bufferLength == 0L) {
|
||||
// Set to seconds in a day, basically infinity
|
||||
bufferLength = 86400L
|
||||
}
|
||||
|
||||
// Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to.
|
||||
val bitRate = downloadFile.getBitRate()
|
||||
val byteCount = max(100000, bitRate * 1024L / 8L * bufferLength)
|
||||
|
||||
// Find out how large the file should grow before resuming playback.
|
||||
Timber.i("Buffering from position %d and bitrate %d", position, bitRate)
|
||||
expectedFileSize = position * bitRate / 8 + byteCount
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CheckCompletionTask(downloadFile: DownloadFile?) : CancellableTask() {
|
||||
private val downloadFile: DownloadFile?
|
||||
private val partialFile: String?
|
||||
override fun execute() {
|
||||
Thread.currentThread().name = "CheckCompletionTask"
|
||||
if (downloadFile == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Do an initial sleep so this prepare can't compete with main prepare
|
||||
Util.sleepQuietly(5000L)
|
||||
while (!bufferComplete()) {
|
||||
Util.sleepQuietly(5000L)
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Start the setup of the next media player
|
||||
mediaPlayerHandler!!.post { setupNext(downloadFile) }
|
||||
}
|
||||
|
||||
private fun bufferComplete(): Boolean {
|
||||
val completeFileAvailable = downloadFile!!.isWorkDone
|
||||
val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED)
|
||||
|
||||
val length = if (partialFile == null) 0
|
||||
else Storage.getFromPath(partialFile)?.length ?: 0
|
||||
|
||||
Timber.i("Buffering next %s (%d)", partialFile, length)
|
||||
|
||||
return completeFileAvailable && state
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return String.format("CheckCompletionTask (%s)", downloadFile)
|
||||
}
|
||||
|
||||
init {
|
||||
setNextPlayerState(PlayerState.IDLE)
|
||||
this.downloadFile = downloadFile
|
||||
partialFile = downloadFile?.partialFile
|
||||
}
|
||||
}
|
||||
|
||||
private inner class PositionCache : Runnable {
|
||||
var isRunning = true
|
||||
fun stop() {
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
Thread.currentThread().name = "PositionCache"
|
||||
|
||||
// Stop checking position before the song reaches completion
|
||||
while (isRunning) {
|
||||
try {
|
||||
if (playerState === PlayerState.STARTED) {
|
||||
synchronized(playerState) {
|
||||
if (playerState === PlayerState.STARTED) {
|
||||
cachedPosition = mediaPlayer.currentPosition
|
||||
}
|
||||
}
|
||||
RxBus.playbackPositionPublisher.onNext(cachedPosition)
|
||||
}
|
||||
Util.sleepQuietly(100L)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Crashed getting current position")
|
||||
isRunning = false
|
||||
positionCache = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleError(x: Exception) {
|
||||
Timber.w(x, "Media player error")
|
||||
try {
|
||||
mediaPlayer.reset()
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Exception encountered when resetting media player")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleErrorNext(x: Exception) {
|
||||
Timber.w(x, "Next Media player error")
|
||||
nextMediaPlayer!!.reset()
|
||||
}
|
||||
|
||||
private fun postRunnable(runnable: Runnable?) {
|
||||
if (runnable != null) {
|
||||
val mainHandler = Handler(context.mainLooper)
|
||||
val myRunnable = Runnable { runnable.run() }
|
||||
mainHandler.post(myRunnable)
|
||||
}
|
||||
}
|
||||
}
|
@ -6,20 +6,34 @@
|
||||
*/
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.STATE_BUFFERING
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.domain.RepeatMode
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.executeOnStartedMediaPlayerService
|
||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.getInstance
|
||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.runningInstance
|
||||
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
||||
import org.moire.ultrasonic.playback.PlaybackService
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
|
||||
import org.moire.ultrasonic.service.DownloadService.Companion.getInstance
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
@ -32,8 +46,8 @@ class MediaPlayerController(
|
||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||
private val downloader: Downloader,
|
||||
private val shufflePlayBuffer: ShufflePlayBuffer,
|
||||
private val localMediaPlayer: LocalMediaPlayer
|
||||
private val legacyPlaylistManager: LegacyPlaylistManager,
|
||||
val context: Context
|
||||
) : KoinComponent {
|
||||
|
||||
private var created = false
|
||||
@ -42,22 +56,142 @@ class MediaPlayerController(
|
||||
var showVisualization = false
|
||||
private var autoPlayStart = false
|
||||
|
||||
private val scrobbler = Scrobbler()
|
||||
|
||||
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private var sessionToken =
|
||||
SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
|
||||
private var mediaControllerFuture = MediaController.Builder(
|
||||
context,
|
||||
sessionToken
|
||||
).buildAsync()
|
||||
|
||||
var controller: MediaController? = null
|
||||
|
||||
fun onCreate() {
|
||||
if (created) return
|
||||
externalStorageMonitor.onCreate { reset() }
|
||||
isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault
|
||||
|
||||
mediaControllerFuture.addListener({
|
||||
controller = mediaControllerFuture.get()
|
||||
|
||||
controller?.addListener(object : Player.Listener {
|
||||
/*
|
||||
* This will be called everytime the playlist has changed.
|
||||
*/
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
legacyPlaylistManager.rebuildPlaylist(controller!!)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
translatePlaybackState(playbackState = playbackState)
|
||||
playerStateChangedHandler()
|
||||
publishPlaybackState()
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
translatePlaybackState(isPlaying = isPlaying)
|
||||
playerStateChangedHandler()
|
||||
publishPlaybackState()
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
legacyPlaylistManager.updateCurrentPlaying(mediaItem)
|
||||
publishPlaybackState()
|
||||
}
|
||||
})
|
||||
|
||||
//controller?.play()
|
||||
}, MoreExecutors.directExecutor())
|
||||
|
||||
created = true
|
||||
Timber.i("MediaPlayerController created")
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun translatePlaybackState(
|
||||
playbackState: Int = controller?.playbackState ?: 0,
|
||||
isPlaying: Boolean = controller?.isPlaying ?: false
|
||||
) {
|
||||
legacyPlayerState = when (playbackState) {
|
||||
STATE_BUFFERING -> PlayerState.DOWNLOADING
|
||||
Player.STATE_ENDED -> {
|
||||
PlayerState.COMPLETED
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
PlayerState.IDLE
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
if (isPlaying) {
|
||||
PlayerState.STARTED
|
||||
} else {
|
||||
PlayerState.PAUSED
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
PlayerState.IDLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playerStateChangedHandler() {
|
||||
|
||||
val playerState = legacyPlayerState
|
||||
val currentPlaying = legacyPlaylistManager.currentPlaying
|
||||
|
||||
when {
|
||||
playerState === PlayerState.PAUSED -> {
|
||||
// TODO: Save playlist
|
||||
// playbackStateSerializer.serialize(
|
||||
// downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition
|
||||
// )
|
||||
}
|
||||
playerState === PlayerState.STARTED -> {
|
||||
scrobbler.scrobble(currentPlaying, false)
|
||||
}
|
||||
playerState === PlayerState.COMPLETED -> {
|
||||
scrobbler.scrobble(currentPlaying, true)
|
||||
}
|
||||
}
|
||||
|
||||
//Update widget
|
||||
if (currentPlaying != null) {
|
||||
updateWidget(playerState, currentPlaying.track)
|
||||
}
|
||||
|
||||
Timber.d("Processed player state change")
|
||||
}
|
||||
|
||||
private fun publishPlaybackState() {
|
||||
RxBus.playerStatePublisher.onNext(
|
||||
RxBus.StateWithTrack(
|
||||
state = legacyPlayerState,
|
||||
track = legacyPlaylistManager.currentPlaying,
|
||||
index = currentMediaItemIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateWidget(playerState: PlayerState, song: Track?) {
|
||||
val started = playerState === PlayerState.STARTED
|
||||
val context = UApp.applicationContext()
|
||||
|
||||
UltrasonicAppWidgetProvider4X1.getInstance().notifyChange(context, song, started, false)
|
||||
UltrasonicAppWidgetProvider4X2.getInstance().notifyChange(context, song, started, true)
|
||||
UltrasonicAppWidgetProvider4X3.getInstance().notifyChange(context, song, started, false)
|
||||
UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false)
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
if (!created) return
|
||||
val context = UApp.applicationContext()
|
||||
externalStorageMonitor.onDestroy()
|
||||
context.stopService(Intent(context, MediaPlayerService::class.java))
|
||||
context.stopService(Intent(context, DownloadService::class.java))
|
||||
legacyPlaylistManager.onDestroy()
|
||||
downloader.onDestroy()
|
||||
created = false
|
||||
Timber.i("MediaPlayerController destroyed")
|
||||
@ -73,33 +207,30 @@ class MediaPlayerController(
|
||||
) {
|
||||
addToPlaylist(
|
||||
songs,
|
||||
save = false,
|
||||
cachePermanently = false,
|
||||
autoPlay = false,
|
||||
playNext = false,
|
||||
shuffle = false,
|
||||
newPlaylist = newPlaylist
|
||||
)
|
||||
|
||||
if (currentPlayingIndex != -1) {
|
||||
executeOnStartedMediaPlayerService { mediaPlayerService: MediaPlayerService ->
|
||||
mediaPlayerService.play(currentPlayingIndex, autoPlayStart)
|
||||
if (localMediaPlayer.currentPlaying != null) {
|
||||
if (autoPlay && jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.skip(
|
||||
downloader.currentPlayingIndex,
|
||||
currentPlayingPosition / 1000
|
||||
)
|
||||
} else {
|
||||
if (localMediaPlayer.currentPlaying!!.isCompleteFileAvailable) {
|
||||
localMediaPlayer.play(
|
||||
localMediaPlayer.currentPlaying,
|
||||
currentPlayingPosition,
|
||||
autoPlay
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
autoPlayStart = false
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.skip(
|
||||
currentPlayingIndex,
|
||||
currentPlayingPosition / 1000
|
||||
)
|
||||
} else {
|
||||
seekTo(currentPlayingIndex, currentPlayingPosition)
|
||||
}
|
||||
|
||||
if (autoPlay) {
|
||||
prepare()
|
||||
play()
|
||||
}
|
||||
|
||||
autoPlayStart = false
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,93 +241,139 @@ class MediaPlayerController(
|
||||
|
||||
@Synchronized
|
||||
fun play(index: Int) {
|
||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
||||
service.play(index, true)
|
||||
}
|
||||
controller?.seekTo(index, 0L)
|
||||
controller?.play()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun play() {
|
||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
||||
service.play()
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.start()
|
||||
} else {
|
||||
controller?.play()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun prepare() {
|
||||
controller?.prepare()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun resumeOrPlay() {
|
||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
||||
service.resumeOrPlay()
|
||||
}
|
||||
controller?.play()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun togglePlayPause() {
|
||||
if (localMediaPlayer.playerState === PlayerState.IDLE) autoPlayStart = true
|
||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
||||
service.togglePlayPause()
|
||||
if (playbackState == Player.STATE_IDLE) autoPlayStart = true
|
||||
if (controller?.isPlaying == false) {
|
||||
controller?.pause()
|
||||
} else {
|
||||
controller?.play()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
||||
service.start()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun seekTo(position: Int) {
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.seekTo(position)
|
||||
controller?.seekTo(position.toLong())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun seekTo(index: Int, position: Int) {
|
||||
controller?.seekTo(index, position.toLong())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pause() {
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.pause()
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.stop()
|
||||
} else {
|
||||
controller?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.stop()
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.stop()
|
||||
} else {
|
||||
controller?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Synchronized
|
||||
@Deprecated("Use InsertionMode Syntax")
|
||||
@Suppress("LongParameterList")
|
||||
fun addToPlaylist(
|
||||
songs: List<Track?>?,
|
||||
save: Boolean,
|
||||
cachePermanently: Boolean,
|
||||
autoPlay: Boolean,
|
||||
playNext: Boolean,
|
||||
shuffle: Boolean,
|
||||
newPlaylist: Boolean
|
||||
) {
|
||||
if (songs == null) return
|
||||
val filteredSongs = songs.filterNotNull()
|
||||
downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist)
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
if (shuffle) shuffle()
|
||||
val isLastTrack = (downloader.getPlaylist().size - 1 == downloader.currentPlayingIndex)
|
||||
|
||||
if (!playNext && !autoPlay && isLastTrack) {
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.setNextPlaying()
|
||||
val insertionMode = when {
|
||||
newPlaylist -> InsertionMode.CLEAR
|
||||
playNext -> InsertionMode.AFTER_CURRENT
|
||||
else -> InsertionMode.APPEND
|
||||
}
|
||||
|
||||
val filteredSongs = songs.filterNotNull()
|
||||
|
||||
addToPlaylist(
|
||||
filteredSongs, cachePermanently, autoPlay, shuffle, insertionMode
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addToPlaylist(
|
||||
songs: List<Track>,
|
||||
cachePermanently: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
insertionMode: InsertionMode
|
||||
) {
|
||||
var insertAt = 0
|
||||
|
||||
if (insertionMode == InsertionMode.CLEAR) {
|
||||
clear()
|
||||
}
|
||||
|
||||
when (insertionMode) {
|
||||
InsertionMode.CLEAR -> clear()
|
||||
InsertionMode.APPEND -> insertAt = mediaItemCount
|
||||
InsertionMode.AFTER_CURRENT -> insertAt = currentMediaItemIndex
|
||||
}
|
||||
|
||||
val mediaItems: List<MediaItem> = songs.map {
|
||||
val downloadFile = downloader.getDownloadFileForSong(it)
|
||||
if (cachePermanently) downloadFile.shouldSave = true
|
||||
val result = it.toMediaItem()
|
||||
legacyPlaylistManager.addToCache(result, downloader.getDownloadFileForSong(it))
|
||||
result
|
||||
}
|
||||
|
||||
controller?.addMediaItems(insertAt, mediaItems)
|
||||
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
|
||||
if (shuffle) isShufflePlayEnabled = true
|
||||
|
||||
if (autoPlay) {
|
||||
prepare()
|
||||
play(0)
|
||||
} else {
|
||||
if (localMediaPlayer.currentPlaying == null && downloader.getPlaylist().isNotEmpty()) {
|
||||
localMediaPlayer.currentPlaying = downloader.getPlaylist()[0]
|
||||
downloader.getPlaylist()[0].setPlaying(true)
|
||||
}
|
||||
downloader.checkDownloads()
|
||||
}
|
||||
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
legacyPlaylistManager.playlist,
|
||||
currentMediaItemIndex,
|
||||
playerPosition
|
||||
)
|
||||
}
|
||||
@ -206,77 +383,60 @@ class MediaPlayerController(
|
||||
if (songs == null) return
|
||||
val filteredSongs = songs.filterNotNull()
|
||||
downloader.downloadBackground(filteredSongs, save)
|
||||
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
legacyPlaylistManager.playlist,
|
||||
currentMediaItemIndex,
|
||||
playerPosition
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setCurrentPlaying(index: Int) {
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.setCurrentPlaying(index)
|
||||
}
|
||||
|
||||
fun stopJukeboxService() {
|
||||
jukeboxMediaPlayer.stopJukeboxService()
|
||||
}
|
||||
|
||||
@set:Synchronized
|
||||
var isShufflePlayEnabled: Boolean
|
||||
get() = shufflePlayBuffer.isEnabled
|
||||
get() = controller?.shuffleModeEnabled == true
|
||||
set(enabled) {
|
||||
shufflePlayBuffer.isEnabled = enabled
|
||||
controller?.shuffleModeEnabled = enabled
|
||||
if (enabled) {
|
||||
clear()
|
||||
downloader.checkDownloads()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun shuffle() {
|
||||
downloader.shuffle()
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.setNextPlaying()
|
||||
fun toggleShuffle() {
|
||||
isShufflePlayEnabled = !isShufflePlayEnabled
|
||||
}
|
||||
|
||||
val bufferedPercentage: Int
|
||||
get() = controller?.bufferedPercentage ?: 0
|
||||
|
||||
@Synchronized
|
||||
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
||||
downloader.moveItemInPlaylist(oldPos, newPos)
|
||||
controller?.moveMediaItem(oldPos, newPos)
|
||||
}
|
||||
|
||||
@set:Synchronized
|
||||
var repeatMode: RepeatMode
|
||||
get() = Settings.repeatMode
|
||||
set(repeatMode) {
|
||||
Settings.repeatMode = repeatMode
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.setNextPlaying()
|
||||
var repeatMode: Int
|
||||
get() = controller?.repeatMode ?: 0
|
||||
set(newMode) {
|
||||
controller?.repeatMode = newMode
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@JvmOverloads
|
||||
fun clear(serialize: Boolean = true) {
|
||||
val mediaPlayerService = runningInstance
|
||||
if (mediaPlayerService != null) {
|
||||
mediaPlayerService.clear(serialize)
|
||||
} else {
|
||||
// If no MediaPlayerService is available, just empty the playlist
|
||||
downloader.clearPlaylist()
|
||||
if (serialize) {
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex, playerPosition
|
||||
)
|
||||
}
|
||||
|
||||
controller?.clearMediaItems()
|
||||
|
||||
if (controller != null && serialize) {
|
||||
playbackStateSerializer.serialize(
|
||||
listOf(), -1, 0
|
||||
)
|
||||
}
|
||||
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
}
|
||||
|
||||
@ -289,11 +449,12 @@ class MediaPlayerController(
|
||||
fun clearIncomplete() {
|
||||
reset()
|
||||
|
||||
downloader.clearIncomplete()
|
||||
downloader.clearActiveDownloads()
|
||||
downloader.clearBackground()
|
||||
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
legacyPlaylistManager.playlist,
|
||||
currentMediaItemIndex,
|
||||
playerPosition
|
||||
)
|
||||
|
||||
@ -301,26 +462,17 @@ class MediaPlayerController(
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
// TODO: If a playlist contains an item twice, this call will wrongly remove all
|
||||
// FIXME
|
||||
// With the new API we can only remove by index!!
|
||||
fun removeFromPlaylist(downloadFile: DownloadFile) {
|
||||
if (downloadFile == localMediaPlayer.currentPlaying) {
|
||||
reset()
|
||||
currentPlaying = null
|
||||
}
|
||||
downloader.removeFromPlaylist(downloadFile)
|
||||
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
legacyPlaylistManager.playlist,
|
||||
legacyPlaylistManager.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
|
||||
if (downloadFile == localMediaPlayer.nextPlaying) {
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.setNextPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@ -341,80 +493,56 @@ class MediaPlayerController(
|
||||
|
||||
@Synchronized
|
||||
fun previous() {
|
||||
val index = downloader.currentPlayingIndex
|
||||
if (index == -1) {
|
||||
return
|
||||
}
|
||||
|
||||
// Restart song if played more than five seconds.
|
||||
@Suppress("MagicNumber")
|
||||
if (playerPosition > 5000 || index == 0) {
|
||||
play(index)
|
||||
} else {
|
||||
play(index - 1)
|
||||
}
|
||||
controller?.seekToPrevious()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
operator fun next() {
|
||||
val index = downloader.currentPlayingIndex
|
||||
if (index != -1) {
|
||||
when (repeatMode) {
|
||||
RepeatMode.SINGLE, RepeatMode.OFF -> {
|
||||
// Play next if exists
|
||||
if (index + 1 >= 0 && index + 1 < downloader.getPlaylist().size) {
|
||||
play(index + 1)
|
||||
}
|
||||
}
|
||||
RepeatMode.ALL -> {
|
||||
play((index + 1) % downloader.getPlaylist().size)
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
controller?.seekToNext()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
val mediaPlayerService = runningInstance
|
||||
if (mediaPlayerService != null) localMediaPlayer.reset()
|
||||
controller?.clearMediaItems()
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val playerPosition: Int
|
||||
get() {
|
||||
val mediaPlayerService = runningInstance ?: return 0
|
||||
return mediaPlayerService.playerPosition
|
||||
return if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.positionSeconds * 1000
|
||||
} else {
|
||||
controller?.currentPosition?.toInt() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val playerDuration: Int
|
||||
get() {
|
||||
val mediaPlayerService = runningInstance ?: return 0
|
||||
return mediaPlayerService.playerDuration
|
||||
return controller?.duration?.toInt() ?: return 0
|
||||
}
|
||||
|
||||
@Deprecated("Use Controller.playbackState and Controller.isPlaying")
|
||||
@set:Synchronized
|
||||
var playerState: PlayerState
|
||||
get() = localMediaPlayer.playerState
|
||||
set(state) {
|
||||
val mediaPlayerService = runningInstance
|
||||
if (mediaPlayerService != null)
|
||||
localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying)
|
||||
}
|
||||
var legacyPlayerState: PlayerState = PlayerState.IDLE
|
||||
|
||||
val playbackState: Int
|
||||
get() = controller?.playbackState ?: 0
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = controller?.isPlaying ?: false
|
||||
|
||||
@set:Synchronized
|
||||
var isJukeboxEnabled: Boolean
|
||||
get() = jukeboxMediaPlayer.isEnabled
|
||||
set(jukeboxEnabled) {
|
||||
jukeboxMediaPlayer.isEnabled = jukeboxEnabled
|
||||
playerState = PlayerState.IDLE
|
||||
|
||||
if (jukeboxEnabled) {
|
||||
jukeboxMediaPlayer.startJukeboxService()
|
||||
reset()
|
||||
|
||||
// Cancel current download, if necessary.
|
||||
// Cancel current downloads
|
||||
downloader.clearActiveDownloads()
|
||||
} else {
|
||||
jukeboxMediaPlayer.stopJukeboxService()
|
||||
@ -441,19 +569,12 @@ class MediaPlayerController(
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
if (runningInstance != null) localMediaPlayer.setVolume(volume)
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
runningInstance?.updateNotification(
|
||||
localMediaPlayer.playerState,
|
||||
localMediaPlayer.currentPlaying
|
||||
)
|
||||
controller?.volume = volume
|
||||
}
|
||||
|
||||
fun toggleSongStarred() {
|
||||
if (localMediaPlayer.currentPlaying == null) return
|
||||
val song = localMediaPlayer.currentPlaying!!.track
|
||||
if (legacyPlaylistManager.currentPlaying == null) return
|
||||
val song = legacyPlaylistManager.currentPlaying!!.track
|
||||
|
||||
Thread {
|
||||
val musicService = getMusicService()
|
||||
@ -469,15 +590,16 @@ class MediaPlayerController(
|
||||
}.start()
|
||||
|
||||
// Trigger an update
|
||||
localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
||||
// TODO Update Metadata of MediaItem...
|
||||
//localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
||||
song.starred = !song.starred
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
||||
fun setSongRating(rating: Int) {
|
||||
if (!Settings.useFiveStarRating) return
|
||||
if (localMediaPlayer.currentPlaying == null) return
|
||||
val song = localMediaPlayer.currentPlaying!!.track
|
||||
if (legacyPlaylistManager.currentPlaying == null) return
|
||||
val song = legacyPlaylistManager.currentPlaying!!.track
|
||||
song.userRating = rating
|
||||
Thread {
|
||||
try {
|
||||
@ -487,27 +609,33 @@ class MediaPlayerController(
|
||||
}
|
||||
}.start()
|
||||
// TODO this would be better handled with a Rx command
|
||||
updateNotification()
|
||||
//updateNotification()
|
||||
}
|
||||
|
||||
@set:Synchronized
|
||||
var currentPlaying: DownloadFile?
|
||||
get() = localMediaPlayer.currentPlaying
|
||||
set(currentPlaying) {
|
||||
if (runningInstance != null) localMediaPlayer.setCurrentPlaying(currentPlaying)
|
||||
}
|
||||
val currentMediaItem: MediaItem?
|
||||
get() = controller?.currentMediaItem
|
||||
|
||||
val currentMediaItemIndex: Int
|
||||
get() = controller?.currentMediaItemIndex ?: -1
|
||||
|
||||
@Deprecated("Use currentMediaItem")
|
||||
val currentPlayingLegacy: DownloadFile?
|
||||
get() = legacyPlaylistManager.currentPlaying
|
||||
|
||||
val mediaItemCount: Int
|
||||
get() = controller?.mediaItemCount ?: 0
|
||||
|
||||
@Deprecated("Use mediaItemCount")
|
||||
val playlistSize: Int
|
||||
get() = downloader.getPlaylist().size
|
||||
|
||||
val currentPlayingNumberOnPlaylist: Int
|
||||
get() = downloader.currentPlayingIndex
|
||||
get() = legacyPlaylistManager.playlist.size
|
||||
|
||||
@Deprecated("Use native APIs")
|
||||
val playList: List<DownloadFile>
|
||||
get() = downloader.getPlaylist()
|
||||
get() = legacyPlaylistManager.playlist
|
||||
|
||||
@Deprecated("Use timeline")
|
||||
val playListDuration: Long
|
||||
get() = downloader.downloadListDuration
|
||||
get() = legacyPlaylistManager.playlistDuration
|
||||
|
||||
fun getDownloadFileForSong(song: Track): DownloadFile {
|
||||
return downloader.getDownloadFileForSong(song)
|
||||
@ -516,4 +644,30 @@ class MediaPlayerController(
|
||||
init {
|
||||
Timber.i("MediaPlayerController constructed")
|
||||
}
|
||||
|
||||
enum class InsertionMode {
|
||||
CLEAR, APPEND, AFTER_CURRENT
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Track.toMediaItem(): MediaItem {
|
||||
|
||||
val filePath = FileUtil.getSongFile(this)
|
||||
val bitrate = Settings.maxBitRate
|
||||
val uri = "$id|$bitrate|$filePath"
|
||||
|
||||
val metadata = MediaMetadata.Builder()
|
||||
metadata.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.setMediaUri(uri.toUri())
|
||||
.setAlbumArtist(artist)
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMediaId(id)
|
||||
.setMediaMetadata(metadata.build())
|
||||
|
||||
return mediaItem.build()
|
||||
}
|
@ -8,32 +8,23 @@
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
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
|
||||
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.Settings
|
||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class is responsible for handling received events for the Media Player implementation
|
||||
*
|
||||
* @author Sindre Mehus
|
||||
*/
|
||||
class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val downloader by inject<Downloader>()
|
||||
|
||||
private var created = false
|
||||
private var headsetEventReceiver: BroadcastReceiver? = null
|
||||
@ -50,11 +41,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
return
|
||||
}
|
||||
|
||||
mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe {
|
||||
handleKeyEvent(it)
|
||||
}
|
||||
|
||||
registerHeadsetReceiver()
|
||||
mediaPlayerController.onCreate()
|
||||
if (autoPlay) mediaPlayerController.preload()
|
||||
|
||||
@ -68,13 +54,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
false
|
||||
)
|
||||
|
||||
// Work-around: Serialize again, as the restore() method creates a
|
||||
// serialization without current playing info.
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
mediaPlayerController.playerPosition
|
||||
)
|
||||
afterCreated?.run()
|
||||
}
|
||||
|
||||
@ -87,11 +66,12 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
|
||||
if (!created) return
|
||||
|
||||
playbackStateSerializer.serializeNow(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
mediaPlayerController.playerPosition
|
||||
)
|
||||
// TODO
|
||||
// playbackStateSerializer.serializeNow(
|
||||
// downloader.getPlaylist(),
|
||||
// downloader.currentPlayingIndex,
|
||||
// mediaPlayerController.playerPosition
|
||||
// )
|
||||
|
||||
mediaPlayerController.clear(false)
|
||||
mediaButtonEventSubscription?.dispose()
|
||||
@ -121,73 +101,19 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Headset Intent Receiver is responsible for resuming playback when a headset is inserted
|
||||
* and pausing it when it is removed.
|
||||
* Unfortunately this Intent can't be registered in the AndroidManifest, so it works only
|
||||
* while Ultrasonic is running.
|
||||
*/
|
||||
private fun registerHeadsetReceiver() {
|
||||
|
||||
val sp = Settings.preferences
|
||||
val context = applicationContext()
|
||||
val spKey = context
|
||||
.getString(R.string.settings_playback_resume_play_on_headphones_plug)
|
||||
|
||||
headsetEventReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val extras = intent.extras ?: return
|
||||
|
||||
Timber.i("Headset event for: %s", extras["name"])
|
||||
|
||||
val state = extras.getInt("state")
|
||||
|
||||
if (state == 0) {
|
||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
||||
mediaPlayerController.pause()
|
||||
}
|
||||
} else if (state == 1) {
|
||||
if (!mediaPlayerController.isJukeboxEnabled &&
|
||||
sp.getBoolean(
|
||||
spKey,
|
||||
false
|
||||
) && mediaPlayerController.playerState === PlayerState.PAUSED
|
||||
) {
|
||||
mediaPlayerController.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val headsetIntentFilter = IntentFilter(AudioManager.ACTION_HEADSET_PLUG)
|
||||
|
||||
applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "ComplexMethod")
|
||||
@Suppress("MagicNumber")
|
||||
private fun handleKeyEvent(event: KeyEvent) {
|
||||
|
||||
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return
|
||||
|
||||
val keyCode: Int
|
||||
val receivedKeyCode = event.keyCode
|
||||
|
||||
// Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices
|
||||
keyCode = if (Settings.singleButtonPlayPause && (
|
||||
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
||||
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE
|
||||
)
|
||||
) {
|
||||
Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE")
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
||||
} else receivedKeyCode
|
||||
val keyCode: Int = event.keyCode
|
||||
|
||||
val autoStart =
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
||||
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
||||
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
|
||||
// We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start
|
||||
onCreate(autoStart) {
|
||||
@ -197,14 +123,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY ->
|
||||
if (mediaPlayerController.playerState === PlayerState.IDLE) {
|
||||
mediaPlayerController.play()
|
||||
} else if (mediaPlayerController.playerState !== PlayerState.STARTED) {
|
||||
mediaPlayerController.start()
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
|
||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
|
||||
@ -221,21 +140,15 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
/**
|
||||
* This function processes the intent that could come from other applications.
|
||||
*/
|
||||
@Suppress("ComplexMethod")
|
||||
private fun handleUltrasonicIntent(intentAction: String) {
|
||||
|
||||
val isRunning = created
|
||||
|
||||
// If Ultrasonic is not running, do nothing to stop or pause
|
||||
if (
|
||||
!isRunning && (
|
||||
intentAction == Constants.CMD_PAUSE ||
|
||||
intentAction == Constants.CMD_STOP
|
||||
)
|
||||
) return
|
||||
if (!isRunning && (intentAction == Constants.CMD_PAUSE || intentAction == Constants.CMD_STOP))
|
||||
return
|
||||
|
||||
val autoStart =
|
||||
intentAction == Constants.CMD_PLAY ||
|
||||
val autoStart = intentAction == Constants.CMD_PLAY ||
|
||||
intentAction == Constants.CMD_RESUME_OR_PLAY ||
|
||||
intentAction == Constants.CMD_TOGGLEPAUSE ||
|
||||
intentAction == Constants.CMD_PREVIOUS ||
|
||||
@ -253,12 +166,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
Constants.CMD_NEXT -> mediaPlayerController.next()
|
||||
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
|
||||
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
||||
|
||||
Constants.CMD_STOP -> {
|
||||
// TODO: There is a stop() function, shouldn't we use that?
|
||||
mediaPlayerController.pause()
|
||||
mediaPlayerController.seekTo(0)
|
||||
}
|
||||
Constants.CMD_STOP -> mediaPlayerController.stop()
|
||||
Constants.CMD_PAUSE -> mediaPlayerController.pause()
|
||||
}
|
||||
}
|
||||
|
@ -1,769 +0,0 @@
|
||||
/*
|
||||
* MediaPlayerService.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
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
|
||||
import org.moire.ultrasonic.activity.NavigationActivity
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.domain.RepeatMode
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.imageloader.BitmapUtils
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
|
||||
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.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
||||
import org.moire.ultrasonic.util.SimpleServiceBinder
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Android Foreground Service for playing music
|
||||
* while the rest of the Ultrasonic App is in the background.
|
||||
*
|
||||
* "A foreground service is a service that the user is
|
||||
* actively aware of and isn’t a candidate for the system to kill when low on memory."
|
||||
*/
|
||||
@Suppress("LargeClass")
|
||||
class MediaPlayerService : Service() {
|
||||
private val binder: IBinder = SimpleServiceBinder(this)
|
||||
private val scrobbler = Scrobbler()
|
||||
|
||||
private val jukeboxMediaPlayer by inject<JukeboxMediaPlayer>()
|
||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||
private val shufflePlayBuffer by inject<ShufflePlayBuffer>()
|
||||
private val downloader by inject<Downloader>()
|
||||
private val localMediaPlayer by inject<LocalMediaPlayer>()
|
||||
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 var rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
private var currentPlayerState: PlayerState? = null
|
||||
private var currentTrack: DownloadFile? = null
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
shufflePlayBuffer.onCreate()
|
||||
localMediaPlayer.init()
|
||||
|
||||
setupOnSongCompletedHandler()
|
||||
|
||||
localMediaPlayer.onPrepared = {
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex,
|
||||
playerPosition
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() }
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
instance = null
|
||||
try {
|
||||
mediaSessionHandler.release()
|
||||
rxBusSubscription.dispose()
|
||||
|
||||
localMediaPlayer.release()
|
||||
downloader.stop()
|
||||
shufflePlayBuffer.onDestroy()
|
||||
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
} catch (ignored: Throwable) {
|
||||
}
|
||||
Timber.i("MediaPlayerService stopped")
|
||||
}
|
||||
|
||||
private fun stopIfIdle() {
|
||||
synchronized(instanceLock) {
|
||||
// currentPlaying could be changed from another thread in the meantime,
|
||||
// so check again before stopping for good
|
||||
if (localMediaPlayer.currentPlaying == null ||
|
||||
localMediaPlayer.playerState === PlayerState.STOPPED
|
||||
) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyDownloaderStopped() {
|
||||
// TODO It would be nice to know if the service really can be stopped instead of just
|
||||
// checking if it is idle once...
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
handler.postDelayed({ stopIfIdle() }, 1000)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun seekTo(position: Int) {
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
// TODO These APIs should be more aligned
|
||||
val seconds = position / 1000
|
||||
jukeboxMediaPlayer.skip(downloader.currentPlayingIndex, seconds)
|
||||
} else {
|
||||
localMediaPlayer.seekTo(position)
|
||||
}
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val playerPosition: Int
|
||||
get() {
|
||||
if (localMediaPlayer.playerState === PlayerState.IDLE ||
|
||||
localMediaPlayer.playerState === PlayerState.DOWNLOADING ||
|
||||
localMediaPlayer.playerState === PlayerState.PREPARING
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
return if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.positionSeconds * 1000
|
||||
} else {
|
||||
localMediaPlayer.playerPosition
|
||||
}
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val playerDuration: Int
|
||||
get() = localMediaPlayer.playerDuration
|
||||
|
||||
@Synchronized
|
||||
fun setCurrentPlaying(currentPlayingIndex: Int) {
|
||||
try {
|
||||
localMediaPlayer.setCurrentPlaying(downloader.getPlaylist()[currentPlayingIndex])
|
||||
} catch (ignored: IndexOutOfBoundsException) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setNextPlaying() {
|
||||
// Download the next few songs if necessary
|
||||
downloader.checkDownloads()
|
||||
|
||||
if (!Settings.gaplessPlayback) {
|
||||
localMediaPlayer.clearNextPlaying(true)
|
||||
return
|
||||
}
|
||||
|
||||
var index = downloader.currentPlayingIndex
|
||||
|
||||
if (index != -1) {
|
||||
when (Settings.repeatMode) {
|
||||
RepeatMode.OFF -> index += 1
|
||||
RepeatMode.ALL -> index = (index + 1) % downloader.getPlaylist().size
|
||||
RepeatMode.SINGLE -> {
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localMediaPlayer.clearNextPlaying(false)
|
||||
if (index < downloader.getPlaylist().size && index != -1) {
|
||||
localMediaPlayer.setNextPlaying(downloader.getPlaylist()[index])
|
||||
} else {
|
||||
localMediaPlayer.clearNextPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun togglePlayPause() {
|
||||
if (localMediaPlayer.playerState === PlayerState.PAUSED ||
|
||||
localMediaPlayer.playerState === PlayerState.COMPLETED ||
|
||||
localMediaPlayer.playerState === PlayerState.STOPPED
|
||||
) {
|
||||
start()
|
||||
} else if (localMediaPlayer.playerState === PlayerState.IDLE) {
|
||||
play()
|
||||
} else if (localMediaPlayer.playerState === PlayerState.STARTED) {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun resumeOrPlay() {
|
||||
if (localMediaPlayer.playerState === PlayerState.PAUSED ||
|
||||
localMediaPlayer.playerState === PlayerState.COMPLETED ||
|
||||
localMediaPlayer.playerState === PlayerState.STOPPED
|
||||
) {
|
||||
start()
|
||||
} else if (localMediaPlayer.playerState === PlayerState.IDLE) {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays either the current song (resume) or the first/next one in queue.
|
||||
*/
|
||||
@Synchronized
|
||||
fun play() {
|
||||
val current = downloader.currentPlayingIndex
|
||||
if (current == -1) {
|
||||
play(0)
|
||||
} else {
|
||||
play(current)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun play(index: Int) {
|
||||
play(index, true)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun play(index: Int, start: Boolean) {
|
||||
Timber.v("play requested for %d", index)
|
||||
if (index < 0 || index >= downloader.getPlaylist().size) {
|
||||
resetPlayback()
|
||||
} else {
|
||||
setCurrentPlaying(index)
|
||||
if (start) {
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.skip(index, 0)
|
||||
} else {
|
||||
localMediaPlayer.play(downloader.getPlaylist()[index])
|
||||
}
|
||||
}
|
||||
setNextPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun resetPlayback() {
|
||||
localMediaPlayer.reset()
|
||||
localMediaPlayer.setCurrentPlaying(null)
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex, playerPosition
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pause() {
|
||||
if (localMediaPlayer.playerState === PlayerState.STARTED) {
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.stop()
|
||||
} else {
|
||||
localMediaPlayer.pause()
|
||||
}
|
||||
localMediaPlayer.setPlayerState(PlayerState.PAUSED, localMediaPlayer.currentPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (localMediaPlayer.playerState === PlayerState.STARTED) {
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.stop()
|
||||
} else {
|
||||
localMediaPlayer.pause()
|
||||
}
|
||||
}
|
||||
localMediaPlayer.setPlayerState(PlayerState.STOPPED, null)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (jukeboxMediaPlayer.isEnabled) {
|
||||
jukeboxMediaPlayer.start()
|
||||
} else {
|
||||
localMediaPlayer.start()
|
||||
}
|
||||
localMediaPlayer.setPlayerState(PlayerState.STARTED, localMediaPlayer.currentPlaying)
|
||||
}
|
||||
|
||||
private fun updateWidget(playerState: PlayerState, song: Track?) {
|
||||
val started = playerState === PlayerState.STARTED
|
||||
val context = this@MediaPlayerService
|
||||
|
||||
UltrasonicAppWidgetProvider4X1.getInstance().notifyChange(context, song, started, false)
|
||||
UltrasonicAppWidgetProvider4X2.getInstance().notifyChange(context, song, started, true)
|
||||
UltrasonicAppWidgetProvider4X3.getInstance().notifyChange(context, song, started, false)
|
||||
UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
val showWhenPaused = playerState !== PlayerState.STOPPED &&
|
||||
Settings.isNotificationAlwaysEnabled
|
||||
|
||||
val show = playerState === PlayerState.STARTED || showWhenPaused
|
||||
val song = currentPlaying?.track
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Util.broadcastPlaybackStatusChange(context, playerState)
|
||||
Util.broadcastA2dpPlayStatusChange(
|
||||
context, playerState, song,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
if (isTrackChanged) {
|
||||
Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying?.track)
|
||||
}
|
||||
|
||||
// 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() {
|
||||
localMediaPlayer.onSongCompleted = { currentPlaying: DownloadFile? ->
|
||||
val index = downloader.currentPlayingIndex
|
||||
|
||||
if (currentPlaying != null) {
|
||||
val song = currentPlaying.track
|
||||
if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) {
|
||||
val musicService = getMusicService()
|
||||
try {
|
||||
musicService.deleteBookmark(song.id)
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index != -1) {
|
||||
when (Settings.repeatMode) {
|
||||
RepeatMode.OFF -> {
|
||||
if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) {
|
||||
if (Settings.shouldClearPlaylist) {
|
||||
clear(true)
|
||||
jukeboxMediaPlayer.updatePlaylist()
|
||||
}
|
||||
resetPlayback()
|
||||
} else {
|
||||
play(index + 1)
|
||||
}
|
||||
}
|
||||
RepeatMode.ALL -> {
|
||||
play((index + 1) % downloader.getPlaylist().size)
|
||||
}
|
||||
RepeatMode.SINGLE -> play(index)
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear(serialize: Boolean) {
|
||||
localMediaPlayer.reset()
|
||||
downloader.clearPlaylist()
|
||||
localMediaPlayer.setCurrentPlaying(null)
|
||||
setNextPlaying()
|
||||
if (serialize) {
|
||||
playbackStateSerializer.serialize(
|
||||
downloader.getPlaylist(),
|
||||
downloader.currentPlayingIndex, playerPosition
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
// The suggested importance of a startForeground service notification is IMPORTANCE_LOW
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NOTIFICATION_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
channel.lightColor = android.R.color.holo_blue_dark
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
channel.setShowBadge(false)
|
||||
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotification(playerState: PlayerState, currentPlaying: DownloadFile?) {
|
||||
val notification = buildForegroundNotification(playerState, currentPlaying)
|
||||
|
||||
if (Settings.isNotificationEnabled) {
|
||||
if (isInForeground) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
} else {
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
Timber.v("Updated notification")
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
isInForeground = true
|
||||
Timber.v("Created Foreground notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method builds a notification, reusing the Notification Builder if possible
|
||||
*/
|
||||
@Suppress("SpreadOperator")
|
||||
private fun buildForegroundNotification(
|
||||
playerState: PlayerState,
|
||||
currentPlaying: DownloadFile?
|
||||
): Notification {
|
||||
|
||||
// Init
|
||||
val context = applicationContext
|
||||
val song = currentPlaying?.track
|
||||
val stopIntent = Util.getPendingIntentForMediaAction(
|
||||
context,
|
||||
KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
100
|
||||
)
|
||||
|
||||
// We should use a single notification builder, otherwise the notification may not be updated
|
||||
if (notificationBuilder == null) {
|
||||
notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
|
||||
// Set some values that never change
|
||||
notificationBuilder!!.setSmallIcon(R.drawable.ic_stat_ultrasonic)
|
||||
notificationBuilder!!.setAutoCancel(false)
|
||||
notificationBuilder!!.setOngoing(true)
|
||||
notificationBuilder!!.setOnlyAlertOnce(true)
|
||||
notificationBuilder!!.setWhen(System.currentTimeMillis())
|
||||
notificationBuilder!!.setShowWhen(false)
|
||||
notificationBuilder!!.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
notificationBuilder!!.priority = NotificationCompat.PRIORITY_LOW
|
||||
|
||||
// Add content intent (when user taps on notification)
|
||||
notificationBuilder!!.setContentIntent(getPendingIntentForContent())
|
||||
|
||||
// This intent is executed when the user closes the notification
|
||||
notificationBuilder!!.setDeleteIntent(stopIntent)
|
||||
}
|
||||
|
||||
// Use the Media Style, to enable native Android support for playback notification
|
||||
val style = androidx.media.app.NotificationCompat.MediaStyle()
|
||||
|
||||
if (mediaSessionToken != null) {
|
||||
style.setMediaSession(mediaSessionToken)
|
||||
}
|
||||
|
||||
// Clear old actions
|
||||
notificationBuilder!!.clearActions()
|
||||
|
||||
if (song != null) {
|
||||
// Add actions
|
||||
val compactActions = addActions(context, notificationBuilder!!, playerState, song)
|
||||
// Configure shortcut actions
|
||||
style.setShowActionsInCompactView(*compactActions)
|
||||
notificationBuilder!!.setStyle(style)
|
||||
|
||||
// Set song title, artist and cover
|
||||
val iconSize = (256 * context.resources.displayMetrics.density).toInt()
|
||||
val bitmap = BitmapUtils.getAlbumArtBitmapFromDisk(song, iconSize)
|
||||
notificationBuilder!!.setContentTitle(song.title)
|
||||
notificationBuilder!!.setContentText(song.artist)
|
||||
notificationBuilder!!.setLargeIcon(bitmap)
|
||||
notificationBuilder!!.setSubText(song.album)
|
||||
} else if (downloader.started) {
|
||||
// No song is playing, but Ultrasonic is downloading files
|
||||
notificationBuilder!!.setContentTitle(
|
||||
getString(R.string.notification_downloading_title)
|
||||
)
|
||||
}
|
||||
|
||||
return notificationBuilder!!.build()
|
||||
}
|
||||
|
||||
private fun addActions(
|
||||
context: Context,
|
||||
notificationBuilder: NotificationCompat.Builder,
|
||||
playerState: PlayerState,
|
||||
song: Track?
|
||||
): IntArray {
|
||||
// Init
|
||||
val compactActionList = ArrayList<Int>()
|
||||
var numActions = 0 // we start and 0 and then increment by 1 for each call to generateAction
|
||||
|
||||
// Star
|
||||
if (song != null) {
|
||||
notificationBuilder.addAction(generateStarAction(context, numActions, song.starred))
|
||||
}
|
||||
numActions++
|
||||
|
||||
// Next
|
||||
notificationBuilder.addAction(generateAction(context, numActions))
|
||||
compactActionList.add(numActions)
|
||||
numActions++
|
||||
|
||||
// Play/Pause button
|
||||
notificationBuilder.addAction(generatePlayPauseAction(context, numActions, playerState))
|
||||
compactActionList.add(numActions)
|
||||
numActions++
|
||||
|
||||
// Previous
|
||||
notificationBuilder.addAction(generateAction(context, numActions))
|
||||
compactActionList.add(numActions)
|
||||
numActions++
|
||||
|
||||
// Close
|
||||
notificationBuilder.addAction(generateAction(context, numActions))
|
||||
val actionArray = IntArray(compactActionList.size)
|
||||
for (i in actionArray.indices) {
|
||||
actionArray[i] = compactActionList[i]
|
||||
}
|
||||
return actionArray
|
||||
// notificationBuilder.setShowActionsInCompactView())
|
||||
}
|
||||
|
||||
private fun generateAction(context: Context, requestCode: Int): NotificationCompat.Action? {
|
||||
val keycode: Int
|
||||
val icon: Int
|
||||
val label: String
|
||||
|
||||
when (requestCode) {
|
||||
1 -> {
|
||||
keycode = KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
||||
label = getString(R.string.common_play_previous)
|
||||
icon = R.drawable.media_backward_medium_dark
|
||||
}
|
||||
2 -> // Is handled in generatePlayPauseAction()
|
||||
return null
|
||||
3 -> {
|
||||
keycode = KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
label = getString(R.string.common_play_next)
|
||||
icon = R.drawable.media_forward_medium_dark
|
||||
}
|
||||
4 -> {
|
||||
keycode = KeyEvent.KEYCODE_MEDIA_STOP
|
||||
label = getString(R.string.buttons_stop)
|
||||
icon = R.drawable.ic_baseline_close
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
|
||||
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
|
||||
}
|
||||
|
||||
private fun generatePlayPauseAction(
|
||||
context: Context,
|
||||
requestCode: Int,
|
||||
playerState: PlayerState
|
||||
): NotificationCompat.Action {
|
||||
val isPlaying = playerState === PlayerState.STARTED
|
||||
val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
|
||||
val label: String
|
||||
val icon: Int
|
||||
|
||||
if (isPlaying) {
|
||||
label = getString(R.string.common_pause)
|
||||
icon = R.drawable.media_pause_large_dark
|
||||
} else {
|
||||
label = getString(R.string.common_play)
|
||||
icon = R.drawable.media_start_large_dark
|
||||
}
|
||||
|
||||
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
|
||||
}
|
||||
|
||||
private fun generateStarAction(
|
||||
context: Context,
|
||||
requestCode: Int,
|
||||
isStarred: Boolean
|
||||
): NotificationCompat.Action {
|
||||
|
||||
val label: String
|
||||
val icon: Int
|
||||
val keyCode: Int = KeyEvent.KEYCODE_STAR
|
||||
|
||||
if (isStarred) {
|
||||
label = getString(R.string.download_menu_star)
|
||||
icon = R.drawable.ic_star_full_dark
|
||||
} else {
|
||||
label = getString(R.string.download_menu_star)
|
||||
icon = R.drawable.ic_star_hollow_dark
|
||||
}
|
||||
|
||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keyCode, requestCode)
|
||||
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
|
||||
}
|
||||
|
||||
private fun getPendingIntentForContent(): PendingIntent {
|
||||
val intent = Intent(this, NavigationActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
|
||||
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
|
||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
|
||||
private const val NOTIFICATION_ID = 3033
|
||||
|
||||
@Volatile
|
||||
private var instance: MediaPlayerService? = null
|
||||
private val instanceLock = Any()
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(): MediaPlayerService? {
|
||||
val context = UApp.applicationContext()
|
||||
// Try for twenty times to retrieve a running service,
|
||||
// sleep 100 millis between each try,
|
||||
// and run the block that creates a service only synchronized.
|
||||
for (i in 0..19) {
|
||||
if (instance != null) return instance
|
||||
synchronized(instanceLock) {
|
||||
if (instance != null) return instance
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(
|
||||
Intent(context, MediaPlayerService::class.java)
|
||||
)
|
||||
} else {
|
||||
context.startService(Intent(context, MediaPlayerService::class.java))
|
||||
}
|
||||
}
|
||||
Util.sleepQuietly(100L)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val runningInstance: MediaPlayerService?
|
||||
get() {
|
||||
synchronized(instanceLock) { return instance }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun executeOnStartedMediaPlayerService(
|
||||
taskToExecute: (MediaPlayerService) -> Unit
|
||||
) {
|
||||
|
||||
val t: Thread = object : Thread() {
|
||||
override fun run() {
|
||||
val instance = getInstance()
|
||||
if (instance == null) {
|
||||
Timber.e("ExecuteOnStarted.. failed to get a MediaPlayerService instance!")
|
||||
return
|
||||
} else {
|
||||
taskToExecute(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.start()
|
||||
}
|
||||
}
|
||||
}
|
@ -53,7 +53,7 @@ class PlaybackStateSerializer : KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
fun serializeNow(
|
||||
private fun serializeNow(
|
||||
songs: Iterable<DownloadFile>,
|
||||
currentPlayingIndex: Int,
|
||||
currentPlayingPosition: Int
|
||||
|
@ -20,11 +20,6 @@ class RxBus {
|
||||
.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> =
|
||||
@ -83,7 +78,7 @@ class RxBus {
|
||||
}
|
||||
}
|
||||
|
||||
data class StateWithTrack(val state: PlayerState, val track: DownloadFile?)
|
||||
data class StateWithTrack(val state: PlayerState, val track: DownloadFile?, val index: Int = -1)
|
||||
}
|
||||
|
||||
operator fun CompositeDisposable.plusAssign(disposable: Disposable) {
|
||||
|
@ -34,20 +34,23 @@ class DownloadHandler(
|
||||
autoPlay: Boolean,
|
||||
playNext: Boolean,
|
||||
shuffle: Boolean,
|
||||
songs: List<Track?>
|
||||
songs: List<Track>,
|
||||
) {
|
||||
val onValid = Runnable {
|
||||
if (!append && !playNext) {
|
||||
mediaPlayerController.clear()
|
||||
// TODO: The logic here is different than in the controller...
|
||||
val insertionMode = when {
|
||||
append -> MediaPlayerController.InsertionMode.APPEND
|
||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||
}
|
||||
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.addToPlaylist(
|
||||
songs,
|
||||
save,
|
||||
autoPlay,
|
||||
playNext,
|
||||
shuffle,
|
||||
false
|
||||
insertionMode
|
||||
)
|
||||
val playlistName: String? = fragment.arguments?.getString(
|
||||
Constants.INTENT_PLAYLIST_NAME
|
||||
@ -281,26 +284,28 @@ class DownloadHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Called when we have collected the tracks
|
||||
override fun done(songs: List<Track>) {
|
||||
if (Settings.shouldSortByDisc) {
|
||||
Collections.sort(songs, EntryByDiscAndTrackComparator())
|
||||
}
|
||||
if (songs.isNotEmpty()) {
|
||||
if (!append && !playNext && !unpin && !background) {
|
||||
mediaPlayerController.clear()
|
||||
}
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
if (!background) {
|
||||
if (unpin) {
|
||||
mediaPlayerController.unpin(songs)
|
||||
} else {
|
||||
val insertionMode = when {
|
||||
append -> MediaPlayerController.InsertionMode.APPEND
|
||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||
}
|
||||
mediaPlayerController.addToPlaylist(
|
||||
songs,
|
||||
save,
|
||||
autoPlay,
|
||||
playNext,
|
||||
shuffle,
|
||||
false
|
||||
insertionMode
|
||||
)
|
||||
if (
|
||||
!append &&
|
||||
|
@ -233,7 +233,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
) {
|
||||
if (file.isFile && (isPartial(file) || isComplete(file))) {
|
||||
files.add(file)
|
||||
} else {
|
||||
} else if (file.isDirectory) {
|
||||
// Depth-first
|
||||
for (child in listFiles(file)) {
|
||||
findCandidatesForDeletion(child, files, dirs)
|
||||
@ -257,7 +257,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
for (downloadFile in downloader.value.all) {
|
||||
filesToNotDelete.add(downloadFile.partialFile)
|
||||
filesToNotDelete.add(downloadFile.completeFile)
|
||||
filesToNotDelete.add(downloadFile.saveFile)
|
||||
filesToNotDelete.add(downloadFile.pinnedFile)
|
||||
}
|
||||
|
||||
filesToNotDelete.add(musicDirectory.path)
|
||||
|
@ -406,7 +406,7 @@ object FileUtil {
|
||||
return path.substringBeforeLast('/')
|
||||
}
|
||||
|
||||
fun getSaveFile(name: String): String {
|
||||
fun getPinnedFile(name: String): String {
|
||||
val baseName = getBaseName(name)
|
||||
if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) {
|
||||
return "${getBaseName(baseName)}.${getExtension(name)}"
|
||||
|
@ -1,332 +0,0 @@
|
||||
/*
|
||||
* MediaSessionHandler.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
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.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
|
||||
/**
|
||||
* Central place to handle the state of the MediaSession
|
||||
*/
|
||||
class MediaSessionHandler : KoinComponent {
|
||||
|
||||
private var mediaSession: MediaSessionCompat? = null
|
||||
private var playbackState: Int? = null
|
||||
private var playbackActions: Long? = null
|
||||
private var cachedPlayingIndex: Long? = null
|
||||
|
||||
private val applicationContext by inject<Context>()
|
||||
|
||||
private var referenceCount: 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
|
||||
RxBus.releaseMediaSessionToken()
|
||||
rxBusSubscription.dispose()
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
|
||||
Timber.i("MediaSessionHandler.release Media Session released")
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
|
||||
referenceCount++
|
||||
if (referenceCount > 1) return
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val keycode = 110
|
||||
|
||||
Timber.d("MediaSessionHandler.initialize Creating Media Session")
|
||||
|
||||
mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService")
|
||||
val mediaSessionToken = mediaSession?.sessionToken ?: return
|
||||
RxBus.mediaSessionTokenPublisher.onNext(mediaSessionToken)
|
||||
|
||||
updateMediaButtonReceiver()
|
||||
|
||||
mediaSession?.setCallback(object : MediaSessionCompat.Callback() {
|
||||
override fun onPlay() {
|
||||
super.onPlay()
|
||||
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||
keycode
|
||||
).send()
|
||||
|
||||
Timber.v("Media Session Callback: onPlay")
|
||||
}
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
super.onPlayFromMediaId(mediaId, extras)
|
||||
|
||||
Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId)
|
||||
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)
|
||||
RxBus.playFromSearchCommandPublisher.onNext(Pair(query, extras))
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onPause")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onStop")
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
super.onSkipToNext()
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onSkipToNext")
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
super.onSkipToPrevious()
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onSkipToPrevious")
|
||||
}
|
||||
|
||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
||||
// 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?
|
||||
event.ifNotNull { RxBus.mediaButtonEventPublisher.onNext(it) }
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSkipToQueueItem(id: Long) {
|
||||
super.onSkipToQueueItem(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
|
||||
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("LongMethod", "ComplexMethod")
|
||||
private fun updateMediaSession(
|
||||
playerState: PlayerState,
|
||||
currentPlaying: DownloadFile?
|
||||
) {
|
||||
Timber.d("Updating the MediaSession")
|
||||
|
||||
// Set Metadata
|
||||
val metadata = MediaMetadataCompat.Builder()
|
||||
if (currentPlaying != null) {
|
||||
try {
|
||||
val song = currentPlaying.track
|
||||
val cover = BitmapUtils.getAlbumArtBitmapFromDisk(
|
||||
song, Util.getMinDisplayMetric()
|
||||
)
|
||||
val duration = song.duration?.times(1000) ?: -1
|
||||
metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.toLong())
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist)
|
||||
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 (all: Exception) {
|
||||
Timber.e(all, "Error setting the metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// Save the metadata
|
||||
mediaSession?.setMetadata(metadata.build())
|
||||
|
||||
playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
|
||||
|
||||
// Map our playerState to native PlaybackState
|
||||
// TODO: Synchronize these APIs
|
||||
when (playerState) {
|
||||
PlayerState.STARTED -> {
|
||||
playbackState = PlaybackStateCompat.STATE_PLAYING
|
||||
playbackActions = playbackActions!! or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
}
|
||||
PlayerState.COMPLETED,
|
||||
PlayerState.STOPPED -> {
|
||||
playbackState = PlaybackStateCompat.STATE_STOPPED
|
||||
cachedPosition = PLAYBACK_POSITION_UNKNOWN
|
||||
}
|
||||
PlayerState.IDLE -> {
|
||||
// IDLE state usually just means the playback is stopped
|
||||
// STATE_NONE means that there is no track to play (playlist is empty)
|
||||
playbackState = if (currentPlaying == null)
|
||||
PlaybackStateCompat.STATE_NONE
|
||||
else
|
||||
PlaybackStateCompat.STATE_STOPPED
|
||||
playbackActions = 0L
|
||||
cachedPosition = PLAYBACK_POSITION_UNKNOWN
|
||||
}
|
||||
PlayerState.PAUSED -> {
|
||||
playbackState = PlaybackStateCompat.STATE_PAUSED
|
||||
playbackActions = playbackActions!! or
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
}
|
||||
else -> {
|
||||
// These are the states PREPARING, PREPARED & DOWNLOADING
|
||||
playbackState = PlaybackStateCompat.STATE_PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
val playbackStateBuilder = PlaybackStateCompat.Builder()
|
||||
playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f)
|
||||
|
||||
// Set actions
|
||||
playbackStateBuilder.setActions(playbackActions!!)
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
private fun updateMediaSessionQueue(playlist: List<DownloadFile>) {
|
||||
cachedPlaylist = playlist
|
||||
setMediaSessionQueue(playlist)
|
||||
}
|
||||
|
||||
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.track),
|
||||
id.toLong()
|
||||
)
|
||||
}
|
||||
mediaSession?.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing))
|
||||
mediaSession?.setQueue(queue)
|
||||
}
|
||||
|
||||
private fun updateMediaSessionPlaybackPosition(playbackPosition: Int) {
|
||||
cachedPosition = playbackPosition.toLong()
|
||||
if (playbackState == null || playbackActions == null) return
|
||||
|
||||
val playbackStateBuilder = PlaybackStateCompat.Builder()
|
||||
playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f)
|
||||
playbackStateBuilder.setActions(playbackActions!!)
|
||||
|
||||
if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending)
|
||||
cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) }
|
||||
|
||||
mediaSession?.setPlaybackState(playbackStateBuilder.build())
|
||||
}
|
||||
|
||||
fun updateMediaButtonReceiver() {
|
||||
if (Settings.mediaButtonsEnabled) {
|
||||
registerMediaButtonEventReceiver()
|
||||
} else {
|
||||
unregisterMediaButtonEventReceiver()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerMediaButtonEventReceiver() {
|
||||
val component = ComponentName(
|
||||
applicationContext.packageName,
|
||||
MediaButtonIntentReceiver::class.java.name
|
||||
)
|
||||
val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
|
||||
mediaButtonIntent.component = component
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
INTENT_CODE_MEDIA_BUTTON,
|
||||
mediaButtonIntent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
)
|
||||
|
||||
mediaSession?.setMediaButtonReceiver(pendingIntent)
|
||||
}
|
||||
|
||||
private fun unregisterMediaButtonEventReceiver() {
|
||||
mediaSession?.setMediaButtonReceiver(null)
|
||||
}
|
||||
}
|
@ -9,13 +9,11 @@ package org.moire.ultrasonic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import androidx.preference.PreferenceManager
|
||||
import java.util.regex.Pattern
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.RepeatMode
|
||||
|
||||
/**
|
||||
* Contains convenience functions for reading and writing preferences
|
||||
@ -23,43 +21,6 @@ import org.moire.ultrasonic.domain.RepeatMode
|
||||
object Settings {
|
||||
private val PATTERN = Pattern.compile(":")
|
||||
|
||||
var repeatMode: RepeatMode
|
||||
get() {
|
||||
val preferences = preferences
|
||||
return RepeatMode.valueOf(
|
||||
preferences.getString(
|
||||
Constants.PREFERENCES_KEY_REPEAT_MODE,
|
||||
RepeatMode.OFF.name
|
||||
)!!
|
||||
)
|
||||
}
|
||||
set(repeatMode) {
|
||||
val preferences = preferences
|
||||
val editor = preferences.edit()
|
||||
editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
// After API26 foreground services must be used for music playback,
|
||||
// and they must have a notification
|
||||
val isNotificationEnabled: Boolean
|
||||
get() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true
|
||||
val preferences = preferences
|
||||
return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION, false)
|
||||
}
|
||||
|
||||
// After API26 foreground services must be used for music playback,
|
||||
// and they must have a notification
|
||||
val isNotificationAlwaysEnabled: Boolean
|
||||
get() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true
|
||||
val preferences = preferences
|
||||
return preferences.getBoolean(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION, false)
|
||||
}
|
||||
|
||||
var isLockScreenEnabled by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS)
|
||||
|
||||
@JvmStatic
|
||||
var theme by StringSetting(
|
||||
Constants.PREFERENCES_KEY_THEME,
|
||||
|
@ -0,0 +1,5 @@
|
||||
<vector android:height="48dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector android:height="48dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M8,5v14l11,-7z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector android:height="32dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="32dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector android:height="32dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="32dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
|
||||
</vector>
|
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="m12,3.8438c-4.9703,0 -9,4.0292 -9,9l0,5.0625c0,1.2426 1.0074,2.25 2.25,2.25 1.2426,0 2.25,-1.0074 2.25,-2.25l0,-3.375c0,-1.2426 -1.0074,-2.25 -2.25,-2.25 -0.4067,0 -0.783,0.1164 -1.1121,0.3049C4.2752,8.3573 7.7379,4.9688 12,4.9688 16.2621,4.9688 19.7242,8.3573 19.8621,12.5861 19.5336,12.3977 19.1567,12.2813 18.75,12.2813c-1.2426,0 -2.25,1.0074 -2.25,2.25l0,3.375c0,1.2426 1.0074,2.25 2.25,2.25 1.2426,0 2.25,-1.0074 2.25,-2.25L21,12.8438C21,7.8729 16.9708,3.8438 12,3.8438ZM5.25,13.4063c0.621,0 1.125,0.504 1.125,1.125l0,3.375c0,0.621 -0.504,1.125 -1.125,1.125 -0.621,0 -1.125,-0.504 -1.125,-1.125l0,-3.375c0,-0.621 0.504,-1.125 1.125,-1.125zM19.875,17.9063c0,0.621 -0.504,1.125 -1.125,1.125 -0.621,0 -1.125,-0.504 -1.125,-1.125l0,-3.375c0,-0.621 0.504,-1.125 1.125,-1.125 0.621,0 1.125,0.504 1.125,1.125z"/>
|
||||
</vector>
|
Loading…
x
Reference in New Issue
Block a user