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"
|
jacoco = "0.8.7"
|
||||||
preferences = "1.1.1"
|
preferences = "1.1.1"
|
||||||
media = "1.3.1"
|
media = "1.3.1"
|
||||||
|
media3 = "1.0.0-alpha03"
|
||||||
|
|
||||||
androidSupport = "28.0.0"
|
androidSupport = "28.0.0"
|
||||||
androidLegacySupport = "1.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" }
|
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
|
||||||
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
|
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
|
||||||
media = { module = "androidx.media:media", version.ref = "media" }
|
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" }
|
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||||
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||||
|
|
|
@ -100,6 +100,9 @@ dependencies {
|
||||||
implementation libs.constraintLayout
|
implementation libs.constraintLayout
|
||||||
implementation libs.preferences
|
implementation libs.preferences
|
||||||
implementation libs.media
|
implementation libs.media
|
||||||
|
implementation libs.media3exoplayer
|
||||||
|
implementation libs.media3session
|
||||||
|
implementation libs.media3okhttp
|
||||||
|
|
||||||
implementation libs.navigationFragment
|
implementation libs.navigationFragment
|
||||||
implementation libs.navigationUi
|
implementation libs.navigationUi
|
||||||
|
|
|
@ -56,18 +56,17 @@
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.MediaPlayerService"
|
android:name=".service.DownloadService"
|
||||||
android:label="Ultrasonic Media Player Service"
|
android:label="Ultrasonic Media Player Service"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<service android:name=".playback.PlaybackService"
|
||||||
tools:ignore="ExportedService"
|
|
||||||
android:name=".service.AutoMediaBrowserService"
|
|
||||||
android:label="@string/common.appname"
|
android:label="@string/common.appname"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaLibraryService" />
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
@ -146,13 +145,6 @@
|
||||||
android:name=".provider.SearchSuggestionProvider"
|
android:name=".provider.SearchSuggestionProvider"
|
||||||
android:authorities="org.moire.ultrasonic.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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaPlayerControllerLazy.getValue().getPlayerState() != PlayerState.STARTED)
|
if (mediaPlayerControllerLazy.getValue().getLegacyPlayerState() != PlayerState.STARTED)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,8 @@ import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.GravityCompat
|
import androidx.core.view.GravityCompat
|
||||||
import androidx.drawerlayout.widget.DrawerLayout
|
import androidx.drawerlayout.widget.DrawerLayout
|
||||||
import androidx.fragment.app.FragmentContainerView
|
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.NavController
|
||||||
import androidx.navigation.findNavController
|
import androidx.navigation.findNavController
|
||||||
import androidx.navigation.fragment.NavHostFragment
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
@ -414,9 +416,9 @@ class NavigationActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nowPlayingView != null) {
|
if (nowPlayingView != null) {
|
||||||
val playerState: PlayerState = mediaPlayerController.playerState
|
val playerState: Int = mediaPlayerController.playbackState
|
||||||
if (playerState == PlayerState.PAUSED || playerState == PlayerState.STARTED) {
|
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
|
||||||
val file: DownloadFile? = mediaPlayerController.currentPlaying
|
val file: DownloadFile? = mediaPlayerController.currentPlayingLegacy
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
nowPlayingView?.visibility = View.VISIBLE
|
nowPlayingView?.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ import org.moire.ultrasonic.service.DownloadFile
|
||||||
import org.moire.ultrasonic.service.Downloader
|
import org.moire.ultrasonic.service.Downloader
|
||||||
|
|
||||||
class TrackViewBinder(
|
class TrackViewBinder(
|
||||||
val onItemClick: (DownloadFile) -> Unit,
|
val onItemClick: (DownloadFile, Int) -> Unit,
|
||||||
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
|
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
|
||||||
val checkable: Boolean,
|
val checkable: Boolean,
|
||||||
val draggable: Boolean,
|
val draggable: Boolean,
|
||||||
|
@ -29,7 +29,7 @@ class TrackViewBinder(
|
||||||
|
|
||||||
// Set our layout files
|
// Set our layout files
|
||||||
val layout = R.layout.list_item_track
|
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 downloader: Downloader by inject()
|
||||||
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
|
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
|
||||||
|
@ -41,15 +41,14 @@ class TrackViewBinder(
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
|
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
|
||||||
val downloadFile: DownloadFile?
|
|
||||||
val diffAdapter = adapter as BaseAdapter<*>
|
val diffAdapter = adapter as BaseAdapter<*>
|
||||||
|
|
||||||
when (item) {
|
val downloadFile: DownloadFile = when (item) {
|
||||||
is Track -> {
|
is Track -> {
|
||||||
downloadFile = downloader.getDownloadFileForSong(item)
|
downloader.getDownloadFileForSong(item)
|
||||||
}
|
}
|
||||||
is DownloadFile -> {
|
is DownloadFile -> {
|
||||||
downloadFile = item
|
item
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
return
|
return
|
||||||
|
@ -90,7 +89,7 @@ class TrackViewBinder(
|
||||||
val nowChecked = !holder.check.isChecked
|
val nowChecked = !holder.check.isChecked
|
||||||
holder.isChecked = nowChecked
|
holder.isChecked = nowChecked
|
||||||
} else {
|
} else {
|
||||||
onItemClick(downloadFile)
|
onItemClick(downloadFile, holder.bindingAdapterPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,41 +102,37 @@ class TrackViewBinder(
|
||||||
|
|
||||||
// Notify the adapter of selection changes
|
// Notify the adapter of selection changes
|
||||||
holder.observableChecked.observe(
|
holder.observableChecked.observe(
|
||||||
lifecycleOwner,
|
lifecycleOwner
|
||||||
{ isCheckedNow ->
|
) { isCheckedNow ->
|
||||||
if (isCheckedNow) {
|
if (isCheckedNow) {
|
||||||
diffAdapter.notifySelected(holder.entry!!.longId)
|
diffAdapter.notifySelected(holder.entry!!.longId)
|
||||||
} else {
|
} else {
|
||||||
diffAdapter.notifyUnselected(holder.entry!!.longId)
|
diffAdapter.notifyUnselected(holder.entry!!.longId)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
// Listen to changes in selection status and update ourselves
|
// Listen to changes in selection status and update ourselves
|
||||||
diffAdapter.selectionRevision.observe(
|
diffAdapter.selectionRevision.observe(
|
||||||
lifecycleOwner,
|
lifecycleOwner
|
||||||
{
|
) {
|
||||||
val newStatus = diffAdapter.isSelected(item.longId)
|
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
|
// Observe download status
|
||||||
downloadFile.status.observe(
|
downloadFile.status.observe(
|
||||||
lifecycleOwner,
|
lifecycleOwner
|
||||||
{
|
) {
|
||||||
holder.updateStatus(it)
|
holder.updateStatus(it)
|
||||||
diffAdapter.notifyChanged()
|
diffAdapter.notifyChanged()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
downloadFile.progress.observe(
|
downloadFile.progress.observe(
|
||||||
lifecycleOwner,
|
lifecycleOwner
|
||||||
{
|
) {
|
||||||
holder.updateProgress(it)
|
holder.updateProgress(it)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewRecycled(holder: TrackViewHolder) {
|
override fun onViewRecycled(holder: TrackViewHolder) {
|
||||||
|
|
|
@ -109,7 +109,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
||||||
}
|
}
|
||||||
|
|
||||||
rxSubscription = RxBus.playerStateObservable.subscribe {
|
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.koin.dsl.module
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This Koin module contains the registration of general classes needed for Ultrasonic
|
* 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 {
|
val applicationModule = module {
|
||||||
single { ActiveServerProvider(get()) }
|
single { ActiveServerProvider(get()) }
|
||||||
single { ImageLoaderProvider(androidContext()) }
|
single { ImageLoaderProvider(androidContext()) }
|
||||||
single { MediaSessionHandler() }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
package org.moire.ultrasonic.di
|
package org.moire.ultrasonic.di
|
||||||
|
|
||||||
import org.koin.dsl.module
|
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.Downloader
|
||||||
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
||||||
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
||||||
import org.moire.ultrasonic.service.LocalMediaPlayer
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||||
import org.moire.ultrasonic.service.PlaybackStateSerializer
|
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
|
* This Koin module contains the registration of classes related to the media player
|
||||||
|
@ -19,10 +17,8 @@ val mediaPlayerModule = module {
|
||||||
single { MediaPlayerLifecycleSupport() }
|
single { MediaPlayerLifecycleSupport() }
|
||||||
single { PlaybackStateSerializer() }
|
single { PlaybackStateSerializer() }
|
||||||
single { ExternalStorageMonitor() }
|
single { ExternalStorageMonitor() }
|
||||||
single { ShufflePlayBuffer() }
|
single { LegacyPlaylistManager() }
|
||||||
single { Downloader(get(), get(), get()) }
|
single { Downloader(get(), get()) }
|
||||||
single { LocalMediaPlayer() }
|
|
||||||
single { AudioFocusHandler(get()) }
|
|
||||||
|
|
||||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
// TODO Ideally this can be cleaned up when all circular references are removed.
|
||||||
single { MediaPlayerController(get(), get(), get(), get(), get()) }
|
single { MediaPlayerController(get(), get(), get(), get(), get()) }
|
||||||
|
|
|
@ -54,7 +54,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
{ },
|
{ _, _ -> },
|
||||||
{ _, _ -> true },
|
{ _, _ -> true },
|
||||||
checkable = false,
|
checkable = false,
|
||||||
draggable = false,
|
draggable = false,
|
||||||
|
|
|
@ -47,7 +47,7 @@ class NowPlayingFragment : Fragment() {
|
||||||
private var nowPlayingTrack: TextView? = null
|
private var nowPlayingTrack: TextView? = null
|
||||||
private var nowPlayingArtist: 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 mediaPlayerController: MediaPlayerController by inject()
|
||||||
private val imageLoader: ImageLoaderProvider by inject()
|
private val imageLoader: ImageLoaderProvider by inject()
|
||||||
|
|
||||||
|
@ -69,8 +69,7 @@ class NowPlayingFragment : Fragment() {
|
||||||
nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image)
|
nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image)
|
||||||
nowPlayingTrack = view.findViewById(R.id.now_playing_trackname)
|
nowPlayingTrack = view.findViewById(R.id.now_playing_trackname)
|
||||||
nowPlayingArtist = view.findViewById(R.id.now_playing_artist)
|
nowPlayingArtist = view.findViewById(R.id.now_playing_artist)
|
||||||
playerStateSubscription =
|
rxBusSubscription = RxBus.playerStateObservable.subscribe { update() }
|
||||||
RxBus.playerStateObservable.subscribe { update() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
@ -80,13 +79,13 @@ class NowPlayingFragment : Fragment() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
playerStateSubscription!!.dispose()
|
rxBusSubscription!!.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
private fun update() {
|
private fun update() {
|
||||||
try {
|
try {
|
||||||
val playerState = mediaPlayerController.playerState
|
val playerState = mediaPlayerController.legacyPlayerState
|
||||||
|
|
||||||
if (playerState === PlayerState.PAUSED) {
|
if (playerState === PlayerState.PAUSED) {
|
||||||
playButton!!.setImageDrawable(
|
playButton!!.setImageDrawable(
|
||||||
|
@ -102,7 +101,7 @@ class NowPlayingFragment : Fragment() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val file = mediaPlayerController.currentPlaying
|
val file = mediaPlayerController.currentPlayingLegacy
|
||||||
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
val song = file.track
|
val song = file.track
|
||||||
|
|
|
@ -13,6 +13,7 @@ import android.graphics.Point
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.view.ContextMenu
|
import android.view.ContextMenu
|
||||||
import android.view.ContextMenu.ContextMenuInfo
|
import android.view.ContextMenu.ContextMenuInfo
|
||||||
import android.view.GestureDetector
|
import android.view.GestureDetector
|
||||||
|
@ -35,24 +36,15 @@ import android.widget.TextView
|
||||||
import android.widget.ViewFlipper
|
import android.widget.ViewFlipper
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
import androidx.navigation.Navigation
|
import androidx.navigation.Navigation
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
|
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
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 kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
@ -66,15 +58,13 @@ import org.moire.ultrasonic.audiofx.EqualizerController
|
||||||
import org.moire.ultrasonic.audiofx.VisualizerController
|
import org.moire.ultrasonic.audiofx.VisualizerController
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||||
import org.moire.ultrasonic.domain.Identifiable
|
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.domain.Track
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
import org.moire.ultrasonic.service.DownloadFile
|
||||||
import org.moire.ultrasonic.service.LocalMediaPlayer
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||||
|
@ -86,9 +76,20 @@ import org.moire.ultrasonic.util.Util
|
||||||
import org.moire.ultrasonic.view.AutoRepeatButton
|
import org.moire.ultrasonic.view.AutoRepeatButton
|
||||||
import org.moire.ultrasonic.view.VisualizerView
|
import org.moire.ultrasonic.view.VisualizerView
|
||||||
import timber.log.Timber
|
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
|
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
|
||||||
|
* TODO: Add timeline lister -> updateProgressBar().
|
||||||
*/
|
*/
|
||||||
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
||||||
class PlayerFragment :
|
class PlayerFragment :
|
||||||
|
@ -113,14 +114,13 @@ class PlayerFragment :
|
||||||
// Data & Services
|
// Data & Services
|
||||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||||
private val mediaPlayerController: MediaPlayerController by inject()
|
private val mediaPlayerController: MediaPlayerController by inject()
|
||||||
private val localMediaPlayer: LocalMediaPlayer by inject()
|
|
||||||
private val shareHandler: ShareHandler by inject()
|
private val shareHandler: ShareHandler by inject()
|
||||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||||
private lateinit var executorService: ScheduledExecutorService
|
|
||||||
private var currentPlaying: DownloadFile? = null
|
private var currentPlaying: DownloadFile? = null
|
||||||
private var currentSong: Track? = null
|
private var currentSong: Track? = null
|
||||||
private lateinit var viewManager: LinearLayoutManager
|
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)
|
private var ioScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
// Views and UI Elements
|
// Views and UI Elements
|
||||||
|
@ -148,7 +148,7 @@ class PlayerFragment :
|
||||||
private lateinit var durationTextView: TextView
|
private lateinit var durationTextView: TextView
|
||||||
private lateinit var pauseButton: View
|
private lateinit var pauseButton: View
|
||||||
private lateinit var stopButton: View
|
private lateinit var stopButton: View
|
||||||
private lateinit var startButton: View
|
private lateinit var playButton: View
|
||||||
private lateinit var repeatButton: ImageView
|
private lateinit var repeatButton: ImageView
|
||||||
private lateinit var hollowStar: Drawable
|
private lateinit var hollowStar: Drawable
|
||||||
private lateinit var fullStar: Drawable
|
private lateinit var fullStar: Drawable
|
||||||
|
@ -189,7 +189,7 @@ class PlayerFragment :
|
||||||
|
|
||||||
pauseButton = view.findViewById(R.id.button_pause)
|
pauseButton = view.findViewById(R.id.button_pause)
|
||||||
stopButton = view.findViewById(R.id.button_stop)
|
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)
|
repeatButton = view.findViewById(R.id.button_repeat)
|
||||||
visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout)
|
visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout)
|
||||||
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
|
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
|
||||||
|
@ -216,13 +216,6 @@ class PlayerFragment :
|
||||||
swipeVelocity = swipeDistance
|
swipeVelocity = swipeDistance
|
||||||
gestureScanner = GestureDetector(context, this)
|
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)
|
findViews(view)
|
||||||
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
|
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
|
||||||
|
@ -291,34 +284,40 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startButton.setOnClickListener {
|
playButton.setOnClickListener {
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
start()
|
mediaPlayerController.play()
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
onSliderProgressChanged()
|
onSliderProgressChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shuffleButton.setOnClickListener {
|
shuffleButton.setOnClickListener {
|
||||||
mediaPlayerController.shuffle()
|
mediaPlayerController.toggleShuffle()
|
||||||
Util.toast(activity, R.string.download_menu_shuffle_notification)
|
Util.toast(activity, R.string.download_menu_shuffle_notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
repeatButton.setOnClickListener {
|
repeatButton.setOnClickListener {
|
||||||
val repeatMode = mediaPlayerController.repeatMode.next()
|
var newRepeat = mediaPlayerController.repeatMode + 1
|
||||||
mediaPlayerController.repeatMode = repeatMode
|
if (newRepeat == 3) {
|
||||||
|
newRepeat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaPlayerController.repeatMode = newRepeat
|
||||||
|
|
||||||
onPlaylistChanged()
|
onPlaylistChanged()
|
||||||
when (repeatMode) {
|
|
||||||
RepeatMode.OFF -> Util.toast(
|
when (newRepeat) {
|
||||||
|
0 -> Util.toast(
|
||||||
context, R.string.download_repeat_off
|
context, R.string.download_repeat_off
|
||||||
)
|
)
|
||||||
RepeatMode.ALL -> Util.toast(
|
1 -> Util.toast(
|
||||||
context, R.string.download_repeat_all
|
|
||||||
)
|
|
||||||
RepeatMode.SINGLE -> Util.toast(
|
|
||||||
context, R.string.download_repeat_single
|
context, R.string.download_repeat_single
|
||||||
)
|
)
|
||||||
|
2 -> Util.toast(
|
||||||
|
context, R.string.download_repeat_all
|
||||||
|
)
|
||||||
else -> {
|
else -> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -351,53 +350,62 @@ class PlayerFragment :
|
||||||
|
|
||||||
visualizerViewLayout.isVisible = false
|
visualizerViewLayout.isVisible = false
|
||||||
VisualizerController.get().observe(
|
VisualizerController.get().observe(
|
||||||
requireActivity(),
|
requireActivity()
|
||||||
{ visualizerController ->
|
) { visualizerController ->
|
||||||
if (visualizerController != null) {
|
if (visualizerController != null) {
|
||||||
Timber.d("VisualizerController Observer.onChanged received controller")
|
Timber.d("VisualizerController Observer.onChanged received controller")
|
||||||
visualizerView = VisualizerView(context)
|
visualizerView = VisualizerView(context)
|
||||||
visualizerViewLayout.addView(
|
visualizerViewLayout.addView(
|
||||||
visualizerView,
|
visualizerView,
|
||||||
LinearLayout.LayoutParams(
|
LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT
|
LinearLayout.LayoutParams.MATCH_PARENT
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
visualizerViewLayout.isVisible = visualizerView.isActive
|
visualizerViewLayout.isVisible = visualizerView.isActive
|
||||||
|
|
||||||
visualizerView.setOnTouchListener { _, _ ->
|
visualizerView.setOnTouchListener { _, _ ->
|
||||||
visualizerView.isActive = !visualizerView.isActive
|
visualizerView.isActive = !visualizerView.isActive
|
||||||
mediaPlayerController.showVisualization = visualizerView.isActive
|
mediaPlayerController.showVisualization = visualizerView.isActive
|
||||||
true
|
true
|
||||||
}
|
|
||||||
isVisualizerAvailable = true
|
|
||||||
} else {
|
|
||||||
Timber.d("VisualizerController Observer.onChanged has no controller")
|
|
||||||
visualizerViewLayout.isVisible = false
|
|
||||||
isVisualizerAvailable = false
|
|
||||||
}
|
}
|
||||||
|
isVisualizerAvailable = true
|
||||||
|
} else {
|
||||||
|
Timber.d("VisualizerController Observer.onChanged has no controller")
|
||||||
|
visualizerViewLayout.isVisible = false
|
||||||
|
isVisualizerAvailable = false
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
EqualizerController.get().observe(
|
EqualizerController.get().observe(
|
||||||
requireActivity(),
|
requireActivity()
|
||||||
{ equalizerController ->
|
) { equalizerController ->
|
||||||
isEqualizerAvailable = if (equalizerController != null) {
|
isEqualizerAvailable = if (equalizerController != null) {
|
||||||
Timber.d("EqualizerController Observer.onChanged received controller")
|
Timber.d("EqualizerController Observer.onChanged received controller")
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
Timber.d("EqualizerController Observer.onChanged has no controller")
|
Timber.d("EqualizerController Observer.onChanged has no controller")
|
||||||
false
|
false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
// Observe playlist changes and update the UI
|
// Observe playlist changes and update the UI
|
||||||
rxBusSubscription = RxBus.playlistObservable.subscribe {
|
// FIXME
|
||||||
|
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
||||||
onPlaylistChanged()
|
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
|
// Query the Jukebox state in an IO Context
|
||||||
ioScope.launch(CommunicationError.getHandler(context)) {
|
ioScope.launch(CommunicationError.getHandler(context)) {
|
||||||
try {
|
try {
|
||||||
|
@ -412,16 +420,15 @@ class PlayerFragment :
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (mediaPlayerController.currentPlaying == null) {
|
if (mediaPlayerController.currentPlayingLegacy == null) {
|
||||||
playlistFlipper.displayedChild = 1
|
playlistFlipper.displayedChild = 1
|
||||||
} else {
|
} else {
|
||||||
// Download list and Album art must be updated when Resumed
|
// Download list and Album art must be updated when resumed
|
||||||
onPlaylistChanged()
|
onPlaylistChanged()
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
}
|
}
|
||||||
val handler = Handler()
|
|
||||||
|
|
||||||
// TODO Use Rx for Update instead of polling!
|
val handler = Handler(Looper.getMainLooper())
|
||||||
val runnable = Runnable { handler.post { update(cancellationToken) } }
|
val runnable = Runnable { handler.post { update(cancellationToken) } }
|
||||||
executorService = Executors.newSingleThreadScheduledExecutor()
|
executorService = Executors.newSingleThreadScheduledExecutor()
|
||||||
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
|
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
|
||||||
|
@ -441,7 +448,7 @@ class PlayerFragment :
|
||||||
|
|
||||||
// Scroll to current playing.
|
// Scroll to current playing.
|
||||||
private fun scrollToCurrent() {
|
private fun scrollToCurrent() {
|
||||||
val index = mediaPlayerController.playList.indexOf(currentPlaying)
|
val index = mediaPlayerController.currentMediaItemIndex
|
||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
val smoothScroller = LinearSmoothScroller(context)
|
val smoothScroller = LinearSmoothScroller(context)
|
||||||
|
@ -459,7 +466,7 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
rxBusSubscription?.dispose()
|
rxBusSubscription.dispose()
|
||||||
cancel("CoroutineScope cancelled because the view was destroyed")
|
cancel("CoroutineScope cancelled because the view was destroyed")
|
||||||
cancellationToken.cancel()
|
cancellationToken.cancel()
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
@ -504,7 +511,7 @@ class PlayerFragment :
|
||||||
visualizerMenuItem.isVisible = isVisualizerAvailable
|
visualizerMenuItem.isVisible = isVisualizerAvailable
|
||||||
}
|
}
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerController
|
||||||
val downloadFile = mediaPlayerController.currentPlaying
|
val downloadFile = mediaPlayerController.currentPlayingLegacy
|
||||||
|
|
||||||
if (downloadFile != null) {
|
if (downloadFile != null) {
|
||||||
currentSong = downloadFile.track
|
currentSong = downloadFile.track
|
||||||
|
@ -631,7 +638,7 @@ class PlayerFragment :
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
R.id.menu_shuffle -> {
|
R.id.menu_shuffle -> {
|
||||||
mediaPlayerController.shuffle()
|
mediaPlayerController.toggleShuffle()
|
||||||
Util.toast(context, R.string.download_menu_shuffle_notification)
|
Util.toast(context, R.string.download_menu_shuffle_notification)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -768,10 +775,10 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun update(cancel: CancellationToken?) {
|
private fun update(cancel: CancellationToken? = null) {
|
||||||
if (cancel!!.isCancellationRequested) return
|
if (cancel?.isCancellationRequested == true) return
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerController
|
||||||
if (currentPlaying != mediaPlayerController.currentPlaying) {
|
if (currentPlaying != mediaPlayerController.currentPlayingLegacy) {
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
}
|
}
|
||||||
onSliderProgressChanged()
|
onSliderProgressChanged()
|
||||||
|
@ -822,23 +829,6 @@ class PlayerFragment :
|
||||||
scrollToCurrent()
|
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() {
|
private fun initPlaylistDisplay() {
|
||||||
// Create a View Manager
|
// Create a View Manager
|
||||||
|
@ -852,17 +842,17 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create listener
|
// Create listener
|
||||||
val listener: ((DownloadFile) -> Unit) = { file ->
|
val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos ->
|
||||||
val list = mediaPlayerController.playList
|
mediaPlayerController.seekTo(pos, 0)
|
||||||
val index = list.indexOf(file)
|
mediaPlayerController.prepare()
|
||||||
mediaPlayerController.play(index)
|
mediaPlayerController.play()
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
onSliderProgressChanged()
|
onSliderProgressChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
onItemClick = listener,
|
onItemClick = clickHandler,
|
||||||
checkable = false,
|
checkable = false,
|
||||||
draggable = true,
|
draggable = true,
|
||||||
context = requireContext(),
|
context = requireContext(),
|
||||||
|
@ -879,62 +869,63 @@ class PlayerFragment :
|
||||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override fun onMove(
|
override fun onMove(
|
||||||
recyclerView: RecyclerView,
|
recyclerView: RecyclerView,
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
target: RecyclerView.ViewHolder
|
target: RecyclerView.ViewHolder
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
|
||||||
val from = viewHolder.bindingAdapterPosition
|
val from = viewHolder.bindingAdapterPosition
|
||||||
val to = target.bindingAdapterPosition
|
val to = target.bindingAdapterPosition
|
||||||
|
|
||||||
// Move it in the data set
|
// Move it in the data set
|
||||||
mediaPlayerController.moveItemInPlaylist(from, to)
|
mediaPlayerController.moveItemInPlaylist(from, to)
|
||||||
viewAdapter.submitList(mediaPlayerController.playList)
|
viewAdapter.submitList(mediaPlayerController.playList)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swipe to delete from playlist
|
// Swipe to delete from playlist
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
val pos = viewHolder.bindingAdapterPosition
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
val file = mediaPlayerController.playList[pos]
|
val pos = viewHolder.bindingAdapterPosition
|
||||||
mediaPlayerController.removeFromPlaylist(file)
|
val file = mediaPlayerController.playList[pos]
|
||||||
|
mediaPlayerController.removeFromPlaylist(file)
|
||||||
|
|
||||||
val songRemoved = String.format(
|
val songRemoved = String.format(
|
||||||
resources.getString(R.string.download_song_removed),
|
resources.getString(R.string.download_song_removed),
|
||||||
file.track.title
|
file.track.title
|
||||||
)
|
)
|
||||||
Util.toast(context, songRemoved)
|
Util.toast(context, songRemoved)
|
||||||
|
|
||||||
viewAdapter.submitList(mediaPlayerController.playList)
|
viewAdapter.submitList(mediaPlayerController.playList)
|
||||||
viewAdapter.notifyDataSetChanged()
|
viewAdapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSelectedChanged(
|
override fun onSelectedChanged(
|
||||||
viewHolder: RecyclerView.ViewHolder?,
|
viewHolder: RecyclerView.ViewHolder?,
|
||||||
actionState: Int
|
actionState: Int
|
||||||
) {
|
) {
|
||||||
super.onSelectedChanged(viewHolder, actionState)
|
super.onSelectedChanged(viewHolder, actionState)
|
||||||
|
|
||||||
if (actionState == ACTION_STATE_DRAG) {
|
if (actionState == ACTION_STATE_DRAG) {
|
||||||
viewHolder?.itemView?.alpha = 0.6f
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
dragTouchHelper.attachToRecyclerView(playlistView)
|
||||||
|
@ -950,32 +941,33 @@ class PlayerFragment :
|
||||||
emptyTextView.isVisible = list.isEmpty()
|
emptyTextView.isVisible = list.isEmpty()
|
||||||
|
|
||||||
when (mediaPlayerController.repeatMode) {
|
when (mediaPlayerController.repeatMode) {
|
||||||
RepeatMode.OFF -> repeatButton.setImageDrawable(
|
0 -> repeatButton.setImageDrawable(
|
||||||
Util.getDrawableFromAttribute(
|
Util.getDrawableFromAttribute(
|
||||||
requireContext(), R.attr.media_repeat_off
|
requireContext(), R.attr.media_repeat_off
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
RepeatMode.ALL -> repeatButton.setImageDrawable(
|
1 -> repeatButton.setImageDrawable(
|
||||||
Util.getDrawableFromAttribute(
|
|
||||||
requireContext(), R.attr.media_repeat_all
|
|
||||||
)
|
|
||||||
)
|
|
||||||
RepeatMode.SINGLE -> repeatButton.setImageDrawable(
|
|
||||||
Util.getDrawableFromAttribute(
|
Util.getDrawableFromAttribute(
|
||||||
requireContext(), R.attr.media_repeat_single
|
requireContext(), R.attr.media_repeat_single
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
2 -> repeatButton.setImageDrawable(
|
||||||
|
Util.getDrawableFromAttribute(
|
||||||
|
requireContext(), R.attr.media_repeat_all
|
||||||
|
)
|
||||||
|
)
|
||||||
else -> {
|
else -> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCurrentChanged() {
|
private fun onCurrentChanged() {
|
||||||
currentPlaying = mediaPlayerController.currentPlaying
|
currentPlaying = mediaPlayerController.currentPlayingLegacy
|
||||||
|
|
||||||
scrollToCurrent()
|
scrollToCurrent()
|
||||||
val totalDuration = mediaPlayerController.playListDuration
|
val totalDuration = mediaPlayerController.playListDuration
|
||||||
val totalSongs = mediaPlayerController.playlistSize.toLong()
|
val totalSongs = mediaPlayerController.playlistSize.toLong()
|
||||||
val currentSongIndex = mediaPlayerController.currentPlayingNumberOnPlaylist + 1
|
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
|
||||||
val duration = Util.formatTotalDuration(totalDuration)
|
val duration = Util.formatTotalDuration(totalDuration)
|
||||||
val trackFormat =
|
val trackFormat =
|
||||||
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
||||||
|
@ -992,7 +984,7 @@ class PlayerFragment :
|
||||||
genreTextView.isVisible =
|
genreTextView.isVisible =
|
||||||
(currentSong!!.genre != null && currentSong!!.genre!!.isNotBlank())
|
(currentSong!!.genre != null && currentSong!!.genre!!.isNotBlank())
|
||||||
|
|
||||||
var bitRate: String = ""
|
var bitRate = ""
|
||||||
if (currentSong!!.bitRate != null && currentSong!!.bitRate!! > 0)
|
if (currentSong!!.bitRate != null && currentSong!!.bitRate!! > 0)
|
||||||
bitRate = String.format(
|
bitRate = String.format(
|
||||||
Util.appContext().getString(R.string.song_details_kbps),
|
Util.appContext().getString(R.string.song_details_kbps),
|
||||||
|
@ -1027,14 +1019,14 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongMethod", "ComplexMethod")
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun onSliderProgressChanged() {
|
private fun onSliderProgressChanged() {
|
||||||
|
|
||||||
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
||||||
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
||||||
val duration: Int = mediaPlayerController.playerDuration
|
val duration: Int = mediaPlayerController.playerDuration
|
||||||
val playerState: PlayerState = mediaPlayerController.playerState
|
val playbackState: Int = mediaPlayerController.playbackState
|
||||||
|
val isPlaying = mediaPlayerController.isPlaying
|
||||||
|
|
||||||
if (cancellationToken.isCancellationRequested) return
|
if (cancellationToken.isCancellationRequested) return
|
||||||
if (currentPlaying != null) {
|
if (currentPlaying != null) {
|
||||||
|
@ -1043,7 +1035,7 @@ class PlayerFragment :
|
||||||
progressBar.max =
|
progressBar.max =
|
||||||
if (duration == 0) 100 else duration // Work-around for apparent bug.
|
if (duration == 0) 100 else duration // Work-around for apparent bug.
|
||||||
progressBar.progress = millisPlayed
|
progressBar.progress = millisPlayed
|
||||||
progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled
|
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
|
||||||
} else {
|
} else {
|
||||||
positionTextView.setText(R.string.util_zero_time)
|
positionTextView.setText(R.string.util_zero_time)
|
||||||
durationTextView.setText(R.string.util_no_time)
|
durationTextView.setText(R.string.util_no_time)
|
||||||
|
@ -1052,21 +1044,20 @@ class PlayerFragment :
|
||||||
progressBar.isEnabled = false
|
progressBar.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
when (playerState) {
|
val progress = mediaPlayerController.bufferedPercentage
|
||||||
PlayerState.DOWNLOADING -> {
|
|
||||||
val progress =
|
when (playbackState) {
|
||||||
if (currentPlaying != null) currentPlaying!!.progress.value!! else 0
|
Player.STATE_BUFFERING -> {
|
||||||
|
|
||||||
val downloadStatus = resources.getString(
|
val downloadStatus = resources.getString(
|
||||||
R.string.download_playerstate_downloading,
|
R.string.download_playerstate_downloading,
|
||||||
Util.formatPercentage(progress)
|
Util.formatPercentage(progress)
|
||||||
)
|
)
|
||||||
|
progressBar.secondaryProgress = progress
|
||||||
setTitle(this@PlayerFragment, downloadStatus)
|
setTitle(this@PlayerFragment, downloadStatus)
|
||||||
}
|
}
|
||||||
PlayerState.PREPARING -> setTitle(
|
Player.STATE_READY -> {
|
||||||
this@PlayerFragment,
|
progressBar.secondaryProgress = progress
|
||||||
R.string.download_playerstate_buffering
|
|
||||||
)
|
|
||||||
PlayerState.STARTED -> {
|
|
||||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
if (mediaPlayerController.isShufflePlayEnabled) {
|
||||||
setTitle(
|
setTitle(
|
||||||
this@PlayerFragment,
|
this@PlayerFragment,
|
||||||
|
@ -1076,30 +1067,28 @@ class PlayerFragment :
|
||||||
setTitle(this@PlayerFragment, R.string.common_appname)
|
setTitle(this@PlayerFragment, R.string.common_appname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerState.IDLE,
|
Player.STATE_IDLE,
|
||||||
PlayerState.PREPARED,
|
Player.STATE_ENDED,
|
||||||
PlayerState.STOPPED,
|
-> {
|
||||||
PlayerState.PAUSED,
|
|
||||||
PlayerState.COMPLETED -> {
|
|
||||||
}
|
}
|
||||||
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (playerState) {
|
when (playbackState) {
|
||||||
PlayerState.STARTED -> {
|
Player.STATE_READY -> {
|
||||||
pauseButton.isVisible = true
|
pauseButton.isVisible = isPlaying
|
||||||
stopButton.isVisible = false
|
stopButton.isVisible = false
|
||||||
startButton.isVisible = false
|
playButton.isVisible = !isPlaying
|
||||||
}
|
}
|
||||||
PlayerState.DOWNLOADING, PlayerState.PREPARING -> {
|
Player.STATE_BUFFERING -> {
|
||||||
pauseButton.isVisible = false
|
pauseButton.isVisible = false
|
||||||
stopButton.isVisible = true
|
stopButton.isVisible = true
|
||||||
startButton.isVisible = false
|
playButton.isVisible = false
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
pauseButton.isVisible = false
|
pauseButton.isVisible = false
|
||||||
stopButton.isVisible = false
|
stopButton.isVisible = false
|
||||||
startButton.isVisible = true
|
playButton.isVisible = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -109,7 +109,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
onItemClick = ::onItemClick,
|
onItemClick = { file, _ -> onItemClick(file) },
|
||||||
onContextMenuClick = ::onContextMenuItemSelected,
|
onContextMenuClick = ::onContextMenuItemSelected,
|
||||||
checkable = false,
|
checkable = false,
|
||||||
draggable = false,
|
draggable = false,
|
||||||
|
@ -151,7 +151,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||||
|
|
||||||
val arguments = arguments
|
val arguments = arguments
|
||||||
val autoPlay = arguments != null &&
|
val autoPlay = arguments != null &&
|
||||||
arguments.getBoolean(Constants.INTENT_AUTOPLAY, false)
|
arguments.getBoolean(Constants.INTENT_AUTOPLAY, false)
|
||||||
val query = arguments?.getString(Constants.INTENT_QUERY)
|
val query = arguments?.getString(Constants.INTENT_QUERY)
|
||||||
|
|
||||||
// If started with a query, enter it to the searchView
|
// If started with a query, enter it to the searchView
|
||||||
|
@ -303,13 +303,12 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||||
}
|
}
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerController.addToPlaylist(
|
||||||
listOf(song),
|
listOf(song),
|
||||||
save = false,
|
cachePermanently = false,
|
||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
playNext = false,
|
|
||||||
shuffle = 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))
|
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 var listView: ListView? = null
|
||||||
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
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 val activeServerProvider: ActiveServerProvider by inject()
|
||||||
private var serverRowAdapter: ServerRowAdapter? = null
|
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.
|
// 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
|
// Maybe this can be refactored by using LiveData, or this can be made more user friendly with a ProgressDialog
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
controller.clearIncomplete()
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (activeServerProvider.getActiveServer().index != index) {
|
if (activeServerProvider.getActiveServer().index != index) {
|
||||||
service.clearIncomplete()
|
|
||||||
activeServerProvider.setActiveServerByIndex(index)
|
activeServerProvider.setActiveServerByIndex(index)
|
||||||
service.isJukeboxEnabled =
|
|
||||||
activeServerProvider.getActiveServer().jukeboxByDefault
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
controller.isJukeboxEnabled =
|
||||||
|
activeServerProvider.getActiveServer().jukeboxByDefault
|
||||||
}
|
}
|
||||||
Timber.i("Active server was set to: $index")
|
Timber.i("Active server was set to: $index")
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ import androidx.preference.PreferenceFragmentCompat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import org.koin.core.component.KoinComponent
|
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.R
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
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.ErrorDialog
|
||||||
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
||||||
import org.moire.ultrasonic.util.InfoDialog
|
import org.moire.ultrasonic.util.InfoDialog
|
||||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Settings.preferences
|
import org.moire.ultrasonic.util.Settings.preferences
|
||||||
import org.moire.ultrasonic.util.Settings.shareGreeting
|
import org.moire.ultrasonic.util.Settings.shareGreeting
|
||||||
|
@ -89,12 +88,7 @@ class SettingsFragment :
|
||||||
private var debugLogToFile: CheckBoxPreference? = null
|
private var debugLogToFile: CheckBoxPreference? = null
|
||||||
private var customCacheLocation: CheckBoxPreference? = null
|
private var customCacheLocation: CheckBoxPreference? = null
|
||||||
|
|
||||||
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
|
private val mediaPlayerController: MediaPlayerController by inject()
|
||||||
MediaPlayerController::class.java
|
|
||||||
)
|
|
||||||
private val mediaSessionHandler = inject<MediaSessionHandler>(
|
|
||||||
MediaSessionHandler::class.java
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.settings, rootKey)
|
setPreferencesFromResource(R.xml.settings, rootKey)
|
||||||
|
@ -221,9 +215,6 @@ class SettingsFragment :
|
||||||
Constants.PREFERENCES_KEY_HIDE_MEDIA -> {
|
Constants.PREFERENCES_KEY_HIDE_MEDIA -> {
|
||||||
setHideMedia(sharedPreferences.getBoolean(key, false))
|
setHideMedia(sharedPreferences.getBoolean(key, false))
|
||||||
}
|
}
|
||||||
Constants.PREFERENCES_KEY_MEDIA_BUTTONS -> {
|
|
||||||
setMediaButtonsEnabled(sharedPreferences.getBoolean(key, true))
|
|
||||||
}
|
|
||||||
Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS -> {
|
Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS -> {
|
||||||
setBluetoothPreferences(sharedPreferences.getBoolean(key, true))
|
setBluetoothPreferences(sharedPreferences.getBoolean(key, true))
|
||||||
}
|
}
|
||||||
|
@ -433,11 +424,6 @@ class SettingsFragment :
|
||||||
toast(activity, R.string.settings_hide_media_toast, false)
|
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) {
|
private fun setBluetoothPreferences(enabled: Boolean) {
|
||||||
sendBluetoothAlbumArt!!.isEnabled = enabled
|
sendBluetoothAlbumArt!!.isEnabled = enabled
|
||||||
}
|
}
|
||||||
|
@ -451,8 +437,8 @@ class SettingsFragment :
|
||||||
Settings.cacheLocationUri = path
|
Settings.cacheLocationUri = path
|
||||||
|
|
||||||
// Clear download queue.
|
// Clear download queue.
|
||||||
mediaPlayerControllerLazy.value.clear()
|
mediaPlayerController.clear()
|
||||||
mediaPlayerControllerLazy.value.clearCaches()
|
mediaPlayerController.clearCaches()
|
||||||
Storage.reset()
|
Storage.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -122,7 +122,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
onItemClick = { onItemClick(it.track) },
|
onItemClick = { file, _ -> onItemClick(file.track) },
|
||||||
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
|
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
|
||||||
checkable = true,
|
checkable = true,
|
||||||
draggable = false,
|
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.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
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.SearchCriteria
|
||||||
import org.moire.ultrasonic.domain.SearchResult
|
import org.moire.ultrasonic.domain.SearchResult
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
@ -73,7 +73,6 @@ private const val SEARCH_LIMIT = 10
|
||||||
class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||||
|
|
||||||
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
|
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
|
||||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
|
||||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||||
private val activeServerProvider: ActiveServerProvider by inject()
|
private val activeServerProvider: ActiveServerProvider by inject()
|
||||||
private val musicService = MusicServiceFactory.getMusicService()
|
private val musicService = MusicServiceFactory.getMusicService()
|
||||||
|
@ -108,9 +107,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||||
playFromSearchCommand(it.first)
|
playFromSearchCommand(it.first)
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSessionHandler.initialize()
|
|
||||||
|
|
||||||
val handler = Handler()
|
val handler = Handler(Looper.getMainLooper())
|
||||||
handler.postDelayed(
|
handler.postDelayed(
|
||||||
{
|
{
|
||||||
// Ultrasonic may be started from Android Auto. This boots up the necessary components.
|
// 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..."
|
"AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService..."
|
||||||
)
|
)
|
||||||
lifecycleSupport.onCreate()
|
lifecycleSupport.onCreate()
|
||||||
MediaPlayerService.getInstance()
|
DownloadService.getInstance()
|
||||||
},
|
},
|
||||||
100
|
100
|
||||||
)
|
)
|
||||||
|
@ -186,7 +184,6 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
rxBusSubscription.dispose()
|
rxBusSubscription.dispose()
|
||||||
mediaSessionHandler.release()
|
|
||||||
serviceJob.cancel()
|
serviceJob.cancel()
|
||||||
|
|
||||||
Timber.i("AutoMediaBrowserService onDestroy finished")
|
Timber.i("AutoMediaBrowserService onDestroy finished")
|
||||||
|
@ -662,7 +659,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||||
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
|
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
|
||||||
playlistCache = content?.getTracks()
|
playlistCache = content?.getTracks()
|
||||||
}
|
}
|
||||||
if (playlistCache != null) playSongs(playlistCache)
|
if (playlistCache != null) playSongs(playlistCache!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -905,7 +902,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||||
val content = listStarredSongsInMusicService()
|
val content = listStarredSongsInMusicService()
|
||||||
starredSongsCache = content?.songs
|
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) }
|
val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
|
||||||
randomSongsCache = content?.getTracks()
|
randomSongsCache = content?.getTracks()
|
||||||
}
|
}
|
||||||
if (randomSongsCache != null) playSongs(randomSongsCache)
|
if (randomSongsCache != null) playSongs(randomSongsCache!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1071,27 +1068,25 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||||
return section.toString()
|
return section.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playSongs(songs: List<Track?>?) {
|
private fun playSongs(songs: List<Track>) {
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerController.addToPlaylist(
|
||||||
songs,
|
songs,
|
||||||
save = false,
|
cachePermanently = false,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
playNext = false,
|
|
||||||
shuffle = false,
|
shuffle = false,
|
||||||
newPlaylist = true
|
insertionMode = MediaPlayerController.InsertionMode.CLEAR
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playSong(song: Track) {
|
private fun playSong(song: Track) {
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerController.addToPlaylist(
|
||||||
listOf(song),
|
listOf(song),
|
||||||
save = false,
|
cachePermanently = false,
|
||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
playNext = true,
|
|
||||||
shuffle = false,
|
shuffle = false,
|
||||||
newPlaylist = false
|
insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||||
)
|
)
|
||||||
if (mediaPlayerController.playlistSize > 1) mediaPlayerController.next()
|
if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next()
|
||||||
else mediaPlayerController.play()
|
else mediaPlayerController.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,27 +7,18 @@
|
||||||
|
|
||||||
package org.moire.ultrasonic.service
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.text.TextUtils
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import org.koin.core.component.KoinComponent
|
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.Identifiable
|
||||||
import org.moire.ultrasonic.domain.Track
|
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.CancellableTask
|
||||||
import org.moire.ultrasonic.util.FileUtil
|
import org.moire.ultrasonic.util.FileUtil
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Storage
|
import org.moire.ultrasonic.util.Storage
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
import org.moire.ultrasonic.util.Util.safeClose
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -44,29 +35,22 @@ class DownloadFile(
|
||||||
) : KoinComponent, Identifiable {
|
) : KoinComponent, Identifiable {
|
||||||
val partialFile: String
|
val partialFile: String
|
||||||
lateinit var completeFile: String
|
lateinit var completeFile: String
|
||||||
val saveFile: String = FileUtil.getSongFile(track)
|
val pinnedFile: String = FileUtil.getSongFile(track)
|
||||||
var shouldSave = save
|
var shouldSave = save
|
||||||
private var downloadTask: CancellableTask? = null
|
internal var downloadTask: CancellableTask? = null
|
||||||
var isFailed = false
|
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 priority = 100
|
||||||
var downloadPrepared = false
|
var downloadPrepared = false
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var isPlaying = false
|
internal var saveWhenDone = false
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var saveWhenDone = false
|
var completeWhenDone = false
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var completeWhenDone = false
|
|
||||||
|
|
||||||
private val downloader: Downloader by inject()
|
|
||||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
|
||||||
private val activeServerProvider: ActiveServerProvider by inject()
|
|
||||||
|
|
||||||
val progress: MutableLiveData<Int> = MutableLiveData(0)
|
val progress: MutableLiveData<Int> = MutableLiveData(0)
|
||||||
|
|
||||||
|
@ -78,7 +62,7 @@ class DownloadFile(
|
||||||
|
|
||||||
private val lazyInitialStatus: Lazy<DownloadStatus> = lazy {
|
private val lazyInitialStatus: Lazy<DownloadStatus> = lazy {
|
||||||
when {
|
when {
|
||||||
Storage.isPathExists(saveFile) -> {
|
Storage.isPathExists(pinnedFile) -> {
|
||||||
DownloadStatus.PINNED
|
DownloadStatus.PINNED
|
||||||
}
|
}
|
||||||
Storage.isPathExists(completeFile) -> {
|
Storage.isPathExists(completeFile) -> {
|
||||||
|
@ -95,10 +79,10 @@ class DownloadFile(
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
partialFile = FileUtil.getParentPath(saveFile) + "/" +
|
partialFile = FileUtil.getParentPath(pinnedFile) + "/" +
|
||||||
FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile))
|
FileUtil.getPartialFile(FileUtil.getNameFromPath(pinnedFile))
|
||||||
completeFile = FileUtil.getParentPath(saveFile) + "/" +
|
completeFile = FileUtil.getParentPath(pinnedFile) + "/" +
|
||||||
FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile))
|
FileUtil.getCompleteFile(FileUtil.getNameFromPath(pinnedFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -115,13 +99,6 @@ class DownloadFile(
|
||||||
downloadPrepared = true
|
downloadPrepared = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun download() {
|
|
||||||
FileUtil.createDirectoryForParent(saveFile)
|
|
||||||
isFailed = false
|
|
||||||
downloadTask = DownloadTask()
|
|
||||||
downloadTask!!.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun cancelDownload() {
|
fun cancelDownload() {
|
||||||
|
@ -129,30 +106,23 @@ class DownloadFile(
|
||||||
}
|
}
|
||||||
|
|
||||||
val completeOrSaveFile: String
|
val completeOrSaveFile: String
|
||||||
get() = if (Storage.isPathExists(saveFile)) {
|
get() = if (Storage.isPathExists(pinnedFile)) {
|
||||||
saveFile
|
pinnedFile
|
||||||
} else {
|
} else {
|
||||||
completeFile
|
completeFile
|
||||||
}
|
}
|
||||||
|
|
||||||
val completeOrPartialFile: String
|
|
||||||
get() = if (isCompleteFileAvailable) {
|
|
||||||
completeOrSaveFile
|
|
||||||
} else {
|
|
||||||
partialFile
|
|
||||||
}
|
|
||||||
|
|
||||||
val isSaved: Boolean
|
val isSaved: Boolean
|
||||||
get() = Storage.isPathExists(saveFile)
|
get() = Storage.isPathExists(pinnedFile)
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val isCompleteFileAvailable: Boolean
|
val isCompleteFileAvailable: Boolean
|
||||||
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)
|
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val isWorkDone: Boolean
|
val isWorkDone: Boolean
|
||||||
get() = Storage.isPathExists(completeFile) && !shouldSave ||
|
get() = Storage.isPathExists(completeFile) && !shouldSave ||
|
||||||
Storage.isPathExists(saveFile) || saveWhenDone || completeWhenDone
|
Storage.isPathExists(pinnedFile) || saveWhenDone || completeWhenDone
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val isDownloading: Boolean
|
val isDownloading: Boolean
|
||||||
|
@ -170,54 +140,66 @@ class DownloadFile(
|
||||||
cancelDownload()
|
cancelDownload()
|
||||||
Storage.delete(partialFile)
|
Storage.delete(partialFile)
|
||||||
Storage.delete(completeFile)
|
Storage.delete(completeFile)
|
||||||
Storage.delete(saveFile)
|
Storage.delete(pinnedFile)
|
||||||
|
|
||||||
status.postValue(DownloadStatus.IDLE)
|
status.postValue(DownloadStatus.IDLE)
|
||||||
|
|
||||||
Util.scanMedia(saveFile)
|
Util.scanMedia(pinnedFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unpin() {
|
fun unpin() {
|
||||||
val file = Storage.getFromPath(saveFile) ?: return
|
Timber.e("CLEANING")
|
||||||
|
val file = Storage.getFromPath(pinnedFile) ?: return
|
||||||
Storage.rename(file, completeFile)
|
Storage.rename(file, completeFile)
|
||||||
status.postValue(DownloadStatus.DONE)
|
status.postValue(DownloadStatus.DONE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cleanup(): Boolean {
|
fun cleanup(): Boolean {
|
||||||
|
Timber.e("CLEANING")
|
||||||
var ok = true
|
var ok = true
|
||||||
if (Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)) {
|
if (Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)) {
|
||||||
ok = Storage.delete(partialFile)
|
ok = Storage.delete(partialFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Storage.isPathExists(saveFile)) {
|
if (Storage.isPathExists(pinnedFile)) {
|
||||||
ok = ok and Storage.delete(completeFile)
|
ok = ok and Storage.delete(completeFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPlaying(isPlaying: Boolean) {
|
/**
|
||||||
if (!isPlaying) doPendingRename()
|
* Create a MediaItem instance representing the data inside this DownloadFile
|
||||||
this.isPlaying = isPlaying
|
*/
|
||||||
|
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
|
// Do a pending rename after the song has stopped playing
|
||||||
private fun doPendingRename() {
|
private fun doPendingRename() {
|
||||||
try {
|
try {
|
||||||
|
Timber.e("CLEANING")
|
||||||
if (saveWhenDone) {
|
if (saveWhenDone) {
|
||||||
Storage.rename(completeFile, saveFile)
|
Storage.rename(completeFile, pinnedFile)
|
||||||
saveWhenDone = false
|
saveWhenDone = false
|
||||||
} else if (completeWhenDone) {
|
} else if (completeWhenDone) {
|
||||||
if (shouldSave) {
|
if (shouldSave) {
|
||||||
Storage.rename(partialFile, saveFile)
|
Storage.rename(partialFile, pinnedFile)
|
||||||
Util.scanMedia(saveFile)
|
Util.scanMedia(pinnedFile)
|
||||||
} else {
|
} else {
|
||||||
Storage.rename(partialFile, completeFile)
|
Storage.rename(partialFile, completeFile)
|
||||||
}
|
}
|
||||||
completeWhenDone = false
|
completeWhenDone = false
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} 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)
|
return String.format(Locale.ROOT, "DownloadFile (%s)", track)
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class DownloadTask : CancellableTask() {
|
internal fun setProgress(totalBytesCopied: Long) {
|
||||||
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) {
|
|
||||||
if (track.size != null) {
|
if (track.size != null) {
|
||||||
progress.postValue((totalBytesCopied * 100 / track.size!!).toInt())
|
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
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.text.TextUtils
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import java.util.ArrayList
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
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 org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
|
import org.moire.ultrasonic.domain.Artist
|
||||||
import org.moire.ultrasonic.domain.Track
|
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.LRUCache
|
||||||
import org.moire.ultrasonic.util.Settings
|
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
|
||||||
|
import org.moire.ultrasonic.util.Util.safeClose
|
||||||
import timber.log.Timber
|
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
|
* This class is responsible for maintaining the playlist and downloading
|
||||||
* its items from the network to the filesystem.
|
* its items from the network to the filesystem.
|
||||||
*
|
*
|
||||||
* TODO: Move away from managing the queue with scheduled checks, instead use callbacks when
|
* TODO: Move entirely to subclass the Media3.DownloadService
|
||||||
* Downloads are finished
|
|
||||||
*/
|
*/
|
||||||
class Downloader(
|
class Downloader(
|
||||||
private val shufflePlayBuffer: ShufflePlayBuffer,
|
private val storageMonitor: ExternalStorageMonitor,
|
||||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
private val legacyPlaylistManager: LegacyPlaylistManager,
|
||||||
private val localMediaPlayer: LocalMediaPlayer
|
|
||||||
) : KoinComponent {
|
) : 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 started: Boolean = false
|
||||||
|
var shouldStop: Boolean = false
|
||||||
|
|
||||||
private val downloadQueue = PriorityQueue<DownloadFile>()
|
private val downloadQueue = PriorityQueue<DownloadFile>()
|
||||||
private val activelyDownloading = mutableListOf<DownloadFile>()
|
private val activelyDownloading = mutableListOf<DownloadFile>()
|
||||||
|
|
||||||
// TODO: The playlist is now published with RX, while the observableDownloads is using LiveData.
|
// The generic list models expect a LiveData, so even though we are using Rx for many events
|
||||||
// Use the same for both
|
// surrounding playback the list of Downloads is published as LiveData.
|
||||||
val observableDownloads = MutableLiveData<List<DownloadFile>>()
|
val observableDownloads = MutableLiveData<List<DownloadFile>>()
|
||||||
|
|
||||||
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
|
||||||
|
|
||||||
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
|
// 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 wifiLock: WifiManager.WifiLock? = null
|
||||||
|
|
||||||
private var playlistUpdateRevision: Long = 0
|
private var backgroundPriorityCounter = 100
|
||||||
private set(value) {
|
|
||||||
field = value
|
|
||||||
RxBus.playlistPublisher.onNext(playlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
var backgroundPriorityCounter = 100
|
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||||
|
|
||||||
val downloadChecker = Runnable {
|
var downloadChecker = object : Runnable {
|
||||||
try {
|
override fun run() {
|
||||||
Timber.w("Checking Downloads")
|
try {
|
||||||
checkDownloadsInternal()
|
Timber.w("Checking Downloads")
|
||||||
} catch (all: Exception) {
|
checkDownloadsInternal()
|
||||||
Timber.e(all, "checkDownloads() failed.")
|
} catch (all: Exception) {
|
||||||
|
Timber.e(all, "checkDownloads() failed.")
|
||||||
|
} finally {
|
||||||
|
if (!shouldStop) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed(this, CHECK_INTERVAL)
|
||||||
|
} else {
|
||||||
|
shouldStop = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onDestroy() {
|
fun onDestroy() {
|
||||||
stop()
|
stop()
|
||||||
clearPlaylist()
|
rxBusSubscription.dispose()
|
||||||
clearBackground()
|
clearBackground()
|
||||||
observableDownloads.value = listOf()
|
observableDownloads.value = listOf()
|
||||||
Timber.i("Downloader destroyed")
|
Timber.i("Downloader destroyed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun start() {
|
fun start() {
|
||||||
started = true
|
started = true
|
||||||
if (executorService == null) {
|
|
||||||
executorService = Executors.newSingleThreadScheduledExecutor()
|
// Start our loop
|
||||||
executorService!!.scheduleWithFixedDelay(
|
handler.postDelayed(downloadChecker, 100)
|
||||||
downloadChecker, 0L, CHECK_INTERVAL, TimeUnit.SECONDS
|
|
||||||
)
|
|
||||||
Timber.i("Downloader started")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wifiLock == null) {
|
if (wifiLock == null) {
|
||||||
wifiLock = Util.createWifiLock(toString())
|
wifiLock = Util.createWifiLock(toString())
|
||||||
wifiLock?.acquire()
|
wifiLock?.acquire()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check downloads if the playlist changed
|
||||||
|
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
||||||
|
checkDownloads()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
started = false
|
started = false
|
||||||
executorService?.shutdown()
|
shouldStop = true
|
||||||
executorService = null
|
|
||||||
wifiLock?.release()
|
wifiLock?.release()
|
||||||
wifiLock = null
|
wifiLock = null
|
||||||
MediaPlayerService.runningInstance?.notifyDownloaderStopped()
|
DownloadService.runningInstance?.notifyDownloaderStopped()
|
||||||
Timber.i("Downloader stopped")
|
Timber.i("Downloader stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkDownloads() {
|
fun checkDownloads() {
|
||||||
if (
|
if (!started) {
|
||||||
executorService == null ||
|
|
||||||
executorService!!.isTerminated ||
|
|
||||||
executorService!!.isShutdown
|
|
||||||
) {
|
|
||||||
start()
|
start()
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
executorService?.execute(downloadChecker)
|
handler.postDelayed(downloadChecker, 100)
|
||||||
} catch (exception: RejectedExecutionException) {
|
} catch (all: Exception) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
exception,
|
all,
|
||||||
"checkDownloads() can't run, maybe the Downloader is shutting down..."
|
"checkDownloads() can't run, maybe the Downloader is shutting down..."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -121,22 +132,17 @@ class Downloader(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@Suppress("ComplexMethod", "ComplexCondition")
|
|
||||||
fun checkDownloadsInternal() {
|
fun checkDownloadsInternal() {
|
||||||
if (
|
if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) {
|
||||||
!Util.isExternalStoragePresent() ||
|
|
||||||
!externalStorageMonitor.isExternalStorageAvailable
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (shufflePlayBuffer.isEnabled) {
|
|
||||||
checkShufflePlay()
|
if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
|
||||||
}
|
|
||||||
if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.v("Downloader checkDownloadsInternal checking downloads")
|
Timber.v("Downloader checkDownloadsInternal checking downloads")
|
||||||
|
|
||||||
// Check the active downloads for failures or completions and remove them
|
// Check the active downloads for failures or completions and remove them
|
||||||
// Store the result in a flag to know if changes have occurred
|
// Store the result in a flag to know if changes have occurred
|
||||||
var listChanged = cleanupActiveDownloads()
|
var listChanged = cleanupActiveDownloads()
|
||||||
|
@ -145,13 +151,14 @@ class Downloader(
|
||||||
val preloadCount = Settings.preloadCount
|
val preloadCount = Settings.preloadCount
|
||||||
|
|
||||||
// Start preloading at the current playing song
|
// Start preloading at the current playing song
|
||||||
var start = currentPlayingIndex
|
var start = mediaController.currentMediaItemIndex
|
||||||
|
|
||||||
if (start == -1) start = 0
|
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) {
|
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)
|
// Set correct priority (the lower the number, the higher the priority)
|
||||||
download.priority = i
|
download.priority = i
|
||||||
|
@ -173,10 +180,6 @@ class Downloader(
|
||||||
activelyDownloading.add(task)
|
activelyDownloading.add(task)
|
||||||
startDownloadOnService(task)
|
startDownloadOnService(task)
|
||||||
|
|
||||||
// The next file on the playlist is currently downloading
|
|
||||||
if (playlist.indexOf(task) == 1) {
|
|
||||||
localMediaPlayer.setNextPlayerState(PlayerState.DOWNLOADING)
|
|
||||||
}
|
|
||||||
listChanged = true
|
listChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,10 +197,14 @@ class Downloader(
|
||||||
observableDownloads.postValue(downloads)
|
observableDownloads.postValue(downloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownloadOnService(task: DownloadFile) {
|
private fun startDownloadOnService(file: DownloadFile) {
|
||||||
task.prepare()
|
if (file.isDownloading) return
|
||||||
MediaPlayerService.executeOnStartedMediaPlayerService {
|
file.prepare()
|
||||||
task.download()
|
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)
|
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
|
@get:Synchronized
|
||||||
val all: List<DownloadFile>
|
val all: List<DownloadFile>
|
||||||
|
@ -252,7 +239,7 @@ class Downloader(
|
||||||
val temp: MutableList<DownloadFile> = ArrayList()
|
val temp: MutableList<DownloadFile> = ArrayList()
|
||||||
temp.addAll(activelyDownloading)
|
temp.addAll(activelyDownloading)
|
||||||
temp.addAll(downloadQueue)
|
temp.addAll(downloadQueue)
|
||||||
temp.addAll(playlist)
|
temp.addAll(legacyPlaylistManager.playlist)
|
||||||
return temp.distinct().sorted()
|
return temp.distinct().sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,7 +254,7 @@ class Downloader(
|
||||||
temp.addAll(activelyDownloading)
|
temp.addAll(activelyDownloading)
|
||||||
temp.addAll(downloadQueue)
|
temp.addAll(downloadQueue)
|
||||||
temp.addAll(
|
temp.addAll(
|
||||||
playlist.filter {
|
legacyPlaylistManager.playlist.filter {
|
||||||
if (!it.isStatusInitialized) false
|
if (!it.isStatusInitialized) false
|
||||||
else when (it.status.value) {
|
else when (it.status.value) {
|
||||||
DownloadStatus.DOWNLOADING -> true
|
DownloadStatus.DOWNLOADING -> true
|
||||||
|
@ -278,37 +265,13 @@ class Downloader(
|
||||||
return temp.distinct().sorted()
|
return temp.distinct().sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public facing playlist (immutable)
|
|
||||||
@Synchronized
|
|
||||||
fun getPlaylist(): List<DownloadFile> = playlist
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun clearDownloadFileCache() {
|
fun clearDownloadFileCache() {
|
||||||
downloadFileCache.clear()
|
downloadFileCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun clearPlaylist() {
|
fun clearBackground() {
|
||||||
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() {
|
|
||||||
// Clear the pending queue
|
// Clear the pending queue
|
||||||
downloadQueue.clear()
|
downloadQueue.clear()
|
||||||
|
|
||||||
|
@ -333,78 +296,6 @@ class Downloader(
|
||||||
updateLiveData()
|
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
|
@Synchronized
|
||||||
fun downloadBackground(songs: List<Track>, save: Boolean) {
|
fun downloadBackground(songs: List<Track>, save: Boolean) {
|
||||||
|
@ -413,30 +304,19 @@ class Downloader(
|
||||||
for (song in songs) {
|
for (song in songs) {
|
||||||
val file = song.getDownloadFile()
|
val file = song.getDownloadFile()
|
||||||
file.shouldSave = save
|
file.shouldSave = save
|
||||||
file.priority = backgroundPriorityCounter++
|
if (!file.isDownloading) {
|
||||||
downloadQueue.add(file)
|
file.priority = backgroundPriorityCounter++
|
||||||
|
downloadQueue.add(file)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkDownloads()
|
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
|
@Synchronized
|
||||||
@Suppress("ReturnCount")
|
@Suppress("ReturnCount")
|
||||||
fun getDownloadFileForSong(song: Track): DownloadFile {
|
fun getDownloadFileForSong(song: Track): DownloadFile {
|
||||||
for (downloadFile in playlist) {
|
for (downloadFile in legacyPlaylistManager.playlist) {
|
||||||
if (downloadFile.track == song) {
|
if (downloadFile.track == song) {
|
||||||
return downloadFile
|
return downloadFile
|
||||||
}
|
}
|
||||||
|
@ -459,63 +339,205 @@ class Downloader(
|
||||||
return downloadFile
|
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 {
|
companion object {
|
||||||
const val PARALLEL_DOWNLOADS = 3
|
const val PARALLEL_DOWNLOADS = 3
|
||||||
const val CHECK_INTERVAL = 5L
|
const val CHECK_INTERVAL = 5000L
|
||||||
const val SHUFFLE_BUFFER_LIMIT = 4
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension function
|
* Extension function
|
||||||
* Gathers the download file for a given song, and modifies shouldSave if provided.
|
* 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 {
|
return getDownloadFileForSong(this).apply {
|
||||||
if (save != null) this.shouldSave = save
|
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
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
import org.moire.ultrasonic.domain.PlayerState
|
||||||
import org.moire.ultrasonic.domain.RepeatMode
|
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.executeOnStartedMediaPlayerService
|
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
||||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.getInstance
|
import org.moire.ultrasonic.playback.PlaybackService
|
||||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.runningInstance
|
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.service.MusicServiceFactory.getMusicService
|
||||||
|
import org.moire.ultrasonic.util.FileUtil
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -32,8 +46,8 @@ class MediaPlayerController(
|
||||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||||
private val downloader: Downloader,
|
private val downloader: Downloader,
|
||||||
private val shufflePlayBuffer: ShufflePlayBuffer,
|
private val legacyPlaylistManager: LegacyPlaylistManager,
|
||||||
private val localMediaPlayer: LocalMediaPlayer
|
val context: Context
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
private var created = false
|
private var created = false
|
||||||
|
@ -42,22 +56,142 @@ class MediaPlayerController(
|
||||||
var showVisualization = false
|
var showVisualization = false
|
||||||
private var autoPlayStart = false
|
private var autoPlayStart = false
|
||||||
|
|
||||||
|
private val scrobbler = Scrobbler()
|
||||||
|
|
||||||
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
||||||
private val activeServerProvider: ActiveServerProvider 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() {
|
fun onCreate() {
|
||||||
if (created) return
|
if (created) return
|
||||||
externalStorageMonitor.onCreate { reset() }
|
externalStorageMonitor.onCreate { reset() }
|
||||||
isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault
|
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
|
created = true
|
||||||
Timber.i("MediaPlayerController created")
|
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() {
|
fun onDestroy() {
|
||||||
if (!created) return
|
if (!created) return
|
||||||
val context = UApp.applicationContext()
|
val context = UApp.applicationContext()
|
||||||
externalStorageMonitor.onDestroy()
|
externalStorageMonitor.onDestroy()
|
||||||
context.stopService(Intent(context, MediaPlayerService::class.java))
|
context.stopService(Intent(context, DownloadService::class.java))
|
||||||
|
legacyPlaylistManager.onDestroy()
|
||||||
downloader.onDestroy()
|
downloader.onDestroy()
|
||||||
created = false
|
created = false
|
||||||
Timber.i("MediaPlayerController destroyed")
|
Timber.i("MediaPlayerController destroyed")
|
||||||
|
@ -73,33 +207,30 @@ class MediaPlayerController(
|
||||||
) {
|
) {
|
||||||
addToPlaylist(
|
addToPlaylist(
|
||||||
songs,
|
songs,
|
||||||
save = false,
|
cachePermanently = false,
|
||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
playNext = false,
|
playNext = false,
|
||||||
shuffle = false,
|
shuffle = false,
|
||||||
newPlaylist = newPlaylist
|
newPlaylist = newPlaylist
|
||||||
)
|
)
|
||||||
|
|
||||||
if (currentPlayingIndex != -1) {
|
if (currentPlayingIndex != -1) {
|
||||||
executeOnStartedMediaPlayerService { mediaPlayerService: MediaPlayerService ->
|
if (jukeboxMediaPlayer.isEnabled) {
|
||||||
mediaPlayerService.play(currentPlayingIndex, autoPlayStart)
|
jukeboxMediaPlayer.skip(
|
||||||
if (localMediaPlayer.currentPlaying != null) {
|
currentPlayingIndex,
|
||||||
if (autoPlay && jukeboxMediaPlayer.isEnabled) {
|
currentPlayingPosition / 1000
|
||||||
jukeboxMediaPlayer.skip(
|
)
|
||||||
downloader.currentPlayingIndex,
|
} else {
|
||||||
currentPlayingPosition / 1000
|
seekTo(currentPlayingIndex, currentPlayingPosition)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
if (localMediaPlayer.currentPlaying!!.isCompleteFileAvailable) {
|
|
||||||
localMediaPlayer.play(
|
|
||||||
localMediaPlayer.currentPlaying,
|
|
||||||
currentPlayingPosition,
|
|
||||||
autoPlay
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
autoPlayStart = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (autoPlay) {
|
||||||
|
prepare()
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
|
||||||
|
autoPlayStart = false
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,93 +241,139 @@ class MediaPlayerController(
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun play(index: Int) {
|
fun play(index: Int) {
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
controller?.seekTo(index, 0L)
|
||||||
service.play(index, true)
|
controller?.play()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun play() {
|
fun play() {
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
if (jukeboxMediaPlayer.isEnabled) {
|
||||||
service.play()
|
jukeboxMediaPlayer.start()
|
||||||
|
} else {
|
||||||
|
controller?.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun prepare() {
|
||||||
|
controller?.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun resumeOrPlay() {
|
fun resumeOrPlay() {
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
controller?.play()
|
||||||
service.resumeOrPlay()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
if (localMediaPlayer.playerState === PlayerState.IDLE) autoPlayStart = true
|
if (playbackState == Player.STATE_IDLE) autoPlayStart = true
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
if (controller?.isPlaying == false) {
|
||||||
service.togglePlayPause()
|
controller?.pause()
|
||||||
|
} else {
|
||||||
|
controller?.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun start() {
|
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
|
||||||
service.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun seekTo(position: Int) {
|
fun seekTo(position: Int) {
|
||||||
val mediaPlayerService = runningInstance
|
controller?.seekTo(position.toLong())
|
||||||
mediaPlayerService?.seekTo(position)
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun seekTo(index: Int, position: Int) {
|
||||||
|
controller?.seekTo(index, position.toLong())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun pause() {
|
fun pause() {
|
||||||
val mediaPlayerService = runningInstance
|
if (jukeboxMediaPlayer.isEnabled) {
|
||||||
mediaPlayerService?.pause()
|
jukeboxMediaPlayer.stop()
|
||||||
|
} else {
|
||||||
|
controller?.pause()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun stop() {
|
fun stop() {
|
||||||
val mediaPlayerService = runningInstance
|
if (jukeboxMediaPlayer.isEnabled) {
|
||||||
mediaPlayerService?.stop()
|
jukeboxMediaPlayer.stop()
|
||||||
|
} else {
|
||||||
|
controller?.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
@Deprecated("Use InsertionMode Syntax")
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
fun addToPlaylist(
|
fun addToPlaylist(
|
||||||
songs: List<Track?>?,
|
songs: List<Track?>?,
|
||||||
save: Boolean,
|
cachePermanently: Boolean,
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
playNext: Boolean,
|
playNext: Boolean,
|
||||||
shuffle: Boolean,
|
shuffle: Boolean,
|
||||||
newPlaylist: Boolean
|
newPlaylist: Boolean
|
||||||
) {
|
) {
|
||||||
if (songs == null) return
|
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 insertionMode = when {
|
||||||
val mediaPlayerService = runningInstance
|
newPlaylist -> InsertionMode.CLEAR
|
||||||
mediaPlayerService?.setNextPlaying()
|
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) {
|
if (autoPlay) {
|
||||||
|
prepare()
|
||||||
play(0)
|
play(0)
|
||||||
} else {
|
} else {
|
||||||
if (localMediaPlayer.currentPlaying == null && downloader.getPlaylist().isNotEmpty()) {
|
|
||||||
localMediaPlayer.currentPlaying = downloader.getPlaylist()[0]
|
|
||||||
downloader.getPlaylist()[0].setPlaying(true)
|
|
||||||
}
|
|
||||||
downloader.checkDownloads()
|
downloader.checkDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackStateSerializer.serialize(
|
playbackStateSerializer.serialize(
|
||||||
downloader.getPlaylist(),
|
legacyPlaylistManager.playlist,
|
||||||
downloader.currentPlayingIndex,
|
currentMediaItemIndex,
|
||||||
playerPosition
|
playerPosition
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -206,77 +383,60 @@ class MediaPlayerController(
|
||||||
if (songs == null) return
|
if (songs == null) return
|
||||||
val filteredSongs = songs.filterNotNull()
|
val filteredSongs = songs.filterNotNull()
|
||||||
downloader.downloadBackground(filteredSongs, save)
|
downloader.downloadBackground(filteredSongs, save)
|
||||||
|
|
||||||
playbackStateSerializer.serialize(
|
playbackStateSerializer.serialize(
|
||||||
downloader.getPlaylist(),
|
legacyPlaylistManager.playlist,
|
||||||
downloader.currentPlayingIndex,
|
currentMediaItemIndex,
|
||||||
playerPosition
|
playerPosition
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setCurrentPlaying(index: Int) {
|
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
mediaPlayerService?.setCurrentPlaying(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopJukeboxService() {
|
fun stopJukeboxService() {
|
||||||
jukeboxMediaPlayer.stopJukeboxService()
|
jukeboxMediaPlayer.stopJukeboxService()
|
||||||
}
|
}
|
||||||
|
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
var isShufflePlayEnabled: Boolean
|
var isShufflePlayEnabled: Boolean
|
||||||
get() = shufflePlayBuffer.isEnabled
|
get() = controller?.shuffleModeEnabled == true
|
||||||
set(enabled) {
|
set(enabled) {
|
||||||
shufflePlayBuffer.isEnabled = enabled
|
controller?.shuffleModeEnabled = enabled
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
clear()
|
|
||||||
downloader.checkDownloads()
|
downloader.checkDownloads()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun shuffle() {
|
fun toggleShuffle() {
|
||||||
downloader.shuffle()
|
isShufflePlayEnabled = !isShufflePlayEnabled
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex,
|
|
||||||
playerPosition
|
|
||||||
)
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
mediaPlayerService?.setNextPlaying()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bufferedPercentage: Int
|
||||||
|
get() = controller?.bufferedPercentage ?: 0
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
||||||
downloader.moveItemInPlaylist(oldPos, newPos)
|
controller?.moveMediaItem(oldPos, newPos)
|
||||||
}
|
}
|
||||||
|
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
var repeatMode: RepeatMode
|
var repeatMode: Int
|
||||||
get() = Settings.repeatMode
|
get() = controller?.repeatMode ?: 0
|
||||||
set(repeatMode) {
|
set(newMode) {
|
||||||
Settings.repeatMode = repeatMode
|
controller?.repeatMode = newMode
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
mediaPlayerService?.setNextPlaying()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
fun clear(serialize: Boolean = true) {
|
fun clear(serialize: Boolean = true) {
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
if (mediaPlayerService != null) {
|
controller?.clearMediaItems()
|
||||||
mediaPlayerService.clear(serialize)
|
|
||||||
} else {
|
if (controller != null && serialize) {
|
||||||
// If no MediaPlayerService is available, just empty the playlist
|
playbackStateSerializer.serialize(
|
||||||
downloader.clearPlaylist()
|
listOf(), -1, 0
|
||||||
if (serialize) {
|
)
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex, playerPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
jukeboxMediaPlayer.updatePlaylist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -289,11 +449,12 @@ class MediaPlayerController(
|
||||||
fun clearIncomplete() {
|
fun clearIncomplete() {
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
downloader.clearIncomplete()
|
downloader.clearActiveDownloads()
|
||||||
|
downloader.clearBackground()
|
||||||
|
|
||||||
playbackStateSerializer.serialize(
|
playbackStateSerializer.serialize(
|
||||||
downloader.getPlaylist(),
|
legacyPlaylistManager.playlist,
|
||||||
downloader.currentPlayingIndex,
|
currentMediaItemIndex,
|
||||||
playerPosition
|
playerPosition
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -301,26 +462,17 @@ class MediaPlayerController(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@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) {
|
fun removeFromPlaylist(downloadFile: DownloadFile) {
|
||||||
if (downloadFile == localMediaPlayer.currentPlaying) {
|
|
||||||
reset()
|
|
||||||
currentPlaying = null
|
|
||||||
}
|
|
||||||
downloader.removeFromPlaylist(downloadFile)
|
|
||||||
|
|
||||||
playbackStateSerializer.serialize(
|
playbackStateSerializer.serialize(
|
||||||
downloader.getPlaylist(),
|
legacyPlaylistManager.playlist,
|
||||||
downloader.currentPlayingIndex,
|
legacyPlaylistManager.currentPlayingIndex,
|
||||||
playerPosition
|
playerPosition
|
||||||
)
|
)
|
||||||
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
jukeboxMediaPlayer.updatePlaylist()
|
||||||
|
|
||||||
if (downloadFile == localMediaPlayer.nextPlaying) {
|
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
mediaPlayerService?.setNextPlaying()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
@ -341,80 +493,56 @@ class MediaPlayerController(
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun previous() {
|
fun previous() {
|
||||||
val index = downloader.currentPlayingIndex
|
controller?.seekToPrevious()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
operator fun next() {
|
operator fun next() {
|
||||||
val index = downloader.currentPlayingIndex
|
controller?.seekToNext()
|
||||||
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 -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun reset() {
|
fun reset() {
|
||||||
val mediaPlayerService = runningInstance
|
controller?.clearMediaItems()
|
||||||
if (mediaPlayerService != null) localMediaPlayer.reset()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val playerPosition: Int
|
val playerPosition: Int
|
||||||
get() {
|
get() {
|
||||||
val mediaPlayerService = runningInstance ?: return 0
|
return if (jukeboxMediaPlayer.isEnabled) {
|
||||||
return mediaPlayerService.playerPosition
|
jukeboxMediaPlayer.positionSeconds * 1000
|
||||||
|
} else {
|
||||||
|
controller?.currentPosition?.toInt() ?: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val playerDuration: Int
|
val playerDuration: Int
|
||||||
get() {
|
get() {
|
||||||
val mediaPlayerService = runningInstance ?: return 0
|
return controller?.duration?.toInt() ?: return 0
|
||||||
return mediaPlayerService.playerDuration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use Controller.playbackState and Controller.isPlaying")
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
var playerState: PlayerState
|
var legacyPlayerState: PlayerState = PlayerState.IDLE
|
||||||
get() = localMediaPlayer.playerState
|
|
||||||
set(state) {
|
val playbackState: Int
|
||||||
val mediaPlayerService = runningInstance
|
get() = controller?.playbackState ?: 0
|
||||||
if (mediaPlayerService != null)
|
|
||||||
localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying)
|
val isPlaying: Boolean
|
||||||
}
|
get() = controller?.isPlaying ?: false
|
||||||
|
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
var isJukeboxEnabled: Boolean
|
var isJukeboxEnabled: Boolean
|
||||||
get() = jukeboxMediaPlayer.isEnabled
|
get() = jukeboxMediaPlayer.isEnabled
|
||||||
set(jukeboxEnabled) {
|
set(jukeboxEnabled) {
|
||||||
jukeboxMediaPlayer.isEnabled = jukeboxEnabled
|
jukeboxMediaPlayer.isEnabled = jukeboxEnabled
|
||||||
playerState = PlayerState.IDLE
|
|
||||||
if (jukeboxEnabled) {
|
if (jukeboxEnabled) {
|
||||||
jukeboxMediaPlayer.startJukeboxService()
|
jukeboxMediaPlayer.startJukeboxService()
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
// Cancel current download, if necessary.
|
// Cancel current downloads
|
||||||
downloader.clearActiveDownloads()
|
downloader.clearActiveDownloads()
|
||||||
} else {
|
} else {
|
||||||
jukeboxMediaPlayer.stopJukeboxService()
|
jukeboxMediaPlayer.stopJukeboxService()
|
||||||
|
@ -441,19 +569,12 @@ class MediaPlayerController(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVolume(volume: Float) {
|
fun setVolume(volume: Float) {
|
||||||
if (runningInstance != null) localMediaPlayer.setVolume(volume)
|
controller?.volume = volume
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateNotification() {
|
|
||||||
runningInstance?.updateNotification(
|
|
||||||
localMediaPlayer.playerState,
|
|
||||||
localMediaPlayer.currentPlaying
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSongStarred() {
|
fun toggleSongStarred() {
|
||||||
if (localMediaPlayer.currentPlaying == null) return
|
if (legacyPlaylistManager.currentPlaying == null) return
|
||||||
val song = localMediaPlayer.currentPlaying!!.track
|
val song = legacyPlaylistManager.currentPlaying!!.track
|
||||||
|
|
||||||
Thread {
|
Thread {
|
||||||
val musicService = getMusicService()
|
val musicService = getMusicService()
|
||||||
|
@ -469,15 +590,16 @@ class MediaPlayerController(
|
||||||
}.start()
|
}.start()
|
||||||
|
|
||||||
// Trigger an update
|
// Trigger an update
|
||||||
localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
// TODO Update Metadata of MediaItem...
|
||||||
|
//localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
||||||
song.starred = !song.starred
|
song.starred = !song.starred
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
||||||
fun setSongRating(rating: Int) {
|
fun setSongRating(rating: Int) {
|
||||||
if (!Settings.useFiveStarRating) return
|
if (!Settings.useFiveStarRating) return
|
||||||
if (localMediaPlayer.currentPlaying == null) return
|
if (legacyPlaylistManager.currentPlaying == null) return
|
||||||
val song = localMediaPlayer.currentPlaying!!.track
|
val song = legacyPlaylistManager.currentPlaying!!.track
|
||||||
song.userRating = rating
|
song.userRating = rating
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
|
@ -487,27 +609,33 @@ class MediaPlayerController(
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
// TODO this would be better handled with a Rx command
|
// TODO this would be better handled with a Rx command
|
||||||
updateNotification()
|
//updateNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
@set:Synchronized
|
val currentMediaItem: MediaItem?
|
||||||
var currentPlaying: DownloadFile?
|
get() = controller?.currentMediaItem
|
||||||
get() = localMediaPlayer.currentPlaying
|
|
||||||
set(currentPlaying) {
|
|
||||||
if (runningInstance != null) localMediaPlayer.setCurrentPlaying(currentPlaying)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
val playlistSize: Int
|
||||||
get() = downloader.getPlaylist().size
|
get() = legacyPlaylistManager.playlist.size
|
||||||
|
|
||||||
val currentPlayingNumberOnPlaylist: Int
|
|
||||||
get() = downloader.currentPlayingIndex
|
|
||||||
|
|
||||||
|
@Deprecated("Use native APIs")
|
||||||
val playList: List<DownloadFile>
|
val playList: List<DownloadFile>
|
||||||
get() = downloader.getPlaylist()
|
get() = legacyPlaylistManager.playlist
|
||||||
|
|
||||||
|
@Deprecated("Use timeline")
|
||||||
val playListDuration: Long
|
val playListDuration: Long
|
||||||
get() = downloader.downloadListDuration
|
get() = legacyPlaylistManager.playlistDuration
|
||||||
|
|
||||||
fun getDownloadFileForSong(song: Track): DownloadFile {
|
fun getDownloadFileForSong(song: Track): DownloadFile {
|
||||||
return downloader.getDownloadFileForSong(song)
|
return downloader.getDownloadFileForSong(song)
|
||||||
|
@ -516,4 +644,30 @@ class MediaPlayerController(
|
||||||
init {
|
init {
|
||||||
Timber.i("MediaPlayerController constructed")
|
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
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.R
|
|
||||||
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
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.CacheCleaner
|
||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
import org.moire.ultrasonic.util.Util.ifNotNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is responsible for handling received events for the Media Player implementation
|
* This class is responsible for handling received events for the Media Player implementation
|
||||||
*
|
|
||||||
* @author Sindre Mehus
|
|
||||||
*/
|
*/
|
||||||
class MediaPlayerLifecycleSupport : KoinComponent {
|
class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||||
private val downloader by inject<Downloader>()
|
|
||||||
|
|
||||||
private var created = false
|
private var created = false
|
||||||
private var headsetEventReceiver: BroadcastReceiver? = null
|
private var headsetEventReceiver: BroadcastReceiver? = null
|
||||||
|
@ -50,11 +41,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe {
|
|
||||||
handleKeyEvent(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
registerHeadsetReceiver()
|
|
||||||
mediaPlayerController.onCreate()
|
mediaPlayerController.onCreate()
|
||||||
if (autoPlay) mediaPlayerController.preload()
|
if (autoPlay) mediaPlayerController.preload()
|
||||||
|
|
||||||
|
@ -68,13 +54,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
false
|
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()
|
afterCreated?.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,11 +66,12 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
|
|
||||||
if (!created) return
|
if (!created) return
|
||||||
|
|
||||||
playbackStateSerializer.serializeNow(
|
// TODO
|
||||||
downloader.getPlaylist(),
|
// playbackStateSerializer.serializeNow(
|
||||||
downloader.currentPlayingIndex,
|
// downloader.getPlaylist(),
|
||||||
mediaPlayerController.playerPosition
|
// downloader.currentPlayingIndex,
|
||||||
)
|
// mediaPlayerController.playerPosition
|
||||||
|
// )
|
||||||
|
|
||||||
mediaPlayerController.clear(false)
|
mediaPlayerController.clear(false)
|
||||||
mediaButtonEventSubscription?.dispose()
|
mediaButtonEventSubscription?.dispose()
|
||||||
|
@ -121,73 +101,19 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Suppress("MagicNumber")
|
||||||
* 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")
|
|
||||||
private fun handleKeyEvent(event: KeyEvent) {
|
private fun handleKeyEvent(event: KeyEvent) {
|
||||||
|
|
||||||
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return
|
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return
|
||||||
|
|
||||||
val keyCode: Int
|
val keyCode: Int = event.keyCode
|
||||||
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 autoStart =
|
val autoStart =
|
||||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
|
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
|
||||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
||||||
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
|
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
|
||||||
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
|
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
|
||||||
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
|
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
|
||||||
|
|
||||||
// We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start
|
// We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start
|
||||||
onCreate(autoStart) {
|
onCreate(autoStart) {
|
||||||
|
@ -197,14 +123,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
|
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY ->
|
|
||||||
if (mediaPlayerController.playerState === PlayerState.IDLE) {
|
|
||||||
mediaPlayerController.play()
|
|
||||||
} else if (mediaPlayerController.playerState !== PlayerState.STARTED) {
|
|
||||||
mediaPlayerController.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
||||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
|
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
|
||||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
|
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
|
||||||
|
@ -221,21 +140,15 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
/**
|
/**
|
||||||
* This function processes the intent that could come from other applications.
|
* This function processes the intent that could come from other applications.
|
||||||
*/
|
*/
|
||||||
@Suppress("ComplexMethod")
|
|
||||||
private fun handleUltrasonicIntent(intentAction: String) {
|
private fun handleUltrasonicIntent(intentAction: String) {
|
||||||
|
|
||||||
val isRunning = created
|
val isRunning = created
|
||||||
|
|
||||||
// If Ultrasonic is not running, do nothing to stop or pause
|
// If Ultrasonic is not running, do nothing to stop or pause
|
||||||
if (
|
if (!isRunning && (intentAction == Constants.CMD_PAUSE || intentAction == Constants.CMD_STOP))
|
||||||
!isRunning && (
|
return
|
||||||
intentAction == Constants.CMD_PAUSE ||
|
|
||||||
intentAction == Constants.CMD_STOP
|
|
||||||
)
|
|
||||||
) return
|
|
||||||
|
|
||||||
val autoStart =
|
val autoStart = intentAction == Constants.CMD_PLAY ||
|
||||||
intentAction == Constants.CMD_PLAY ||
|
|
||||||
intentAction == Constants.CMD_RESUME_OR_PLAY ||
|
intentAction == Constants.CMD_RESUME_OR_PLAY ||
|
||||||
intentAction == Constants.CMD_TOGGLEPAUSE ||
|
intentAction == Constants.CMD_TOGGLEPAUSE ||
|
||||||
intentAction == Constants.CMD_PREVIOUS ||
|
intentAction == Constants.CMD_PREVIOUS ||
|
||||||
|
@ -253,12 +166,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
Constants.CMD_NEXT -> mediaPlayerController.next()
|
Constants.CMD_NEXT -> mediaPlayerController.next()
|
||||||
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
|
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
|
||||||
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
||||||
|
Constants.CMD_STOP -> mediaPlayerController.stop()
|
||||||
Constants.CMD_STOP -> {
|
|
||||||
// TODO: There is a stop() function, shouldn't we use that?
|
|
||||||
mediaPlayerController.pause()
|
|
||||||
mediaPlayerController.seekTo(0)
|
|
||||||
}
|
|
||||||
Constants.CMD_PAUSE -> mediaPlayerController.pause()
|
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>,
|
songs: Iterable<DownloadFile>,
|
||||||
currentPlayingIndex: Int,
|
currentPlayingIndex: Int,
|
||||||
currentPlayingPosition: Int
|
currentPlayingPosition: Int
|
||||||
|
|
|
@ -20,11 +20,6 @@ class RxBus {
|
||||||
.replay(1)
|
.replay(1)
|
||||||
.autoConnect(0)
|
.autoConnect(0)
|
||||||
|
|
||||||
val mediaButtonEventPublisher: PublishSubject<KeyEvent> =
|
|
||||||
PublishSubject.create()
|
|
||||||
val mediaButtonEventObservable: Observable<KeyEvent> =
|
|
||||||
mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
|
|
||||||
val themeChangedEventPublisher: PublishSubject<Unit> =
|
val themeChangedEventPublisher: PublishSubject<Unit> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val themeChangedEventObservable: Observable<Unit> =
|
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) {
|
operator fun CompositeDisposable.plusAssign(disposable: Disposable) {
|
||||||
|
|
|
@ -34,20 +34,23 @@ class DownloadHandler(
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
playNext: Boolean,
|
playNext: Boolean,
|
||||||
shuffle: Boolean,
|
shuffle: Boolean,
|
||||||
songs: List<Track?>
|
songs: List<Track>,
|
||||||
) {
|
) {
|
||||||
val onValid = Runnable {
|
val onValid = Runnable {
|
||||||
if (!append && !playNext) {
|
// TODO: The logic here is different than in the controller...
|
||||||
mediaPlayerController.clear()
|
val insertionMode = when {
|
||||||
|
append -> MediaPlayerController.InsertionMode.APPEND
|
||||||
|
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||||
|
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||||
}
|
}
|
||||||
|
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerController.addToPlaylist(
|
||||||
songs,
|
songs,
|
||||||
save,
|
save,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
playNext,
|
|
||||||
shuffle,
|
shuffle,
|
||||||
false
|
insertionMode
|
||||||
)
|
)
|
||||||
val playlistName: String? = fragment.arguments?.getString(
|
val playlistName: String? = fragment.arguments?.getString(
|
||||||
Constants.INTENT_PLAYLIST_NAME
|
Constants.INTENT_PLAYLIST_NAME
|
||||||
|
@ -281,26 +284,28 @@ class DownloadHandler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called when we have collected the tracks
|
||||||
override fun done(songs: List<Track>) {
|
override fun done(songs: List<Track>) {
|
||||||
if (Settings.shouldSortByDisc) {
|
if (Settings.shouldSortByDisc) {
|
||||||
Collections.sort(songs, EntryByDiscAndTrackComparator())
|
Collections.sort(songs, EntryByDiscAndTrackComparator())
|
||||||
}
|
}
|
||||||
if (songs.isNotEmpty()) {
|
if (songs.isNotEmpty()) {
|
||||||
if (!append && !playNext && !unpin && !background) {
|
|
||||||
mediaPlayerController.clear()
|
|
||||||
}
|
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
if (!background) {
|
if (!background) {
|
||||||
if (unpin) {
|
if (unpin) {
|
||||||
mediaPlayerController.unpin(songs)
|
mediaPlayerController.unpin(songs)
|
||||||
} else {
|
} else {
|
||||||
|
val insertionMode = when {
|
||||||
|
append -> MediaPlayerController.InsertionMode.APPEND
|
||||||
|
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||||
|
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||||
|
}
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerController.addToPlaylist(
|
||||||
songs,
|
songs,
|
||||||
save,
|
save,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
playNext,
|
|
||||||
shuffle,
|
shuffle,
|
||||||
false
|
insertionMode
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
!append &&
|
!append &&
|
||||||
|
|
|
@ -233,7 +233,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||||
) {
|
) {
|
||||||
if (file.isFile && (isPartial(file) || isComplete(file))) {
|
if (file.isFile && (isPartial(file) || isComplete(file))) {
|
||||||
files.add(file)
|
files.add(file)
|
||||||
} else {
|
} else if (file.isDirectory) {
|
||||||
// Depth-first
|
// Depth-first
|
||||||
for (child in listFiles(file)) {
|
for (child in listFiles(file)) {
|
||||||
findCandidatesForDeletion(child, files, dirs)
|
findCandidatesForDeletion(child, files, dirs)
|
||||||
|
@ -257,7 +257,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||||
for (downloadFile in downloader.value.all) {
|
for (downloadFile in downloader.value.all) {
|
||||||
filesToNotDelete.add(downloadFile.partialFile)
|
filesToNotDelete.add(downloadFile.partialFile)
|
||||||
filesToNotDelete.add(downloadFile.completeFile)
|
filesToNotDelete.add(downloadFile.completeFile)
|
||||||
filesToNotDelete.add(downloadFile.saveFile)
|
filesToNotDelete.add(downloadFile.pinnedFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
filesToNotDelete.add(musicDirectory.path)
|
filesToNotDelete.add(musicDirectory.path)
|
||||||
|
|
|
@ -406,7 +406,7 @@ object FileUtil {
|
||||||
return path.substringBeforeLast('/')
|
return path.substringBeforeLast('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSaveFile(name: String): String {
|
fun getPinnedFile(name: String): String {
|
||||||
val baseName = getBaseName(name)
|
val baseName = getBaseName(name)
|
||||||
if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) {
|
if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) {
|
||||||
return "${getBaseName(baseName)}.${getExtension(name)}"
|
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.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Build
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.RepeatMode
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contains convenience functions for reading and writing preferences
|
* Contains convenience functions for reading and writing preferences
|
||||||
|
@ -23,43 +21,6 @@ import org.moire.ultrasonic.domain.RepeatMode
|
||||||
object Settings {
|
object Settings {
|
||||||
private val PATTERN = Pattern.compile(":")
|
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
|
@JvmStatic
|
||||||
var theme by StringSetting(
|
var theme by StringSetting(
|
||||||
Constants.PREFERENCES_KEY_THEME,
|
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…
Reference in New Issue