Switch to Media3

This commit is contained in:
tzugen 2022-04-03 23:57:50 +02:00
parent bfc11f9924
commit 922022ab03
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
50 changed files with 2871 additions and 4075 deletions

View File

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

View File

@ -11,6 +11,7 @@ detekt = "1.19.0"
jacoco = "0.8.7"
preferences = "1.1.1"
media = "1.3.1"
media3 = "1.0.0-alpha03"
androidSupport = "28.0.0"
androidLegacySupport = "1.0.0"
@ -66,6 +67,9 @@ navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", ve
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
media = { module = "androidx.media:media", version.ref = "media" }
media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
media3okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
media3session = { module = "androidx.media3:media3-session", version.ref = "media3" }
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }

View File

@ -100,6 +100,9 @@ dependencies {
implementation libs.constraintLayout
implementation libs.preferences
implementation libs.media
implementation libs.media3exoplayer
implementation libs.media3session
implementation libs.media3okhttp
implementation libs.navigationFragment
implementation libs.navigationUi

View File

@ -56,18 +56,17 @@
</activity>
<service
android:name=".service.MediaPlayerService"
android:name=".service.DownloadService"
android:label="Ultrasonic Media Player Service"
android:exported="false">
</service>
<service
tools:ignore="ExportedService"
android:name=".service.AutoMediaBrowserService"
<service android:name=".playback.PlaybackService"
android:label="@string/common.appname"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
@ -146,13 +145,6 @@
android:name=".provider.SearchSuggestionProvider"
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/>
<receiver
android:name=".receiver.A2dpIntentReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.android.music.playstatusrequest"/>
</intent-filter>
</receiver>
</application>
</manifest>

View File

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

View File

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

View File

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

View File

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

View File

@ -130,7 +130,7 @@ public class VisualizerView extends View
return;
}
if (mediaPlayerControllerLazy.getValue().getPlayerState() != PlayerState.STARTED)
if (mediaPlayerControllerLazy.getValue().getLegacyPlayerState() != PlayerState.STARTED)
{
return;
}

View File

@ -27,6 +27,8 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.FragmentContainerView
import androidx.media3.common.Player.STATE_BUFFERING
import androidx.media3.common.Player.STATE_READY
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
@ -414,9 +416,9 @@ class NavigationActivity : AppCompatActivity() {
}
if (nowPlayingView != null) {
val playerState: PlayerState = mediaPlayerController.playerState
if (playerState == PlayerState.PAUSED || playerState == PlayerState.STARTED) {
val file: DownloadFile? = mediaPlayerController.currentPlaying
val playerState: Int = mediaPlayerController.playbackState
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
val file: DownloadFile? = mediaPlayerController.currentPlayingLegacy
if (file != null) {
nowPlayingView?.visibility = View.VISIBLE
}

View File

@ -17,7 +17,7 @@ import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.Downloader
class TrackViewBinder(
val onItemClick: (DownloadFile) -> Unit,
val onItemClick: (DownloadFile, Int) -> Unit,
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
val checkable: Boolean,
val draggable: Boolean,
@ -29,7 +29,7 @@ class TrackViewBinder(
// Set our layout files
val layout = R.layout.list_item_track
val contextMenuLayout = R.menu.context_menu_track
private val contextMenuLayout = R.menu.context_menu_track
private val downloader: Downloader by inject()
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
@ -41,15 +41,14 @@ class TrackViewBinder(
@SuppressLint("ClickableViewAccessibility")
@Suppress("LongMethod")
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
val downloadFile: DownloadFile?
val diffAdapter = adapter as BaseAdapter<*>
when (item) {
val downloadFile: DownloadFile = when (item) {
is Track -> {
downloadFile = downloader.getDownloadFileForSong(item)
downloader.getDownloadFileForSong(item)
}
is DownloadFile -> {
downloadFile = item
item
}
else -> {
return
@ -90,7 +89,7 @@ class TrackViewBinder(
val nowChecked = !holder.check.isChecked
holder.isChecked = nowChecked
} else {
onItemClick(downloadFile)
onItemClick(downloadFile, holder.bindingAdapterPosition)
}
}
@ -103,41 +102,37 @@ class TrackViewBinder(
// Notify the adapter of selection changes
holder.observableChecked.observe(
lifecycleOwner,
{ isCheckedNow ->
if (isCheckedNow) {
diffAdapter.notifySelected(holder.entry!!.longId)
} else {
diffAdapter.notifyUnselected(holder.entry!!.longId)
}
lifecycleOwner
) { isCheckedNow ->
if (isCheckedNow) {
diffAdapter.notifySelected(holder.entry!!.longId)
} else {
diffAdapter.notifyUnselected(holder.entry!!.longId)
}
)
}
// Listen to changes in selection status and update ourselves
diffAdapter.selectionRevision.observe(
lifecycleOwner,
{
val newStatus = diffAdapter.isSelected(item.longId)
lifecycleOwner
) {
val newStatus = diffAdapter.isSelected(item.longId)
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
}
)
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
}
// Observe download status
downloadFile.status.observe(
lifecycleOwner,
{
holder.updateStatus(it)
diffAdapter.notifyChanged()
}
)
lifecycleOwner
) {
holder.updateStatus(it)
diffAdapter.notifyChanged()
}
downloadFile.progress.observe(
lifecycleOwner,
{
holder.updateProgress(it)
}
)
lifecycleOwner
) {
holder.updateProgress(it)
}
}
override fun onViewRecycled(holder: TrackViewHolder) {

View File

@ -109,7 +109,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
}
rxSubscription = RxBus.playerStateObservable.subscribe {
setPlayIcon(it.track == downloadFile)
setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile)
}
}

View File

@ -4,7 +4,6 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.MediaSessionHandler
/**
* This Koin module contains the registration of general classes needed for Ultrasonic
@ -12,5 +11,4 @@ import org.moire.ultrasonic.util.MediaSessionHandler
val applicationModule = module {
single { ActiveServerProvider(get()) }
single { ImageLoaderProvider(androidContext()) }
single { MediaSessionHandler() }
}

View File

@ -1,15 +1,13 @@
package org.moire.ultrasonic.di
import org.koin.dsl.module
import org.moire.ultrasonic.service.AudioFocusHandler
import org.moire.ultrasonic.playback.LegacyPlaylistManager
import org.moire.ultrasonic.service.Downloader
import org.moire.ultrasonic.service.ExternalStorageMonitor
import org.moire.ultrasonic.service.JukeboxMediaPlayer
import org.moire.ultrasonic.service.LocalMediaPlayer
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.PlaybackStateSerializer
import org.moire.ultrasonic.util.ShufflePlayBuffer
/**
* This Koin module contains the registration of classes related to the media player
@ -19,10 +17,8 @@ val mediaPlayerModule = module {
single { MediaPlayerLifecycleSupport() }
single { PlaybackStateSerializer() }
single { ExternalStorageMonitor() }
single { ShufflePlayBuffer() }
single { Downloader(get(), get(), get()) }
single { LocalMediaPlayer() }
single { AudioFocusHandler(get()) }
single { LegacyPlaylistManager() }
single { Downloader(get(), get()) }
// TODO Ideally this can be cleaned up when all circular references are removed.
single { MediaPlayerController(get(), get(), get(), get(), get()) }

View File

@ -54,7 +54,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
viewAdapter.register(
TrackViewBinder(
{ },
{ _, _ -> },
{ _, _ -> true },
checkable = false,
draggable = false,

View File

@ -47,7 +47,7 @@ class NowPlayingFragment : Fragment() {
private var nowPlayingTrack: TextView? = null
private var nowPlayingArtist: TextView? = null
private var playerStateSubscription: Disposable? = null
private var rxBusSubscription: Disposable? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val imageLoader: ImageLoaderProvider by inject()
@ -69,8 +69,7 @@ class NowPlayingFragment : Fragment() {
nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image)
nowPlayingTrack = view.findViewById(R.id.now_playing_trackname)
nowPlayingArtist = view.findViewById(R.id.now_playing_artist)
playerStateSubscription =
RxBus.playerStateObservable.subscribe { update() }
rxBusSubscription = RxBus.playerStateObservable.subscribe { update() }
}
override fun onResume() {
@ -80,13 +79,13 @@ class NowPlayingFragment : Fragment() {
override fun onDestroy() {
super.onDestroy()
playerStateSubscription!!.dispose()
rxBusSubscription!!.dispose()
}
@SuppressLint("ClickableViewAccessibility")
private fun update() {
try {
val playerState = mediaPlayerController.playerState
val playerState = mediaPlayerController.legacyPlayerState
if (playerState === PlayerState.PAUSED) {
playButton!!.setImageDrawable(
@ -102,7 +101,7 @@ class NowPlayingFragment : Fragment() {
)
}
val file = mediaPlayerController.currentPlaying
val file = mediaPlayerController.currentPlayingLegacy
if (file != null) {
val song = file.track

View File

@ -13,6 +13,7 @@ import android.graphics.Point
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.ContextMenu
import android.view.ContextMenu.ContextMenuInfo
import android.view.GestureDetector
@ -35,24 +36,15 @@ import android.widget.TextView
import android.widget.ViewFlipper
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.navigation.Navigation
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.disposables.Disposable
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.ArrayList
import java.util.Date
import java.util.Locale
import java.util.concurrent.CancellationException
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlin.math.max
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@ -66,15 +58,13 @@ import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.audiofx.VisualizerController
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.domain.RepeatMode
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.LocalMediaPlayer
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
@ -86,9 +76,20 @@ import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.view.AutoRepeatButton
import org.moire.ultrasonic.view.VisualizerView
import timber.log.Timber
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.abs
import kotlin.math.max
/**
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
* TODO: Add timeline lister -> updateProgressBar().
*/
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
class PlayerFragment :
@ -113,14 +114,13 @@ class PlayerFragment :
// Data & Services
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private val mediaPlayerController: MediaPlayerController by inject()
private val localMediaPlayer: LocalMediaPlayer by inject()
private val shareHandler: ShareHandler by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private lateinit var executorService: ScheduledExecutorService
private var currentPlaying: DownloadFile? = null
private var currentSong: Track? = null
private lateinit var viewManager: LinearLayoutManager
private var rxBusSubscription: Disposable? = null
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
private lateinit var executorService: ScheduledExecutorService
private var ioScope = CoroutineScope(Dispatchers.IO)
// Views and UI Elements
@ -148,7 +148,7 @@ class PlayerFragment :
private lateinit var durationTextView: TextView
private lateinit var pauseButton: View
private lateinit var stopButton: View
private lateinit var startButton: View
private lateinit var playButton: View
private lateinit var repeatButton: ImageView
private lateinit var hollowStar: Drawable
private lateinit var fullStar: Drawable
@ -189,7 +189,7 @@ class PlayerFragment :
pauseButton = view.findViewById(R.id.button_pause)
stopButton = view.findViewById(R.id.button_stop)
startButton = view.findViewById(R.id.button_start)
playButton = view.findViewById(R.id.button_start)
repeatButton = view.findViewById(R.id.button_repeat)
visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout)
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
@ -216,13 +216,6 @@ class PlayerFragment :
swipeVelocity = swipeDistance
gestureScanner = GestureDetector(context, this)
// The secondary progress is an indicator of how far the song is cached.
localMediaPlayer.secondaryProgress.observe(
viewLifecycleOwner,
{
progressBar.secondaryProgress = it
}
)
findViews(view)
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
@ -291,34 +284,40 @@ class PlayerFragment :
}
}
startButton.setOnClickListener {
playButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
start()
mediaPlayerController.play()
onCurrentChanged()
onSliderProgressChanged()
}
}
shuffleButton.setOnClickListener {
mediaPlayerController.shuffle()
mediaPlayerController.toggleShuffle()
Util.toast(activity, R.string.download_menu_shuffle_notification)
}
repeatButton.setOnClickListener {
val repeatMode = mediaPlayerController.repeatMode.next()
mediaPlayerController.repeatMode = repeatMode
var newRepeat = mediaPlayerController.repeatMode + 1
if (newRepeat == 3) {
newRepeat = 0
}
mediaPlayerController.repeatMode = newRepeat
onPlaylistChanged()
when (repeatMode) {
RepeatMode.OFF -> Util.toast(
when (newRepeat) {
0 -> Util.toast(
context, R.string.download_repeat_off
)
RepeatMode.ALL -> Util.toast(
context, R.string.download_repeat_all
)
RepeatMode.SINGLE -> Util.toast(
1 -> Util.toast(
context, R.string.download_repeat_single
)
2 -> Util.toast(
context, R.string.download_repeat_all
)
else -> {
}
}
@ -351,53 +350,62 @@ class PlayerFragment :
visualizerViewLayout.isVisible = false
VisualizerController.get().observe(
requireActivity(),
{ visualizerController ->
if (visualizerController != null) {
Timber.d("VisualizerController Observer.onChanged received controller")
visualizerView = VisualizerView(context)
visualizerViewLayout.addView(
visualizerView,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
requireActivity()
) { visualizerController ->
if (visualizerController != null) {
Timber.d("VisualizerController Observer.onChanged received controller")
visualizerView = VisualizerView(context)
visualizerViewLayout.addView(
visualizerView,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
)
visualizerViewLayout.isVisible = visualizerView.isActive
visualizerViewLayout.isVisible = visualizerView.isActive
visualizerView.setOnTouchListener { _, _ ->
visualizerView.isActive = !visualizerView.isActive
mediaPlayerController.showVisualization = visualizerView.isActive
true
}
isVisualizerAvailable = true
} else {
Timber.d("VisualizerController Observer.onChanged has no controller")
visualizerViewLayout.isVisible = false
isVisualizerAvailable = false
visualizerView.setOnTouchListener { _, _ ->
visualizerView.isActive = !visualizerView.isActive
mediaPlayerController.showVisualization = visualizerView.isActive
true
}
isVisualizerAvailable = true
} else {
Timber.d("VisualizerController Observer.onChanged has no controller")
visualizerViewLayout.isVisible = false
isVisualizerAvailable = false
}
)
}
EqualizerController.get().observe(
requireActivity(),
{ equalizerController ->
isEqualizerAvailable = if (equalizerController != null) {
Timber.d("EqualizerController Observer.onChanged received controller")
true
} else {
Timber.d("EqualizerController Observer.onChanged has no controller")
false
}
requireActivity()
) { equalizerController ->
isEqualizerAvailable = if (equalizerController != null) {
Timber.d("EqualizerController Observer.onChanged received controller")
true
} else {
Timber.d("EqualizerController Observer.onChanged has no controller")
false
}
)
}
// Observe playlist changes and update the UI
rxBusSubscription = RxBus.playlistObservable.subscribe {
// FIXME
rxBusSubscription += RxBus.playlistObservable.subscribe {
onPlaylistChanged()
}
rxBusSubscription += RxBus.playerStateObservable.subscribe {
update()
}
mediaPlayerController.controller?.addListener(object : Player.Listener {
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
onSliderProgressChanged()
}
})
// Query the Jukebox state in an IO Context
ioScope.launch(CommunicationError.getHandler(context)) {
try {
@ -412,16 +420,15 @@ class PlayerFragment :
override fun onResume() {
super.onResume()
if (mediaPlayerController.currentPlaying == null) {
if (mediaPlayerController.currentPlayingLegacy == null) {
playlistFlipper.displayedChild = 1
} else {
// Download list and Album art must be updated when Resumed
// Download list and Album art must be updated when resumed
onPlaylistChanged()
onCurrentChanged()
}
val handler = Handler()
// TODO Use Rx for Update instead of polling!
val handler = Handler(Looper.getMainLooper())
val runnable = Runnable { handler.post { update(cancellationToken) } }
executorService = Executors.newSingleThreadScheduledExecutor()
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
@ -441,7 +448,7 @@ class PlayerFragment :
// Scroll to current playing.
private fun scrollToCurrent() {
val index = mediaPlayerController.playList.indexOf(currentPlaying)
val index = mediaPlayerController.currentMediaItemIndex
if (index != -1) {
val smoothScroller = LinearSmoothScroller(context)
@ -459,7 +466,7 @@ class PlayerFragment :
}
override fun onDestroyView() {
rxBusSubscription?.dispose()
rxBusSubscription.dispose()
cancel("CoroutineScope cancelled because the view was destroyed")
cancellationToken.cancel()
super.onDestroyView()
@ -504,7 +511,7 @@ class PlayerFragment :
visualizerMenuItem.isVisible = isVisualizerAvailable
}
val mediaPlayerController = mediaPlayerController
val downloadFile = mediaPlayerController.currentPlaying
val downloadFile = mediaPlayerController.currentPlayingLegacy
if (downloadFile != null) {
currentSong = downloadFile.track
@ -631,7 +638,7 @@ class PlayerFragment :
return true
}
R.id.menu_shuffle -> {
mediaPlayerController.shuffle()
mediaPlayerController.toggleShuffle()
Util.toast(context, R.string.download_menu_shuffle_notification)
return true
}
@ -768,10 +775,10 @@ class PlayerFragment :
}
}
private fun update(cancel: CancellationToken?) {
if (cancel!!.isCancellationRequested) return
private fun update(cancel: CancellationToken? = null) {
if (cancel?.isCancellationRequested == true) return
val mediaPlayerController = mediaPlayerController
if (currentPlaying != mediaPlayerController.currentPlaying) {
if (currentPlaying != mediaPlayerController.currentPlayingLegacy) {
onCurrentChanged()
}
onSliderProgressChanged()
@ -822,23 +829,6 @@ class PlayerFragment :
scrollToCurrent()
}
private fun start() {
val service = mediaPlayerController
val state = service.playerState
if (state === PlayerState.PAUSED ||
state === PlayerState.COMPLETED || state === PlayerState.STOPPED
) {
service.start()
} else if (state === PlayerState.IDLE) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
val current = mediaPlayerController.currentPlayingNumberOnPlaylist
if (current == -1) {
service.play(0)
} else {
service.play(current)
}
}
}
private fun initPlaylistDisplay() {
// Create a View Manager
@ -852,17 +842,17 @@ class PlayerFragment :
}
// Create listener
val listener: ((DownloadFile) -> Unit) = { file ->
val list = mediaPlayerController.playList
val index = list.indexOf(file)
mediaPlayerController.play(index)
val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos ->
mediaPlayerController.seekTo(pos, 0)
mediaPlayerController.prepare()
mediaPlayerController.play()
onCurrentChanged()
onSliderProgressChanged()
}
viewAdapter.register(
TrackViewBinder(
onItemClick = listener,
onItemClick = clickHandler,
checkable = false,
draggable = true,
context = requireContext(),
@ -879,62 +869,63 @@ class PlayerFragment :
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
// Move it in the data set
mediaPlayerController.moveItemInPlaylist(from, to)
viewAdapter.submitList(mediaPlayerController.playList)
// Move it in the data set
mediaPlayerController.moveItemInPlaylist(from, to)
viewAdapter.submitList(mediaPlayerController.playList)
return true
}
return true
}
// Swipe to delete from playlist
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val pos = viewHolder.bindingAdapterPosition
val file = mediaPlayerController.playList[pos]
mediaPlayerController.removeFromPlaylist(file)
// Swipe to delete from playlist
@SuppressLint("NotifyDataSetChanged")
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val pos = viewHolder.bindingAdapterPosition
val file = mediaPlayerController.playList[pos]
mediaPlayerController.removeFromPlaylist(file)
val songRemoved = String.format(
resources.getString(R.string.download_song_removed),
file.track.title
)
Util.toast(context, songRemoved)
val songRemoved = String.format(
resources.getString(R.string.download_song_removed),
file.track.title
)
Util.toast(context, songRemoved)
viewAdapter.submitList(mediaPlayerController.playList)
viewAdapter.notifyDataSetChanged()
}
viewAdapter.submitList(mediaPlayerController.playList)
viewAdapter.notifyDataSetChanged()
}
override fun onSelectedChanged(
viewHolder: RecyclerView.ViewHolder?,
actionState: Int
) {
super.onSelectedChanged(viewHolder, actionState)
override fun onSelectedChanged(
viewHolder: RecyclerView.ViewHolder?,
actionState: Int
) {
super.onSelectedChanged(viewHolder, actionState)
if (actionState == ACTION_STATE_DRAG) {
viewHolder?.itemView?.alpha = 0.6f
}
}
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.alpha = 1.0f
}
override fun isLongPressDragEnabled(): Boolean {
return false
if (actionState == ACTION_STATE_DRAG) {
viewHolder?.itemView?.alpha = 0.6f
}
}
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.alpha = 1.0f
}
override fun isLongPressDragEnabled(): Boolean {
return false
}
}
)
dragTouchHelper.attachToRecyclerView(playlistView)
@ -950,32 +941,33 @@ class PlayerFragment :
emptyTextView.isVisible = list.isEmpty()
when (mediaPlayerController.repeatMode) {
RepeatMode.OFF -> repeatButton.setImageDrawable(
0 -> repeatButton.setImageDrawable(
Util.getDrawableFromAttribute(
requireContext(), R.attr.media_repeat_off
)
)
RepeatMode.ALL -> repeatButton.setImageDrawable(
Util.getDrawableFromAttribute(
requireContext(), R.attr.media_repeat_all
)
)
RepeatMode.SINGLE -> repeatButton.setImageDrawable(
1 -> repeatButton.setImageDrawable(
Util.getDrawableFromAttribute(
requireContext(), R.attr.media_repeat_single
)
)
2 -> repeatButton.setImageDrawable(
Util.getDrawableFromAttribute(
requireContext(), R.attr.media_repeat_all
)
)
else -> {
}
}
}
private fun onCurrentChanged() {
currentPlaying = mediaPlayerController.currentPlaying
currentPlaying = mediaPlayerController.currentPlayingLegacy
scrollToCurrent()
val totalDuration = mediaPlayerController.playListDuration
val totalSongs = mediaPlayerController.playlistSize.toLong()
val currentSongIndex = mediaPlayerController.currentPlayingNumberOnPlaylist + 1
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
val duration = Util.formatTotalDuration(totalDuration)
val trackFormat =
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
@ -992,7 +984,7 @@ class PlayerFragment :
genreTextView.isVisible =
(currentSong!!.genre != null && currentSong!!.genre!!.isNotBlank())
var bitRate: String = ""
var bitRate = ""
if (currentSong!!.bitRate != null && currentSong!!.bitRate!! > 0)
bitRate = String.format(
Util.appContext().getString(R.string.song_details_kbps),
@ -1027,14 +1019,14 @@ class PlayerFragment :
}
}
@Suppress("LongMethod", "ComplexMethod")
@Synchronized
private fun onSliderProgressChanged() {
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
val duration: Int = mediaPlayerController.playerDuration
val playerState: PlayerState = mediaPlayerController.playerState
val playbackState: Int = mediaPlayerController.playbackState
val isPlaying = mediaPlayerController.isPlaying
if (cancellationToken.isCancellationRequested) return
if (currentPlaying != null) {
@ -1043,7 +1035,7 @@ class PlayerFragment :
progressBar.max =
if (duration == 0) 100 else duration // Work-around for apparent bug.
progressBar.progress = millisPlayed
progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
} else {
positionTextView.setText(R.string.util_zero_time)
durationTextView.setText(R.string.util_no_time)
@ -1052,21 +1044,20 @@ class PlayerFragment :
progressBar.isEnabled = false
}
when (playerState) {
PlayerState.DOWNLOADING -> {
val progress =
if (currentPlaying != null) currentPlaying!!.progress.value!! else 0
val progress = mediaPlayerController.bufferedPercentage
when (playbackState) {
Player.STATE_BUFFERING -> {
val downloadStatus = resources.getString(
R.string.download_playerstate_downloading,
Util.formatPercentage(progress)
)
progressBar.secondaryProgress = progress
setTitle(this@PlayerFragment, downloadStatus)
}
PlayerState.PREPARING -> setTitle(
this@PlayerFragment,
R.string.download_playerstate_buffering
)
PlayerState.STARTED -> {
Player.STATE_READY -> {
progressBar.secondaryProgress = progress
if (mediaPlayerController.isShufflePlayEnabled) {
setTitle(
this@PlayerFragment,
@ -1076,30 +1067,28 @@ class PlayerFragment :
setTitle(this@PlayerFragment, R.string.common_appname)
}
}
PlayerState.IDLE,
PlayerState.PREPARED,
PlayerState.STOPPED,
PlayerState.PAUSED,
PlayerState.COMPLETED -> {
Player.STATE_IDLE,
Player.STATE_ENDED,
-> {
}
else -> setTitle(this@PlayerFragment, R.string.common_appname)
}
when (playerState) {
PlayerState.STARTED -> {
pauseButton.isVisible = true
when (playbackState) {
Player.STATE_READY -> {
pauseButton.isVisible = isPlaying
stopButton.isVisible = false
startButton.isVisible = false
playButton.isVisible = !isPlaying
}
PlayerState.DOWNLOADING, PlayerState.PREPARING -> {
Player.STATE_BUFFERING -> {
pauseButton.isVisible = false
stopButton.isVisible = true
startButton.isVisible = false
playButton.isVisible = false
}
else -> {
pauseButton.isVisible = false
stopButton.isVisible = false
startButton.isVisible = true
playButton.isVisible = true
}
}

View File

@ -109,7 +109,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
viewAdapter.register(
TrackViewBinder(
onItemClick = ::onItemClick,
onItemClick = { file, _ -> onItemClick(file) },
onContextMenuClick = ::onContextMenuItemSelected,
checkable = false,
draggable = false,
@ -151,7 +151,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
val arguments = arguments
val autoPlay = arguments != null &&
arguments.getBoolean(Constants.INTENT_AUTOPLAY, false)
arguments.getBoolean(Constants.INTENT_AUTOPLAY, false)
val query = arguments?.getString(Constants.INTENT_QUERY)
// If started with a query, enter it to the searchView
@ -303,13 +303,12 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
}
mediaPlayerController.addToPlaylist(
listOf(song),
save = false,
cachePermanently = false,
autoPlay = false,
playNext = false,
shuffle = false,
newPlaylist = false
insertionMode = MediaPlayerController.InsertionMode.APPEND
)
mediaPlayerController.play(mediaPlayerController.playlistSize - 1)
mediaPlayerController.play(mediaPlayerController.mediaItemCount - 1)
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
}

View File

@ -34,7 +34,7 @@ class ServerSelectorFragment : Fragment() {
private var listView: ListView? = null
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val service: MediaPlayerController by inject()
private val controller: MediaPlayerController by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private var serverRowAdapter: ServerRowAdapter? = null
@ -117,14 +117,14 @@ class ServerSelectorFragment : Fragment() {
// TODO this is still a blocking call - we shouldn't leave this activity before the active server is updated.
// Maybe this can be refactored by using LiveData, or this can be made more user friendly with a ProgressDialog
runBlocking {
controller.clearIncomplete()
withContext(Dispatchers.IO) {
if (activeServerProvider.getActiveServer().index != index) {
service.clearIncomplete()
activeServerProvider.setActiveServerByIndex(index)
service.isJukeboxEnabled =
activeServerProvider.getActiveServer().jukeboxByDefault
}
}
controller.isJukeboxEnabled =
activeServerProvider.getActiveServer().jukeboxByDefault
}
Timber.i("Active server was set to: $index")
}

View File

@ -23,7 +23,7 @@ import androidx.preference.PreferenceFragmentCompat
import java.io.File
import kotlin.math.ceil
import org.koin.core.component.KoinComponent
import org.koin.java.KoinJavaComponent.inject
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
@ -40,7 +40,6 @@ import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.ErrorDialog
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Settings.preferences
import org.moire.ultrasonic.util.Settings.shareGreeting
@ -89,12 +88,7 @@ class SettingsFragment :
private var debugLogToFile: CheckBoxPreference? = null
private var customCacheLocation: CheckBoxPreference? = null
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
MediaPlayerController::class.java
)
private val mediaSessionHandler = inject<MediaSessionHandler>(
MediaSessionHandler::class.java
)
private val mediaPlayerController: MediaPlayerController by inject()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
@ -221,9 +215,6 @@ class SettingsFragment :
Constants.PREFERENCES_KEY_HIDE_MEDIA -> {
setHideMedia(sharedPreferences.getBoolean(key, false))
}
Constants.PREFERENCES_KEY_MEDIA_BUTTONS -> {
setMediaButtonsEnabled(sharedPreferences.getBoolean(key, true))
}
Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS -> {
setBluetoothPreferences(sharedPreferences.getBoolean(key, true))
}
@ -433,11 +424,6 @@ class SettingsFragment :
toast(activity, R.string.settings_hide_media_toast, false)
}
private fun setMediaButtonsEnabled(enabled: Boolean) {
lockScreenEnabled!!.isEnabled = enabled
mediaSessionHandler.value.updateMediaButtonReceiver()
}
private fun setBluetoothPreferences(enabled: Boolean) {
sendBluetoothAlbumArt!!.isEnabled = enabled
}
@ -451,8 +437,8 @@ class SettingsFragment :
Settings.cacheLocationUri = path
// Clear download queue.
mediaPlayerControllerLazy.value.clear()
mediaPlayerControllerLazy.value.clearCaches()
mediaPlayerController.clear()
mediaPlayerController.clearCaches()
Storage.reset()
}

View File

@ -122,7 +122,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
viewAdapter.register(
TrackViewBinder(
onItemClick = { onItemClick(it.track) },
onItemClick = { file, _ -> onItemClick(file.track) },
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
checkable = true,
draggable = false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ package org.moire.ultrasonic.service
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import androidx.media.MediaBrowserServiceCompat
@ -26,7 +27,6 @@ import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import timber.log.Timber
@ -73,7 +73,6 @@ private const val SEARCH_LIMIT = 10
class AutoMediaBrowserService : MediaBrowserServiceCompat() {
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
private val mediaPlayerController by inject<MediaPlayerController>()
private val activeServerProvider: ActiveServerProvider by inject()
private val musicService = MusicServiceFactory.getMusicService()
@ -108,9 +107,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
playFromSearchCommand(it.first)
}
mediaSessionHandler.initialize()
val handler = Handler()
val handler = Handler(Looper.getMainLooper())
handler.postDelayed(
{
// Ultrasonic may be started from Android Auto. This boots up the necessary components.
@ -118,7 +116,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
"AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService..."
)
lifecycleSupport.onCreate()
MediaPlayerService.getInstance()
DownloadService.getInstance()
},
100
)
@ -186,7 +184,6 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
override fun onDestroy() {
super.onDestroy()
rxBusSubscription.dispose()
mediaSessionHandler.release()
serviceJob.cancel()
Timber.i("AutoMediaBrowserService onDestroy finished")
@ -662,7 +659,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
playlistCache = content?.getTracks()
}
if (playlistCache != null) playSongs(playlistCache)
if (playlistCache != null) playSongs(playlistCache!!)
}
}
@ -905,7 +902,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val content = listStarredSongsInMusicService()
starredSongsCache = content?.songs
}
if (starredSongsCache != null) playSongs(starredSongsCache)
if (starredSongsCache != null) playSongs(starredSongsCache!!)
}
}
@ -959,7 +956,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
randomSongsCache = content?.getTracks()
}
if (randomSongsCache != null) playSongs(randomSongsCache)
if (randomSongsCache != null) playSongs(randomSongsCache!!)
}
}
@ -1071,27 +1068,25 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
return section.toString()
}
private fun playSongs(songs: List<Track?>?) {
private fun playSongs(songs: List<Track>) {
mediaPlayerController.addToPlaylist(
songs,
save = false,
cachePermanently = false,
autoPlay = true,
playNext = false,
shuffle = false,
newPlaylist = true
insertionMode = MediaPlayerController.InsertionMode.CLEAR
)
}
private fun playSong(song: Track) {
mediaPlayerController.addToPlaylist(
listOf(song),
save = false,
cachePermanently = false,
autoPlay = false,
playNext = true,
shuffle = false,
newPlaylist = false
insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT
)
if (mediaPlayerController.playlistSize > 1) mediaPlayerController.next()
if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next()
else mediaPlayerController.play()
}

View File

@ -7,27 +7,18 @@
package org.moire.ultrasonic.service
import android.text.TextUtils
import androidx.lifecycle.MutableLiveData
import androidx.media3.common.MediaItem
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.safeClose
import timber.log.Timber
/**
@ -44,29 +35,22 @@ class DownloadFile(
) : KoinComponent, Identifiable {
val partialFile: String
lateinit var completeFile: String
val saveFile: String = FileUtil.getSongFile(track)
val pinnedFile: String = FileUtil.getSongFile(track)
var shouldSave = save
private var downloadTask: CancellableTask? = null
internal var downloadTask: CancellableTask? = null
var isFailed = false
private var retryCount = MAX_RETRIES
internal var retryCount = MAX_RETRIES
private val desiredBitRate: Int = Settings.maxBitRate
val desiredBitRate: Int = Settings.maxBitRate
var priority = 100
var downloadPrepared = false
@Volatile
private var isPlaying = false
internal var saveWhenDone = false
@Volatile
private var saveWhenDone = false
@Volatile
private var completeWhenDone = false
private val downloader: Downloader by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject()
var completeWhenDone = false
val progress: MutableLiveData<Int> = MutableLiveData(0)
@ -78,7 +62,7 @@ class DownloadFile(
private val lazyInitialStatus: Lazy<DownloadStatus> = lazy {
when {
Storage.isPathExists(saveFile) -> {
Storage.isPathExists(pinnedFile) -> {
DownloadStatus.PINNED
}
Storage.isPathExists(completeFile) -> {
@ -95,10 +79,10 @@ class DownloadFile(
}
init {
partialFile = FileUtil.getParentPath(saveFile) + "/" +
FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile))
completeFile = FileUtil.getParentPath(saveFile) + "/" +
FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile))
partialFile = FileUtil.getParentPath(pinnedFile) + "/" +
FileUtil.getPartialFile(FileUtil.getNameFromPath(pinnedFile))
completeFile = FileUtil.getParentPath(pinnedFile) + "/" +
FileUtil.getCompleteFile(FileUtil.getNameFromPath(pinnedFile))
}
/**
@ -115,13 +99,6 @@ class DownloadFile(
downloadPrepared = true
}
@Synchronized
fun download() {
FileUtil.createDirectoryForParent(saveFile)
isFailed = false
downloadTask = DownloadTask()
downloadTask!!.start()
}
@Synchronized
fun cancelDownload() {
@ -129,30 +106,23 @@ class DownloadFile(
}
val completeOrSaveFile: String
get() = if (Storage.isPathExists(saveFile)) {
saveFile
get() = if (Storage.isPathExists(pinnedFile)) {
pinnedFile
} else {
completeFile
}
val completeOrPartialFile: String
get() = if (isCompleteFileAvailable) {
completeOrSaveFile
} else {
partialFile
}
val isSaved: Boolean
get() = Storage.isPathExists(saveFile)
get() = Storage.isPathExists(pinnedFile)
@get:Synchronized
val isCompleteFileAvailable: Boolean
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)
@get:Synchronized
val isWorkDone: Boolean
get() = Storage.isPathExists(completeFile) && !shouldSave ||
Storage.isPathExists(saveFile) || saveWhenDone || completeWhenDone
Storage.isPathExists(pinnedFile) || saveWhenDone || completeWhenDone
@get:Synchronized
val isDownloading: Boolean
@ -170,54 +140,66 @@ class DownloadFile(
cancelDownload()
Storage.delete(partialFile)
Storage.delete(completeFile)
Storage.delete(saveFile)
Storage.delete(pinnedFile)
status.postValue(DownloadStatus.IDLE)
Util.scanMedia(saveFile)
Util.scanMedia(pinnedFile)
}
fun unpin() {
val file = Storage.getFromPath(saveFile) ?: return
Timber.e("CLEANING")
val file = Storage.getFromPath(pinnedFile) ?: return
Storage.rename(file, completeFile)
status.postValue(DownloadStatus.DONE)
}
fun cleanup(): Boolean {
Timber.e("CLEANING")
var ok = true
if (Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)) {
if (Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)) {
ok = Storage.delete(partialFile)
}
if (Storage.isPathExists(saveFile)) {
if (Storage.isPathExists(pinnedFile)) {
ok = ok and Storage.delete(completeFile)
}
return ok
}
fun setPlaying(isPlaying: Boolean) {
if (!isPlaying) doPendingRename()
this.isPlaying = isPlaying
/**
* Create a MediaItem instance representing the data inside this DownloadFile
*/
val mediaItem: MediaItem by lazy {
track.toMediaItem()
}
var isPlaying: Boolean = false
get() = field
set(isPlaying) {
if (!isPlaying) doPendingRename()
field = isPlaying
}
// Do a pending rename after the song has stopped playing
private fun doPendingRename() {
try {
Timber.e("CLEANING")
if (saveWhenDone) {
Storage.rename(completeFile, saveFile)
Storage.rename(completeFile, pinnedFile)
saveWhenDone = false
} else if (completeWhenDone) {
if (shouldSave) {
Storage.rename(partialFile, saveFile)
Util.scanMedia(saveFile)
Storage.rename(partialFile, pinnedFile)
Util.scanMedia(pinnedFile)
} else {
Storage.rename(partialFile, completeFile)
}
completeWhenDone = false
}
} catch (e: IOException) {
Timber.w(e, "Failed to rename file %s to %s", completeFile, saveFile)
Timber.w(e, "Failed to rename file %s to %s", completeFile, pinnedFile)
}
}
@ -225,176 +207,7 @@ class DownloadFile(
return String.format(Locale.ROOT, "DownloadFile (%s)", track)
}
private inner class DownloadTask : CancellableTask() {
val musicService = getMusicService()
override fun execute() {
downloadPrepared = false
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
try {
if (Storage.isPathExists(saveFile)) {
Timber.i("%s already exists. Skipping.", saveFile)
status.postValue(DownloadStatus.PINNED)
return
}
if (Storage.isPathExists(completeFile)) {
var newStatus: DownloadStatus = DownloadStatus.DONE
if (shouldSave) {
if (isPlaying) {
saveWhenDone = true
} else {
Storage.rename(completeFile, saveFile)
newStatus = DownloadStatus.PINNED
}
} else {
Timber.i("%s already exists. Skipping.", completeFile)
}
status.postValue(newStatus)
return
}
status.postValue(DownloadStatus.DOWNLOADING)
// Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean
val duration = track.duration
val fileLength = Storage.getFromPath(partialFile)?.length ?: 0
needsDownloading = (
desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L
)
if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, isPartial) = musicService.getDownloadInputStream(
track, fileLength, desiredBitRate, shouldSave
)
inputStream = inStream
if (isPartial) {
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
}
outputStream = Storage.getOrCreateFileFromPath(partialFile)
.getFileOutputStream(isPartial)
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
setProgress(totalBytesCopied)
}
Timber.i("Downloaded %d bytes to %s", len, partialFile)
inputStream.close()
outputStream.flush()
outputStream.close()
if (isCancelled) {
status.postValue(DownloadStatus.CANCELLED)
throw RuntimeException(
String.format(Locale.ROOT, "Download of '%s' was cancelled", track)
)
}
if (track.artistId != null) {
cacheMetadata(track.artistId!!)
}
downloadAndSaveCoverArt()
}
if (isPlaying) {
completeWhenDone = true
} else {
if (shouldSave) {
Storage.rename(partialFile, saveFile)
status.postValue(DownloadStatus.PINNED)
Util.scanMedia(saveFile)
} else {
Storage.rename(partialFile, completeFile)
status.postValue(DownloadStatus.DONE)
}
}
} catch (all: Exception) {
outputStream.safeClose()
Storage.delete(completeFile)
Storage.delete(saveFile)
if (!isCancelled) {
isFailed = true
if (retryCount > 1) {
status.postValue(DownloadStatus.RETRYING)
--retryCount
} else if (retryCount == 1) {
status.postValue(DownloadStatus.FAILED)
--retryCount
}
Timber.w(all, "Failed to download '%s'.", track)
}
} finally {
inputStream.safeClose()
outputStream.safeClose()
CacheCleaner().cleanSpace()
downloader.checkDownloads()
}
}
override fun toString(): String {
return String.format(Locale.ROOT, "DownloadTask (%s)", track)
}
private fun cacheMetadata(artistId: String) {
// TODO: Right now it's caching the track artist.
// Once the albums are cached in db, we should retrieve the album,
// and then cache the album artist.
if (artistId.isEmpty()) return
var artist: Artist? =
activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId)
// If we are downloading a new album, and the user has not visited the Artists list
// recently, then the artist won't be in the database.
if (artist == null) {
val artists: List<Artist> = musicService.getArtists(true)
artist = artists.find {
it.id == artistId
}
}
// If we have found an artist, catch it.
if (artist != null) {
activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist)
}
}
private fun downloadAndSaveCoverArt() {
try {
if (!TextUtils.isEmpty(track.coverArt)) {
// Download the largest size that we can display in the UI
imageLoaderProvider.getImageLoader().cacheCoverArt(track)
}
} catch (all: Exception) {
Timber.e(all, "Failed to get cover art.")
}
}
@Throws(IOException::class)
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
while (!isCancelled && bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
onCopy(bytesCopied)
bytes = read(buffer)
}
return bytesCopied
}
}
private fun setProgress(totalBytesCopied: Long) {
internal fun setProgress(totalBytesCopied: Long) {
if (track.size != null) {
progress.postValue((totalBytesCopied * 100 / track.size!!).toInt())
}

View File

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

View File

@ -1,119 +1,130 @@
package org.moire.ultrasonic.service
import android.net.wifi.WifiManager
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import androidx.lifecycle.MutableLiveData
import java.util.ArrayList
import java.util.PriorityQueue
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.playback.LegacyPlaylistManager
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.LRUCache
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.ShufflePlayBuffer
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.safeClose
import timber.log.Timber
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import java.util.PriorityQueue
/**
* This class is responsible for maintaining the playlist and downloading
* its items from the network to the filesystem.
*
* TODO: Move away from managing the queue with scheduled checks, instead use callbacks when
* Downloads are finished
* TODO: Move entirely to subclass the Media3.DownloadService
*/
class Downloader(
private val shufflePlayBuffer: ShufflePlayBuffer,
private val externalStorageMonitor: ExternalStorageMonitor,
private val localMediaPlayer: LocalMediaPlayer
private val storageMonitor: ExternalStorageMonitor,
private val legacyPlaylistManager: LegacyPlaylistManager,
) : KoinComponent {
private val playlist = mutableListOf<DownloadFile>()
// Dependencies
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val mediaController: MediaPlayerController by inject()
var started: Boolean = false
var shouldStop: Boolean = false
private val downloadQueue = PriorityQueue<DownloadFile>()
private val activelyDownloading = mutableListOf<DownloadFile>()
// TODO: The playlist is now published with RX, while the observableDownloads is using LiveData.
// Use the same for both
// The generic list models expect a LiveData, so even though we are using Rx for many events
// surrounding playback the list of Downloads is published as LiveData.
val observableDownloads = MutableLiveData<List<DownloadFile>>()
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
private val downloadFileCache = LRUCache<Track, DownloadFile>(100)
private val downloadFileCache = LRUCache<Track, DownloadFile>(500)
private var executorService: ScheduledExecutorService? = null
private var handler: Handler = Handler(Looper.getMainLooper())
private var wifiLock: WifiManager.WifiLock? = null
private var playlistUpdateRevision: Long = 0
private set(value) {
field = value
RxBus.playlistPublisher.onNext(playlist)
}
private var backgroundPriorityCounter = 100
var backgroundPriorityCounter = 100
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
val downloadChecker = Runnable {
try {
Timber.w("Checking Downloads")
checkDownloadsInternal()
} catch (all: Exception) {
Timber.e(all, "checkDownloads() failed.")
var downloadChecker = object : Runnable {
override fun run() {
try {
Timber.w("Checking Downloads")
checkDownloadsInternal()
} catch (all: Exception) {
Timber.e(all, "checkDownloads() failed.")
} finally {
if (!shouldStop) {
Handler(Looper.getMainLooper()).postDelayed(this, CHECK_INTERVAL)
} else {
shouldStop = false
}
}
}
}
fun onDestroy() {
stop()
clearPlaylist()
rxBusSubscription.dispose()
clearBackground()
observableDownloads.value = listOf()
Timber.i("Downloader destroyed")
}
@Synchronized
fun start() {
started = true
if (executorService == null) {
executorService = Executors.newSingleThreadScheduledExecutor()
executorService!!.scheduleWithFixedDelay(
downloadChecker, 0L, CHECK_INTERVAL, TimeUnit.SECONDS
)
Timber.i("Downloader started")
}
// Start our loop
handler.postDelayed(downloadChecker, 100)
if (wifiLock == null) {
wifiLock = Util.createWifiLock(toString())
wifiLock?.acquire()
}
// Check downloads if the playlist changed
rxBusSubscription += RxBus.playlistObservable.subscribe {
checkDownloads()
}
}
fun stop() {
started = false
executorService?.shutdown()
executorService = null
shouldStop = true
wifiLock?.release()
wifiLock = null
MediaPlayerService.runningInstance?.notifyDownloaderStopped()
DownloadService.runningInstance?.notifyDownloaderStopped()
Timber.i("Downloader stopped")
}
fun checkDownloads() {
if (
executorService == null ||
executorService!!.isTerminated ||
executorService!!.isShutdown
) {
if (!started) {
start()
} else {
try {
executorService?.execute(downloadChecker)
} catch (exception: RejectedExecutionException) {
handler.postDelayed(downloadChecker, 100)
} catch (all: Exception) {
Timber.w(
exception,
all,
"checkDownloads() can't run, maybe the Downloader is shutting down..."
)
}
@ -121,22 +132,17 @@ class Downloader(
}
@Synchronized
@Suppress("ComplexMethod", "ComplexCondition")
fun checkDownloadsInternal() {
if (
!Util.isExternalStoragePresent() ||
!externalStorageMonitor.isExternalStorageAvailable
) {
if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) {
return
}
if (shufflePlayBuffer.isEnabled) {
checkShufflePlay()
}
if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
return
}
Timber.v("Downloader checkDownloadsInternal checking downloads")
// Check the active downloads for failures or completions and remove them
// Store the result in a flag to know if changes have occurred
var listChanged = cleanupActiveDownloads()
@ -145,13 +151,14 @@ class Downloader(
val preloadCount = Settings.preloadCount
// Start preloading at the current playing song
var start = currentPlayingIndex
var start = mediaController.currentMediaItemIndex
if (start == -1) start = 0
val end = (start + preloadCount).coerceAtMost(playlist.size)
val end = (start + preloadCount).coerceAtMost(mediaController.mediaItemCount)
for (i in start until end) {
val download = playlist[i]
val download = legacyPlaylistManager.playlist[i]
// Set correct priority (the lower the number, the higher the priority)
download.priority = i
@ -173,10 +180,6 @@ class Downloader(
activelyDownloading.add(task)
startDownloadOnService(task)
// The next file on the playlist is currently downloading
if (playlist.indexOf(task) == 1) {
localMediaPlayer.setNextPlayerState(PlayerState.DOWNLOADING)
}
listChanged = true
}
@ -194,10 +197,14 @@ class Downloader(
observableDownloads.postValue(downloads)
}
private fun startDownloadOnService(task: DownloadFile) {
task.prepare()
MediaPlayerService.executeOnStartedMediaPlayerService {
task.download()
private fun startDownloadOnService(file: DownloadFile) {
if (file.isDownloading) return
file.prepare()
DownloadService.executeOnStartedMediaPlayerService {
FileUtil.createDirectoryForParent(file.pinnedFile)
file.isFailed = false
file.downloadTask = DownloadTask(file)
file.downloadTask!!.start()
}
}
@ -225,26 +232,6 @@ class Downloader(
return (oldSize != activelyDownloading.size)
}
@get:Synchronized
val currentPlayingIndex: Int
get() = playlist.indexOf(localMediaPlayer.currentPlaying)
@get:Synchronized
val downloadListDuration: Long
get() {
var totalDuration: Long = 0
for (downloadFile in playlist) {
val song = downloadFile.track
if (!song.isDirectory) {
if (song.artist != null) {
if (song.duration != null) {
totalDuration += song.duration!!.toLong()
}
}
}
}
return totalDuration
}
@get:Synchronized
val all: List<DownloadFile>
@ -252,7 +239,7 @@ class Downloader(
val temp: MutableList<DownloadFile> = ArrayList()
temp.addAll(activelyDownloading)
temp.addAll(downloadQueue)
temp.addAll(playlist)
temp.addAll(legacyPlaylistManager.playlist)
return temp.distinct().sorted()
}
@ -267,7 +254,7 @@ class Downloader(
temp.addAll(activelyDownloading)
temp.addAll(downloadQueue)
temp.addAll(
playlist.filter {
legacyPlaylistManager.playlist.filter {
if (!it.isStatusInitialized) false
else when (it.status.value) {
DownloadStatus.DOWNLOADING -> true
@ -278,37 +265,13 @@ class Downloader(
return temp.distinct().sorted()
}
// Public facing playlist (immutable)
@Synchronized
fun getPlaylist(): List<DownloadFile> = playlist
@Synchronized
fun clearDownloadFileCache() {
downloadFileCache.clear()
}
@Synchronized
fun clearPlaylist() {
playlist.clear()
val toRemove = mutableListOf<DownloadFile>()
// Cancel all active downloads with a high priority
for (download in activelyDownloading) {
if (download.priority < 100) {
download.cancelDownload()
toRemove.add(download)
}
}
activelyDownloading.removeAll(toRemove)
playlistUpdateRevision++
updateLiveData()
}
@Synchronized
private fun clearBackground() {
fun clearBackground() {
// Clear the pending queue
downloadQueue.clear()
@ -333,78 +296,6 @@ class Downloader(
updateLiveData()
}
@Synchronized
fun removeFromPlaylist(downloadFile: DownloadFile) {
if (activelyDownloading.contains(downloadFile)) {
downloadFile.cancelDownload()
}
playlist.remove(downloadFile)
playlistUpdateRevision++
checkDownloads()
}
@Synchronized
fun addToPlaylist(
songs: List<Track>,
save: Boolean,
autoPlay: Boolean,
playNext: Boolean,
newPlaylist: Boolean
) {
shufflePlayBuffer.isEnabled = false
var offset = 1
if (songs.isEmpty()) {
return
}
if (newPlaylist) {
playlist.clear()
}
if (playNext) {
if (autoPlay && currentPlayingIndex >= 0) {
offset = 0
}
for (song in songs) {
val downloadFile = song.getDownloadFile(save)
playlist.add(currentPlayingIndex + offset, downloadFile)
offset++
}
} else {
for (song in songs) {
val downloadFile = song.getDownloadFile(save)
playlist.add(downloadFile)
}
}
playlistUpdateRevision++
checkDownloads()
}
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
val item = playlist[oldPos]
playlist.remove(item)
if (newPos < oldPos) {
playlist.add(newPos + 1, item)
} else {
playlist.add(newPos - 1, item)
}
playlistUpdateRevision++
checkDownloads()
}
@Synchronized
fun clearIncomplete() {
val iterator = playlist.iterator()
var changedPlaylist = false
while (iterator.hasNext()) {
val downloadFile = iterator.next()
if (!downloadFile.isCompleteFileAvailable) {
iterator.remove()
changedPlaylist = true
}
}
if (changedPlaylist) playlistUpdateRevision++
}
@Synchronized
fun downloadBackground(songs: List<Track>, save: Boolean) {
@ -413,30 +304,19 @@ class Downloader(
for (song in songs) {
val file = song.getDownloadFile()
file.shouldSave = save
file.priority = backgroundPriorityCounter++
downloadQueue.add(file)
if (!file.isDownloading) {
file.priority = backgroundPriorityCounter++
downloadQueue.add(file)
}
}
checkDownloads()
}
@Synchronized
fun shuffle() {
playlist.shuffle()
// Move the current song to the top..
if (localMediaPlayer.currentPlaying != null) {
playlist.remove(localMediaPlayer.currentPlaying)
playlist.add(0, localMediaPlayer.currentPlaying!!)
}
playlistUpdateRevision++
}
@Synchronized
@Suppress("ReturnCount")
fun getDownloadFileForSong(song: Track): DownloadFile {
for (downloadFile in playlist) {
for (downloadFile in legacyPlaylistManager.playlist) {
if (downloadFile.track == song) {
return downloadFile
}
@ -459,63 +339,205 @@ class Downloader(
return downloadFile
}
@Synchronized
private fun checkShufflePlay() {
// Get users desired random playlist size
val listSize = Settings.maxSongs
val wasEmpty = playlist.isEmpty()
val revisionBefore = playlistUpdateRevision
// First, ensure that list is at least 20 songs long.
val size = playlist.size
if (size < listSize) {
for (song in shufflePlayBuffer[listSize - size]) {
val downloadFile = song.getDownloadFile(false)
playlist.add(downloadFile)
playlistUpdateRevision++
}
}
val currIndex = if (localMediaPlayer.currentPlaying == null) 0 else currentPlayingIndex
// Only shift playlist if playing song #5 or later.
if (currIndex > SHUFFLE_BUFFER_LIMIT) {
val songsToShift = currIndex - 2
for (song in shufflePlayBuffer[songsToShift]) {
playlist.add(song.getDownloadFile(false))
playlist[0].cancelDownload()
playlist.removeAt(0)
playlistUpdateRevision++
}
}
if (revisionBefore != playlistUpdateRevision) {
jukeboxMediaPlayer.updatePlaylist()
}
if (wasEmpty && playlist.isNotEmpty()) {
if (jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.skip(0, 0)
localMediaPlayer.setPlayerState(PlayerState.STARTED, playlist[0])
} else {
localMediaPlayer.play(playlist[0])
}
}
}
companion object {
const val PARALLEL_DOWNLOADS = 3
const val CHECK_INTERVAL = 5L
const val SHUFFLE_BUFFER_LIMIT = 4
const val CHECK_INTERVAL = 5000L
}
/**
* Extension function
* Gathers the download file for a given song, and modifies shouldSave if provided.
*/
fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
private fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
return getDownloadFileForSong(this).apply {
if (save != null) this.shouldSave = save
}
}
private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() {
val musicService = MusicServiceFactory.getMusicService()
override fun execute() {
downloadFile.downloadPrepared = false
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
try {
if (Storage.isPathExists(downloadFile.pinnedFile)) {
Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile)
downloadFile.status.postValue(DownloadStatus.PINNED)
return
}
if (Storage.isPathExists(downloadFile.completeFile)) {
var newStatus: DownloadStatus = DownloadStatus.DONE
if (downloadFile.shouldSave) {
if (downloadFile.isPlaying) {
downloadFile.saveWhenDone = true
} else {
Storage.rename(
downloadFile.completeFile,
downloadFile.pinnedFile
)
newStatus = DownloadStatus.PINNED
}
} else {
Timber.i(
"%s already exists. Skipping.",
downloadFile.completeFile
)
}
downloadFile.status.postValue(newStatus)
return
}
downloadFile.status.postValue(DownloadStatus.DOWNLOADING)
// Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean
val duration = downloadFile.track.duration
val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0
needsDownloading = (
downloadFile.desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L
)
if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, isPartial) = musicService.getDownloadInputStream(
downloadFile.track, fileLength,
downloadFile.desiredBitRate,
downloadFile.shouldSave
)
inputStream = inStream
if (isPartial) {
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
}
outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile)
.getFileOutputStream(isPartial)
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
downloadFile.setProgress(totalBytesCopied)
}
Timber.i("Downloaded %d bytes to %s", len, downloadFile.partialFile)
inputStream.close()
outputStream.flush()
outputStream.close()
if (isCancelled) {
downloadFile.status.postValue(DownloadStatus.CANCELLED)
throw RuntimeException(
String.format(
Locale.ROOT, "Download of '%s' was cancelled",
downloadFile.track
)
)
}
if (downloadFile.track.artistId != null) {
cacheMetadata(downloadFile.track.artistId!!)
}
downloadAndSaveCoverArt()
}
if (downloadFile.isPlaying) {
downloadFile.completeWhenDone = true
} else {
if (downloadFile.shouldSave) {
Storage.rename(
downloadFile.partialFile,
downloadFile.pinnedFile
)
downloadFile.status.postValue(DownloadStatus.PINNED)
Util.scanMedia(downloadFile.pinnedFile)
} else {
Storage.rename(
downloadFile.partialFile,
downloadFile.completeFile
)
downloadFile.status.postValue(DownloadStatus.DONE)
}
}
} catch (all: Exception) {
outputStream.safeClose()
Storage.delete(downloadFile.completeFile)
Storage.delete(downloadFile.pinnedFile)
if (!isCancelled) {
downloadFile.isFailed = true
if (downloadFile.retryCount > 1) {
downloadFile.status.postValue(DownloadStatus.RETRYING)
--downloadFile.retryCount
} else if (downloadFile.retryCount == 1) {
downloadFile.status.postValue(DownloadStatus.FAILED)
--downloadFile.retryCount
}
Timber.w(all, "Failed to download '%s'.", downloadFile.track)
}
} finally {
inputStream.safeClose()
outputStream.safeClose()
CacheCleaner().cleanSpace()
checkDownloads()
}
}
override fun toString(): String {
return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track)
}
private fun cacheMetadata(artistId: String) {
// TODO: Right now it's caching the track artist.
// Once the albums are cached in db, we should retrieve the album,
// and then cache the album artist.
if (artistId.isEmpty()) return
var artist: Artist? =
activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId)
// If we are downloading a new album, and the user has not visited the Artists list
// recently, then the artist won't be in the database.
if (artist == null) {
val artists: List<Artist> = musicService.getArtists(true)
artist = artists.find {
it.id == artistId
}
}
// If we have found an artist, catch it.
if (artist != null) {
activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist)
}
}
private fun downloadAndSaveCoverArt() {
try {
if (!TextUtils.isEmpty(downloadFile.track.coverArt)) {
// Download the largest size that we can display in the UI
imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track)
}
} catch (all: Exception) {
Timber.e(all, "Failed to get cover art.")
}
}
@Throws(IOException::class)
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
while (!isCancelled && bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
onCopy(bytesCopied)
bytes = read(buffer)
}
return bytesCopied
}
}
}

View File

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

View File

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

View File

@ -6,20 +6,34 @@
*/
package org.moire.ultrasonic.service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_BUFFERING
import androidx.media3.common.Timeline
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.domain.RepeatMode
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerService.Companion.executeOnStartedMediaPlayerService
import org.moire.ultrasonic.service.MediaPlayerService.Companion.getInstance
import org.moire.ultrasonic.service.MediaPlayerService.Companion.runningInstance
import org.moire.ultrasonic.playback.LegacyPlaylistManager
import org.moire.ultrasonic.playback.PlaybackService
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
import org.moire.ultrasonic.service.DownloadService.Companion.getInstance
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.ShufflePlayBuffer
import timber.log.Timber
/**
@ -32,8 +46,8 @@ class MediaPlayerController(
private val playbackStateSerializer: PlaybackStateSerializer,
private val externalStorageMonitor: ExternalStorageMonitor,
private val downloader: Downloader,
private val shufflePlayBuffer: ShufflePlayBuffer,
private val localMediaPlayer: LocalMediaPlayer
private val legacyPlaylistManager: LegacyPlaylistManager,
val context: Context
) : KoinComponent {
private var created = false
@ -42,22 +56,142 @@ class MediaPlayerController(
var showVisualization = false
private var autoPlayStart = false
private val scrobbler = Scrobbler()
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private var sessionToken =
SessionToken(context, ComponentName(context, PlaybackService::class.java))
private var mediaControllerFuture = MediaController.Builder(
context,
sessionToken
).buildAsync()
var controller: MediaController? = null
fun onCreate() {
if (created) return
externalStorageMonitor.onCreate { reset() }
isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault
mediaControllerFuture.addListener({
controller = mediaControllerFuture.get()
controller?.addListener(object : Player.Listener {
/*
* This will be called everytime the playlist has changed.
*/
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
legacyPlaylistManager.rebuildPlaylist(controller!!)
}
override fun onPlaybackStateChanged(playbackState: Int) {
translatePlaybackState(playbackState = playbackState)
playerStateChangedHandler()
publishPlaybackState()
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
translatePlaybackState(isPlaying = isPlaying)
playerStateChangedHandler()
publishPlaybackState()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
legacyPlaylistManager.updateCurrentPlaying(mediaItem)
publishPlaybackState()
}
})
//controller?.play()
}, MoreExecutors.directExecutor())
created = true
Timber.i("MediaPlayerController created")
}
@Suppress("DEPRECATION")
fun translatePlaybackState(
playbackState: Int = controller?.playbackState ?: 0,
isPlaying: Boolean = controller?.isPlaying ?: false
) {
legacyPlayerState = when (playbackState) {
STATE_BUFFERING -> PlayerState.DOWNLOADING
Player.STATE_ENDED -> {
PlayerState.COMPLETED
}
Player.STATE_IDLE -> {
PlayerState.IDLE
}
Player.STATE_READY -> {
if (isPlaying) {
PlayerState.STARTED
} else {
PlayerState.PAUSED
}
}
else -> {
PlayerState.IDLE
}
}
}
private fun playerStateChangedHandler() {
val playerState = legacyPlayerState
val currentPlaying = legacyPlaylistManager.currentPlaying
when {
playerState === PlayerState.PAUSED -> {
// TODO: Save playlist
// playbackStateSerializer.serialize(
// downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition
// )
}
playerState === PlayerState.STARTED -> {
scrobbler.scrobble(currentPlaying, false)
}
playerState === PlayerState.COMPLETED -> {
scrobbler.scrobble(currentPlaying, true)
}
}
//Update widget
if (currentPlaying != null) {
updateWidget(playerState, currentPlaying.track)
}
Timber.d("Processed player state change")
}
private fun publishPlaybackState() {
RxBus.playerStatePublisher.onNext(
RxBus.StateWithTrack(
state = legacyPlayerState,
track = legacyPlaylistManager.currentPlaying,
index = currentMediaItemIndex
)
)
}
private fun updateWidget(playerState: PlayerState, song: Track?) {
val started = playerState === PlayerState.STARTED
val context = UApp.applicationContext()
UltrasonicAppWidgetProvider4X1.getInstance().notifyChange(context, song, started, false)
UltrasonicAppWidgetProvider4X2.getInstance().notifyChange(context, song, started, true)
UltrasonicAppWidgetProvider4X3.getInstance().notifyChange(context, song, started, false)
UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false)
}
fun onDestroy() {
if (!created) return
val context = UApp.applicationContext()
externalStorageMonitor.onDestroy()
context.stopService(Intent(context, MediaPlayerService::class.java))
context.stopService(Intent(context, DownloadService::class.java))
legacyPlaylistManager.onDestroy()
downloader.onDestroy()
created = false
Timber.i("MediaPlayerController destroyed")
@ -73,33 +207,30 @@ class MediaPlayerController(
) {
addToPlaylist(
songs,
save = false,
cachePermanently = false,
autoPlay = false,
playNext = false,
shuffle = false,
newPlaylist = newPlaylist
)
if (currentPlayingIndex != -1) {
executeOnStartedMediaPlayerService { mediaPlayerService: MediaPlayerService ->
mediaPlayerService.play(currentPlayingIndex, autoPlayStart)
if (localMediaPlayer.currentPlaying != null) {
if (autoPlay && jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.skip(
downloader.currentPlayingIndex,
currentPlayingPosition / 1000
)
} else {
if (localMediaPlayer.currentPlaying!!.isCompleteFileAvailable) {
localMediaPlayer.play(
localMediaPlayer.currentPlaying,
currentPlayingPosition,
autoPlay
)
}
}
}
autoPlayStart = false
if (jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.skip(
currentPlayingIndex,
currentPlayingPosition / 1000
)
} else {
seekTo(currentPlayingIndex, currentPlayingPosition)
}
if (autoPlay) {
prepare()
play()
}
autoPlayStart = false
}
}
@ -110,93 +241,139 @@ class MediaPlayerController(
@Synchronized
fun play(index: Int) {
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
service.play(index, true)
}
controller?.seekTo(index, 0L)
controller?.play()
}
@Synchronized
fun play() {
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
service.play()
if (jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.start()
} else {
controller?.play()
}
}
@Synchronized
fun prepare() {
controller?.prepare()
}
@Synchronized
fun resumeOrPlay() {
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
service.resumeOrPlay()
}
controller?.play()
}
@Synchronized
fun togglePlayPause() {
if (localMediaPlayer.playerState === PlayerState.IDLE) autoPlayStart = true
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
service.togglePlayPause()
if (playbackState == Player.STATE_IDLE) autoPlayStart = true
if (controller?.isPlaying == false) {
controller?.pause()
} else {
controller?.play()
}
}
@Synchronized
fun start() {
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
service.start()
}
}
@Synchronized
fun seekTo(position: Int) {
val mediaPlayerService = runningInstance
mediaPlayerService?.seekTo(position)
controller?.seekTo(position.toLong())
}
@Synchronized
fun seekTo(index: Int, position: Int) {
controller?.seekTo(index, position.toLong())
}
@Synchronized
fun pause() {
val mediaPlayerService = runningInstance
mediaPlayerService?.pause()
if (jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.stop()
} else {
controller?.pause()
}
}
@Synchronized
fun stop() {
val mediaPlayerService = runningInstance
mediaPlayerService?.stop()
if (jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.stop()
} else {
controller?.stop()
}
}
@Synchronized
@Deprecated("Use InsertionMode Syntax")
@Suppress("LongParameterList")
fun addToPlaylist(
songs: List<Track?>?,
save: Boolean,
cachePermanently: Boolean,
autoPlay: Boolean,
playNext: Boolean,
shuffle: Boolean,
newPlaylist: Boolean
) {
if (songs == null) return
val filteredSongs = songs.filterNotNull()
downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist)
jukeboxMediaPlayer.updatePlaylist()
if (shuffle) shuffle()
val isLastTrack = (downloader.getPlaylist().size - 1 == downloader.currentPlayingIndex)
if (!playNext && !autoPlay && isLastTrack) {
val mediaPlayerService = runningInstance
mediaPlayerService?.setNextPlaying()
val insertionMode = when {
newPlaylist -> InsertionMode.CLEAR
playNext -> InsertionMode.AFTER_CURRENT
else -> InsertionMode.APPEND
}
val filteredSongs = songs.filterNotNull()
addToPlaylist(
filteredSongs, cachePermanently, autoPlay, shuffle, insertionMode
)
}
@Synchronized
fun addToPlaylist(
songs: List<Track>,
cachePermanently: Boolean,
autoPlay: Boolean,
shuffle: Boolean,
insertionMode: InsertionMode
) {
var insertAt = 0
if (insertionMode == InsertionMode.CLEAR) {
clear()
}
when (insertionMode) {
InsertionMode.CLEAR -> clear()
InsertionMode.APPEND -> insertAt = mediaItemCount
InsertionMode.AFTER_CURRENT -> insertAt = currentMediaItemIndex
}
val mediaItems: List<MediaItem> = songs.map {
val downloadFile = downloader.getDownloadFileForSong(it)
if (cachePermanently) downloadFile.shouldSave = true
val result = it.toMediaItem()
legacyPlaylistManager.addToCache(result, downloader.getDownloadFileForSong(it))
result
}
controller?.addMediaItems(insertAt, mediaItems)
jukeboxMediaPlayer.updatePlaylist()
if (shuffle) isShufflePlayEnabled = true
if (autoPlay) {
prepare()
play(0)
} else {
if (localMediaPlayer.currentPlaying == null && downloader.getPlaylist().isNotEmpty()) {
localMediaPlayer.currentPlaying = downloader.getPlaylist()[0]
downloader.getPlaylist()[0].setPlaying(true)
}
downloader.checkDownloads()
}
playbackStateSerializer.serialize(
downloader.getPlaylist(),
downloader.currentPlayingIndex,
legacyPlaylistManager.playlist,
currentMediaItemIndex,
playerPosition
)
}
@ -206,77 +383,60 @@ class MediaPlayerController(
if (songs == null) return
val filteredSongs = songs.filterNotNull()
downloader.downloadBackground(filteredSongs, save)
playbackStateSerializer.serialize(
downloader.getPlaylist(),
downloader.currentPlayingIndex,
legacyPlaylistManager.playlist,
currentMediaItemIndex,
playerPosition
)
}
@Synchronized
fun setCurrentPlaying(index: Int) {
val mediaPlayerService = runningInstance
mediaPlayerService?.setCurrentPlaying(index)
}
fun stopJukeboxService() {
jukeboxMediaPlayer.stopJukeboxService()
}
@set:Synchronized
var isShufflePlayEnabled: Boolean
get() = shufflePlayBuffer.isEnabled
get() = controller?.shuffleModeEnabled == true
set(enabled) {
shufflePlayBuffer.isEnabled = enabled
controller?.shuffleModeEnabled = enabled
if (enabled) {
clear()
downloader.checkDownloads()
}
}
@Synchronized
fun shuffle() {
downloader.shuffle()
playbackStateSerializer.serialize(
downloader.getPlaylist(),
downloader.currentPlayingIndex,
playerPosition
)
jukeboxMediaPlayer.updatePlaylist()
val mediaPlayerService = runningInstance
mediaPlayerService?.setNextPlaying()
fun toggleShuffle() {
isShufflePlayEnabled = !isShufflePlayEnabled
}
val bufferedPercentage: Int
get() = controller?.bufferedPercentage ?: 0
@Synchronized
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
downloader.moveItemInPlaylist(oldPos, newPos)
controller?.moveMediaItem(oldPos, newPos)
}
@set:Synchronized
var repeatMode: RepeatMode
get() = Settings.repeatMode
set(repeatMode) {
Settings.repeatMode = repeatMode
val mediaPlayerService = runningInstance
mediaPlayerService?.setNextPlaying()
var repeatMode: Int
get() = controller?.repeatMode ?: 0
set(newMode) {
controller?.repeatMode = newMode
}
@Synchronized
@JvmOverloads
fun clear(serialize: Boolean = true) {
val mediaPlayerService = runningInstance
if (mediaPlayerService != null) {
mediaPlayerService.clear(serialize)
} else {
// If no MediaPlayerService is available, just empty the playlist
downloader.clearPlaylist()
if (serialize) {
playbackStateSerializer.serialize(
downloader.getPlaylist(),
downloader.currentPlayingIndex, playerPosition
)
}
controller?.clearMediaItems()
if (controller != null && serialize) {
playbackStateSerializer.serialize(
listOf(), -1, 0
)
}
jukeboxMediaPlayer.updatePlaylist()
}
@ -289,11 +449,12 @@ class MediaPlayerController(
fun clearIncomplete() {
reset()
downloader.clearIncomplete()
downloader.clearActiveDownloads()
downloader.clearBackground()
playbackStateSerializer.serialize(
downloader.getPlaylist(),
downloader.currentPlayingIndex,
legacyPlaylistManager.playlist,
currentMediaItemIndex,
playerPosition
)
@ -301,26 +462,17 @@ class MediaPlayerController(
}
@Synchronized
// TODO: If a playlist contains an item twice, this call will wrongly remove all
// FIXME
// With the new API we can only remove by index!!
fun removeFromPlaylist(downloadFile: DownloadFile) {
if (downloadFile == localMediaPlayer.currentPlaying) {
reset()
currentPlaying = null
}
downloader.removeFromPlaylist(downloadFile)
playbackStateSerializer.serialize(
downloader.getPlaylist(),
downloader.currentPlayingIndex,
legacyPlaylistManager.playlist,
legacyPlaylistManager.currentPlayingIndex,
playerPosition
)
jukeboxMediaPlayer.updatePlaylist()
if (downloadFile == localMediaPlayer.nextPlaying) {
val mediaPlayerService = runningInstance
mediaPlayerService?.setNextPlaying()
}
}
@Synchronized
@ -341,80 +493,56 @@ class MediaPlayerController(
@Synchronized
fun previous() {
val index = downloader.currentPlayingIndex
if (index == -1) {
return
}
// Restart song if played more than five seconds.
@Suppress("MagicNumber")
if (playerPosition > 5000 || index == 0) {
play(index)
} else {
play(index - 1)
}
controller?.seekToPrevious()
}
@Synchronized
operator fun next() {
val index = downloader.currentPlayingIndex
if (index != -1) {
when (repeatMode) {
RepeatMode.SINGLE, RepeatMode.OFF -> {
// Play next if exists
if (index + 1 >= 0 && index + 1 < downloader.getPlaylist().size) {
play(index + 1)
}
}
RepeatMode.ALL -> {
play((index + 1) % downloader.getPlaylist().size)
}
else -> {
}
}
}
controller?.seekToNext()
}
@Synchronized
fun reset() {
val mediaPlayerService = runningInstance
if (mediaPlayerService != null) localMediaPlayer.reset()
controller?.clearMediaItems()
}
@get:Synchronized
val playerPosition: Int
get() {
val mediaPlayerService = runningInstance ?: return 0
return mediaPlayerService.playerPosition
return if (jukeboxMediaPlayer.isEnabled) {
jukeboxMediaPlayer.positionSeconds * 1000
} else {
controller?.currentPosition?.toInt() ?: 0
}
}
@get:Synchronized
val playerDuration: Int
get() {
val mediaPlayerService = runningInstance ?: return 0
return mediaPlayerService.playerDuration
return controller?.duration?.toInt() ?: return 0
}
@Deprecated("Use Controller.playbackState and Controller.isPlaying")
@set:Synchronized
var playerState: PlayerState
get() = localMediaPlayer.playerState
set(state) {
val mediaPlayerService = runningInstance
if (mediaPlayerService != null)
localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying)
}
var legacyPlayerState: PlayerState = PlayerState.IDLE
val playbackState: Int
get() = controller?.playbackState ?: 0
val isPlaying: Boolean
get() = controller?.isPlaying ?: false
@set:Synchronized
var isJukeboxEnabled: Boolean
get() = jukeboxMediaPlayer.isEnabled
set(jukeboxEnabled) {
jukeboxMediaPlayer.isEnabled = jukeboxEnabled
playerState = PlayerState.IDLE
if (jukeboxEnabled) {
jukeboxMediaPlayer.startJukeboxService()
reset()
// Cancel current download, if necessary.
// Cancel current downloads
downloader.clearActiveDownloads()
} else {
jukeboxMediaPlayer.stopJukeboxService()
@ -441,19 +569,12 @@ class MediaPlayerController(
}
fun setVolume(volume: Float) {
if (runningInstance != null) localMediaPlayer.setVolume(volume)
}
private fun updateNotification() {
runningInstance?.updateNotification(
localMediaPlayer.playerState,
localMediaPlayer.currentPlaying
)
controller?.volume = volume
}
fun toggleSongStarred() {
if (localMediaPlayer.currentPlaying == null) return
val song = localMediaPlayer.currentPlaying!!.track
if (legacyPlaylistManager.currentPlaying == null) return
val song = legacyPlaylistManager.currentPlaying!!.track
Thread {
val musicService = getMusicService()
@ -469,15 +590,16 @@ class MediaPlayerController(
}.start()
// Trigger an update
localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
// TODO Update Metadata of MediaItem...
//localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
song.starred = !song.starred
}
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
fun setSongRating(rating: Int) {
if (!Settings.useFiveStarRating) return
if (localMediaPlayer.currentPlaying == null) return
val song = localMediaPlayer.currentPlaying!!.track
if (legacyPlaylistManager.currentPlaying == null) return
val song = legacyPlaylistManager.currentPlaying!!.track
song.userRating = rating
Thread {
try {
@ -487,27 +609,33 @@ class MediaPlayerController(
}
}.start()
// TODO this would be better handled with a Rx command
updateNotification()
//updateNotification()
}
@set:Synchronized
var currentPlaying: DownloadFile?
get() = localMediaPlayer.currentPlaying
set(currentPlaying) {
if (runningInstance != null) localMediaPlayer.setCurrentPlaying(currentPlaying)
}
val currentMediaItem: MediaItem?
get() = controller?.currentMediaItem
val currentMediaItemIndex: Int
get() = controller?.currentMediaItemIndex ?: -1
@Deprecated("Use currentMediaItem")
val currentPlayingLegacy: DownloadFile?
get() = legacyPlaylistManager.currentPlaying
val mediaItemCount: Int
get() = controller?.mediaItemCount ?: 0
@Deprecated("Use mediaItemCount")
val playlistSize: Int
get() = downloader.getPlaylist().size
val currentPlayingNumberOnPlaylist: Int
get() = downloader.currentPlayingIndex
get() = legacyPlaylistManager.playlist.size
@Deprecated("Use native APIs")
val playList: List<DownloadFile>
get() = downloader.getPlaylist()
get() = legacyPlaylistManager.playlist
@Deprecated("Use timeline")
val playListDuration: Long
get() = downloader.downloadListDuration
get() = legacyPlaylistManager.playlistDuration
fun getDownloadFileForSong(song: Track): DownloadFile {
return downloader.getDownloadFileForSong(song)
@ -516,4 +644,30 @@ class MediaPlayerController(
init {
Timber.i("MediaPlayerController constructed")
}
enum class InsertionMode {
CLEAR, APPEND, AFTER_CURRENT
}
}
fun Track.toMediaItem(): MediaItem {
val filePath = FileUtil.getSongFile(this)
val bitrate = Settings.maxBitRate
val uri = "$id|$bitrate|$filePath"
val metadata = MediaMetadata.Builder()
metadata.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.setMediaUri(uri.toUri())
.setAlbumArtist(artist)
val mediaItem = MediaItem.Builder()
.setUri(uri)
.setMediaId(id)
.setMediaMetadata(metadata.build())
return mediaItem.build()
}

View File

@ -8,32 +8,23 @@
package org.moire.ultrasonic.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.view.KeyEvent
import io.reactivex.rxjava3.disposables.Disposable
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util.ifNotNull
import timber.log.Timber
/**
* This class is responsible for handling received events for the Media Player implementation
*
* @author Sindre Mehus
*/
class MediaPlayerLifecycleSupport : KoinComponent {
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
private val mediaPlayerController by inject<MediaPlayerController>()
private val downloader by inject<Downloader>()
private var created = false
private var headsetEventReceiver: BroadcastReceiver? = null
@ -50,11 +41,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
return
}
mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe {
handleKeyEvent(it)
}
registerHeadsetReceiver()
mediaPlayerController.onCreate()
if (autoPlay) mediaPlayerController.preload()
@ -68,13 +54,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
false
)
// Work-around: Serialize again, as the restore() method creates a
// serialization without current playing info.
playbackStateSerializer.serialize(
downloader.getPlaylist(),
downloader.currentPlayingIndex,
mediaPlayerController.playerPosition
)
afterCreated?.run()
}
@ -87,11 +66,12 @@ class MediaPlayerLifecycleSupport : KoinComponent {
if (!created) return
playbackStateSerializer.serializeNow(
downloader.getPlaylist(),
downloader.currentPlayingIndex,
mediaPlayerController.playerPosition
)
// TODO
// playbackStateSerializer.serializeNow(
// downloader.getPlaylist(),
// downloader.currentPlayingIndex,
// mediaPlayerController.playerPosition
// )
mediaPlayerController.clear(false)
mediaButtonEventSubscription?.dispose()
@ -121,73 +101,19 @@ class MediaPlayerLifecycleSupport : KoinComponent {
}
}
/**
* The Headset Intent Receiver is responsible for resuming playback when a headset is inserted
* and pausing it when it is removed.
* Unfortunately this Intent can't be registered in the AndroidManifest, so it works only
* while Ultrasonic is running.
*/
private fun registerHeadsetReceiver() {
val sp = Settings.preferences
val context = applicationContext()
val spKey = context
.getString(R.string.settings_playback_resume_play_on_headphones_plug)
headsetEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val extras = intent.extras ?: return
Timber.i("Headset event for: %s", extras["name"])
val state = extras.getInt("state")
if (state == 0) {
if (!mediaPlayerController.isJukeboxEnabled) {
mediaPlayerController.pause()
}
} else if (state == 1) {
if (!mediaPlayerController.isJukeboxEnabled &&
sp.getBoolean(
spKey,
false
) && mediaPlayerController.playerState === PlayerState.PAUSED
) {
mediaPlayerController.start()
}
}
}
}
val headsetIntentFilter = IntentFilter(AudioManager.ACTION_HEADSET_PLUG)
applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter)
}
@Suppress("MagicNumber", "ComplexMethod")
@Suppress("MagicNumber")
private fun handleKeyEvent(event: KeyEvent) {
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return
val keyCode: Int
val receivedKeyCode = event.keyCode
// Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices
keyCode = if (Settings.singleButtonPlayPause && (
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE
)
) {
Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE")
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
} else receivedKeyCode
val keyCode: Int = event.keyCode
val autoStart =
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
// We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start
onCreate(autoStart) {
@ -197,14 +123,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
KeyEvent.KEYCODE_MEDIA_PLAY ->
if (mediaPlayerController.playerState === PlayerState.IDLE) {
mediaPlayerController.play()
} else if (mediaPlayerController.playerState !== PlayerState.STARTED) {
mediaPlayerController.start()
}
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
@ -221,21 +140,15 @@ class MediaPlayerLifecycleSupport : KoinComponent {
/**
* This function processes the intent that could come from other applications.
*/
@Suppress("ComplexMethod")
private fun handleUltrasonicIntent(intentAction: String) {
val isRunning = created
// If Ultrasonic is not running, do nothing to stop or pause
if (
!isRunning && (
intentAction == Constants.CMD_PAUSE ||
intentAction == Constants.CMD_STOP
)
) return
if (!isRunning && (intentAction == Constants.CMD_PAUSE || intentAction == Constants.CMD_STOP))
return
val autoStart =
intentAction == Constants.CMD_PLAY ||
val autoStart = intentAction == Constants.CMD_PLAY ||
intentAction == Constants.CMD_RESUME_OR_PLAY ||
intentAction == Constants.CMD_TOGGLEPAUSE ||
intentAction == Constants.CMD_PREVIOUS ||
@ -253,12 +166,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
Constants.CMD_NEXT -> mediaPlayerController.next()
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
Constants.CMD_STOP -> {
// TODO: There is a stop() function, shouldn't we use that?
mediaPlayerController.pause()
mediaPlayerController.seekTo(0)
}
Constants.CMD_STOP -> mediaPlayerController.stop()
Constants.CMD_PAUSE -> mediaPlayerController.pause()
}
}

View File

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

View File

@ -53,7 +53,7 @@ class PlaybackStateSerializer : KoinComponent {
}
}
fun serializeNow(
private fun serializeNow(
songs: Iterable<DownloadFile>,
currentPlayingIndex: Int,
currentPlayingPosition: Int

View File

@ -20,11 +20,6 @@ class RxBus {
.replay(1)
.autoConnect(0)
val mediaButtonEventPublisher: PublishSubject<KeyEvent> =
PublishSubject.create()
val mediaButtonEventObservable: Observable<KeyEvent> =
mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread())
val themeChangedEventPublisher: PublishSubject<Unit> =
PublishSubject.create()
val themeChangedEventObservable: Observable<Unit> =
@ -83,7 +78,7 @@ class RxBus {
}
}
data class StateWithTrack(val state: PlayerState, val track: DownloadFile?)
data class StateWithTrack(val state: PlayerState, val track: DownloadFile?, val index: Int = -1)
}
operator fun CompositeDisposable.plusAssign(disposable: Disposable) {

View File

@ -34,20 +34,23 @@ class DownloadHandler(
autoPlay: Boolean,
playNext: Boolean,
shuffle: Boolean,
songs: List<Track?>
songs: List<Track>,
) {
val onValid = Runnable {
if (!append && !playNext) {
mediaPlayerController.clear()
// TODO: The logic here is different than in the controller...
val insertionMode = when {
append -> MediaPlayerController.InsertionMode.APPEND
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
else -> MediaPlayerController.InsertionMode.CLEAR
}
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.addToPlaylist(
songs,
save,
autoPlay,
playNext,
shuffle,
false
insertionMode
)
val playlistName: String? = fragment.arguments?.getString(
Constants.INTENT_PLAYLIST_NAME
@ -281,26 +284,28 @@ class DownloadHandler(
}
}
// Called when we have collected the tracks
override fun done(songs: List<Track>) {
if (Settings.shouldSortByDisc) {
Collections.sort(songs, EntryByDiscAndTrackComparator())
}
if (songs.isNotEmpty()) {
if (!append && !playNext && !unpin && !background) {
mediaPlayerController.clear()
}
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
if (!background) {
if (unpin) {
mediaPlayerController.unpin(songs)
} else {
val insertionMode = when {
append -> MediaPlayerController.InsertionMode.APPEND
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
else -> MediaPlayerController.InsertionMode.CLEAR
}
mediaPlayerController.addToPlaylist(
songs,
save,
autoPlay,
playNext,
shuffle,
false
insertionMode
)
if (
!append &&

View File

@ -233,7 +233,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
) {
if (file.isFile && (isPartial(file) || isComplete(file))) {
files.add(file)
} else {
} else if (file.isDirectory) {
// Depth-first
for (child in listFiles(file)) {
findCandidatesForDeletion(child, files, dirs)
@ -257,7 +257,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
for (downloadFile in downloader.value.all) {
filesToNotDelete.add(downloadFile.partialFile)
filesToNotDelete.add(downloadFile.completeFile)
filesToNotDelete.add(downloadFile.saveFile)
filesToNotDelete.add(downloadFile.pinnedFile)
}
filesToNotDelete.add(musicDirectory.path)

View File

@ -406,7 +406,7 @@ object FileUtil {
return path.substringBeforeLast('/')
}
fun getSaveFile(name: String): String {
fun getPinnedFile(name: String): String {
val baseName = getBaseName(name)
if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) {
return "${getBaseName(baseName)}.${getExtension(name)}"

View File

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

View File

@ -9,13 +9,11 @@ package org.moire.ultrasonic.util
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.preference.PreferenceManager
import java.util.regex.Pattern
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.RepeatMode
/**
* Contains convenience functions for reading and writing preferences
@ -23,43 +21,6 @@ import org.moire.ultrasonic.domain.RepeatMode
object Settings {
private val PATTERN = Pattern.compile(":")
var repeatMode: RepeatMode
get() {
val preferences = preferences
return RepeatMode.valueOf(
preferences.getString(
Constants.PREFERENCES_KEY_REPEAT_MODE,
RepeatMode.OFF.name
)!!
)
}
set(repeatMode) {
val preferences = preferences
val editor = preferences.edit()
editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name)
editor.apply()
}
// After API26 foreground services must be used for music playback,
// and they must have a notification
val isNotificationEnabled: Boolean
get() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true
val preferences = preferences
return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION, false)
}
// After API26 foreground services must be used for music playback,
// and they must have a notification
val isNotificationAlwaysEnabled: Boolean
get() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true
val preferences = preferences
return preferences.getBoolean(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION, false)
}
var isLockScreenEnabled by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS)
@JvmStatic
var theme by StringSetting(
Constants.PREFERENCES_KEY_THEME,

View File

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

View File

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

View File

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

View File

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

View File

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