Merge pull request #413 from tzugen/download

Migrate DownloadFile to Kotlin, add percentage display
This commit is contained in:
Nite 2021-04-18 16:33:00 +02:00 committed by GitHub
commit 4f7da06e26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 582 additions and 754 deletions

View File

@ -320,28 +320,6 @@
column="25"/>
</issue>
<issue
id="MissingDefaultResource"
message="The layout &quot;download&quot; in layout-land has no declaration in the base `layout` folder; this can lead to crashes when the resource is queried in a configuration that does not match this qualifier"
errorLine1="&lt;LinearLayout xmlns:a=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/layout-land/download.xml"
line="2"
column="2"/>
</issue>
<issue
id="MissingDefaultResource"
message="The layout &quot;download&quot; in layout-port has no declaration in the base `layout` folder; this can lead to crashes when the resource is queried in a configuration that does not match this qualifier"
errorLine1="&lt;LinearLayout xmlns:a=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/layout-port/download.xml"
line="2"
column="2"/>
</issue>
<issue
id="SdCardPath"
message="Do not hardcode &quot;/sdcard/&quot;; use `Environment.getExternalStorageDirectory().getPath()` instead"
@ -2430,61 +2408,6 @@
column="4"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1="&lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/media_buttons.xml"
line="12"
column="2"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/media_buttons.xml"
line="32"
column="6"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/media_buttons.xml"
line="42"
column="6"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/media_buttons.xml"
line="52"
column="6"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/media_buttons.xml"
line="72"
column="6"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"

View File

@ -145,7 +145,7 @@ public class PlayerFragment extends Fragment implements GestureDetector.OnGestur
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(R.layout.download, container, false);
return inflater.inflate(R.layout.current_playing, container, false);
}
@Override
@ -168,27 +168,27 @@ public class PlayerFragment extends Fragment implements GestureDetector.OnGestur
swipeVelocity = swipeDistance;
gestureScanner = new GestureDetector(getContext(), this);
playlistFlipper = view.findViewById(R.id.download_playlist_flipper);
emptyTextView = view.findViewById(R.id.download_empty);
songTitleTextView = view.findViewById(R.id.download_song_title);
albumTextView = view.findViewById(R.id.download_album);
artistTextView = view.findViewById(R.id.download_artist);
albumArtImageView = view.findViewById(R.id.download_album_art_image);
positionTextView = view.findViewById(R.id.download_position);
downloadTrackTextView = view.findViewById(R.id.download_track);
downloadTotalDurationTextView = view.findViewById(R.id.download_total_duration);
durationTextView = view.findViewById(R.id.download_duration);
progressBar = view.findViewById(R.id.download_progress_bar);
playlistView = view.findViewById(R.id.download_list);
final AutoRepeatButton previousButton = view.findViewById(R.id.download_previous);
final AutoRepeatButton nextButton = view.findViewById(R.id.download_next);
pauseButton = view.findViewById(R.id.download_pause);
stopButton = view.findViewById(R.id.download_stop);
startButton = view.findViewById(R.id.download_start);
final View shuffleButton = view.findViewById(R.id.download_shuffle);
repeatButton = view.findViewById(R.id.download_repeat);
playlistFlipper = view.findViewById(R.id.current_playing_playlist_flipper);
emptyTextView = view.findViewById(R.id.playlist_empty);
songTitleTextView = view.findViewById(R.id.current_playing_song);
albumTextView = view.findViewById(R.id.current_playing_album);
artistTextView = view.findViewById(R.id.current_playing_artist);
albumArtImageView = view.findViewById(R.id.current_playing_album_art_image);
positionTextView = view.findViewById(R.id.current_playing_position);
downloadTrackTextView = view.findViewById(R.id.current_playing_track);
downloadTotalDurationTextView = view.findViewById(R.id.current_total_duration);
durationTextView = view.findViewById(R.id.current_playing_duration);
progressBar = view.findViewById(R.id.current_playing_progress_bar);
playlistView = view.findViewById(R.id.playlist_view);
final AutoRepeatButton previousButton = view.findViewById(R.id.button_previous);
final AutoRepeatButton nextButton = view.findViewById(R.id.button_next);
pauseButton = view.findViewById(R.id.button_pause);
stopButton = view.findViewById(R.id.button_stop);
startButton = view.findViewById(R.id.button_start);
final View shuffleButton = view.findViewById(R.id.button_shuffle);
repeatButton = view.findViewById(R.id.button_repeat);
visualizerViewLayout = view.findViewById(R.id.download_visualizer_view_layout);
visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout);
LinearLayout ratingLinearLayout = view.findViewById(R.id.song_rating);
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1);
@ -1375,13 +1375,10 @@ public class PlayerFragment extends Fragment implements GestureDetector.OnGestur
switch (playerState)
{
case DOWNLOADING:
final long bytes = currentPlaying != null ? currentPlaying.getPartialFile().length() : 0;
String downloadStatus = getResources().getString(R.string.download_playerstate_downloading, Util.formatLocalizedBytes(bytes, getContext()));
Timber.d("Player set title");
String downloadStatus = getResources().getString(R.string.download_playerstate_downloading, Util.formatPercentage(currentPlaying.getProgress().getValue()));
FragmentTitle.Companion.setTitle(PlayerFragment.this, downloadStatus);
break;
case PREPARING:
Timber.d("Player set title");
FragmentTitle.Companion.setTitle(PlayerFragment.this, R.string.download_playerstate_buffering);
break;
case STARTED:
@ -1389,17 +1386,14 @@ public class PlayerFragment extends Fragment implements GestureDetector.OnGestur
if (mediaPlayerController != null && mediaPlayerController.isShufflePlayEnabled())
{
Timber.d("Player set title");
FragmentTitle.Companion.setTitle(PlayerFragment.this, R.string.download_playerstate_playing_shuffle);
}
else
{
Timber.d("Player set title");
FragmentTitle.Companion.setTitle(PlayerFragment.this, R.string.common_appname);
}
break;
default:
Timber.d("Player set title");
FragmentTitle.Companion.setTitle(PlayerFragment.this, R.string.common_appname);
break;
case IDLE:

View File

@ -321,9 +321,9 @@ public class CachedMusicService implements MusicService
}
@Override
public Pair<InputStream, Boolean> getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception
public Pair<InputStream, Boolean> getDownloadInputStream(MusicDirectory.Entry song, long offset, int maxBitrate) throws Exception
{
return musicService.getDownloadInputStream(context, song, offset, maxBitrate, task);
return musicService.getDownloadInputStream(song, offset, maxBitrate);
}
@Override

View File

@ -1,521 +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.net.wifi.WifiManager;
import android.os.PowerManager;
import android.text.TextUtils;
import timber.log.Timber;
import org.jetbrains.annotations.NotNull;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.util.CacheCleaner;
import org.moire.ultrasonic.util.CancellableTask;
import org.moire.ultrasonic.util.FileUtil;
import org.moire.ultrasonic.util.Util;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import kotlin.Lazy;
import kotlin.Pair;
import static android.content.Context.POWER_SERVICE;
import static android.os.PowerManager.ON_AFTER_RELEASE;
import static android.os.PowerManager.SCREEN_DIM_WAKE_LOCK;
import static org.koin.java.KoinJavaComponent.inject;
/**
* @author Sindre Mehus
* @version $Id$
*/
public class DownloadFile
{
private final Context context;
private final MusicDirectory.Entry song;
private final File partialFile;
private final File completeFile;
private final File saveFile;
private final MediaStoreService mediaStoreService;
private CancellableTask downloadTask;
private final boolean save;
private boolean failed;
private int bitRate;
private volatile boolean isPlaying;
private volatile boolean saveWhenDone;
private volatile boolean completeWhenDone;
private final Lazy<Downloader> downloader = inject(Downloader.class);
public DownloadFile(Context context, MusicDirectory.Entry song, boolean save)
{
super();
this.context = context;
this.song = song;
this.save = save;
saveFile = FileUtil.getSongFile(context, song);
bitRate = Util.getMaxBitRate(context);
partialFile = new File(saveFile.getParent(), String.format("%s.partial.%s", FileUtil.getBaseName(saveFile.getName()), FileUtil.getExtension(saveFile.getName())));
completeFile = new File(saveFile.getParent(), String.format("%s.complete.%s", FileUtil.getBaseName(saveFile.getName()), FileUtil.getExtension(saveFile.getName())));
mediaStoreService = new MediaStoreService(context);
}
public MusicDirectory.Entry getSong()
{
return song;
}
/**
* Returns the effective bit rate.
*/
public int getBitRate()
{
if (!partialFile.exists())
{
bitRate = Util.getMaxBitRate(context);
}
if (bitRate > 0)
{
return bitRate;
}
return song.getBitRate() == null ? 160 : song.getBitRate();
}
public synchronized void download()
{
FileUtil.createDirectoryForParent(saveFile);
failed = false;
if (!partialFile.exists())
{
bitRate = Util.getMaxBitRate(context);
}
downloadTask = new DownloadTask();
downloadTask.start();
}
public synchronized void cancelDownload()
{
if (downloadTask != null)
{
downloadTask.cancel();
}
}
public File getCompleteFile()
{
if (saveFile.exists())
{
return saveFile;
}
if (completeFile.exists())
{
return completeFile;
}
return saveFile;
}
public File getCompleteOrPartialFile() {
if (isCompleteFileAvailable()) {
return getCompleteFile();
} else {
return getPartialFile();
}
}
public File getPartialFile()
{
return partialFile;
}
public boolean isSaved()
{
return saveFile.exists();
}
public synchronized boolean isCompleteFileAvailable()
{
return saveFile.exists() || completeFile.exists();
}
public synchronized boolean isWorkDone()
{
return saveFile.exists() || (completeFile.exists() && !save) || saveWhenDone || completeWhenDone;
}
public synchronized boolean isDownloading()
{
return downloadTask != null && downloadTask.isRunning();
}
public synchronized boolean isDownloadCancelled()
{
return downloadTask != null && downloadTask.isCancelled();
}
public boolean shouldSave()
{
return save;
}
public boolean isFailed()
{
return failed;
}
public void delete()
{
cancelDownload();
Util.delete(partialFile);
Util.delete(completeFile);
Util.delete(saveFile);
mediaStoreService.deleteFromMediaStore(this);
}
public void unpin()
{
if (saveFile.exists())
{
if (!saveFile.renameTo(completeFile)){
Timber.w("Renaming file failed. Original file: %s; Rename to: %s", saveFile.getName(), completeFile.getName());
}
}
}
public boolean cleanup()
{
boolean ok = true;
if (completeFile.exists() || saveFile.exists())
{
ok = Util.delete(partialFile);
}
if (saveFile.exists())
{
ok &= Util.delete(completeFile);
}
return ok;
}
// In support of LRU caching.
public void updateModificationDate()
{
updateModificationDate(saveFile);
updateModificationDate(partialFile);
updateModificationDate(completeFile);
}
private static void updateModificationDate(File file)
{
if (file.exists())
{
boolean ok = file.setLastModified(System.currentTimeMillis());
if (!ok)
{
Timber.i("Failed to set last-modified date on %s, trying alternate method", file);
try
{
// Try alternate method to update last modified date to current time
// Found at https://code.google.com/p/android/issues/detail?id=18624
RandomAccessFile raf = new RandomAccessFile(file, "rw");
long length = raf.length();
raf.setLength(length + 1);
raf.setLength(length);
raf.close();
}
catch (Exception e)
{
Timber.w("Failed to set last-modified date on %s", file);
}
}
}
}
public void setPlaying(boolean isPlaying)
{
try
{
if (saveWhenDone && !isPlaying)
{
Util.renameFile(completeFile, saveFile);
saveWhenDone = false;
}
else if (completeWhenDone && !isPlaying)
{
if (save)
{
Util.renameFile(partialFile, saveFile);
mediaStoreService.saveInMediaStore(DownloadFile.this);
}
else
{
Util.renameFile(partialFile, completeFile);
}
completeWhenDone = false;
}
}
catch (IOException ex)
{
Timber.w("Failed to rename file %s to %s", completeFile, saveFile);
}
this.isPlaying = isPlaying;
}
@NotNull
@Override
public String toString()
{
return String.format("DownloadFile (%s)", song);
}
private class DownloadTask extends CancellableTask
{
@Override
public void execute()
{
InputStream in = null;
FileOutputStream out = null;
PowerManager.WakeLock wakeLock = null;
WifiManager.WifiLock wifiLock = null;
try
{
if (Util.isScreenLitOnDownload(context))
{
PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE);
wakeLock = pm.newWakeLock(SCREEN_DIM_WAKE_LOCK | ON_AFTER_RELEASE, toString());
wakeLock.acquire(10*60*1000L /*10 minutes*/);
Timber.i("Acquired wake lock %s", wakeLock);
}
wifiLock = Util.createWifiLock(context, toString());
wifiLock.acquire();
if (saveFile.exists())
{
Timber.i("%s already exists. Skipping.", saveFile);
return;
}
if (completeFile.exists())
{
if (save)
{
if (isPlaying)
{
saveWhenDone = true;
}
else
{
Util.renameFile(completeFile, saveFile);
}
}
else
{
Timber.i("%s already exists. Skipping.", completeFile);
}
return;
}
MusicService musicService = MusicServiceFactory.getMusicService(context);
// Some devices seem to throw error on partial file which doesn't exist
boolean compare;
Integer duration = song.getDuration();
long fileLength = 0;
if (!partialFile.exists())
{
fileLength = partialFile.length();
}
try
{
compare = (bitRate == 0) || (duration == null || duration == 0) || (fileLength == 0);
//(bitRate * song.getDuration() * 1000 / 8) > partialFile.length();
}
catch (Exception e)
{
compare = true;
}
if (compare)
{
// Attempt partial HTTP GET, appending to the file if it exists.
Pair<InputStream, Boolean> response = musicService
.getDownloadInputStream(context, song, partialFile.length(), bitRate,
DownloadTask.this);
if (response.getSecond())
{
Timber.i("Executed partial HTTP GET, skipping %d bytes", partialFile.length());
}
out = new FileOutputStream(partialFile, response.getSecond());
long n = copy(response.getFirst(), out);
Timber.i("Downloaded %d bytes to %s", n, partialFile);
out.flush();
out.close();
if (isCancelled())
{
throw new Exception(String.format("Download of '%s' was cancelled", song));
}
downloadAndSaveCoverArt(musicService);
}
if (isPlaying)
{
completeWhenDone = true;
}
else
{
if (save)
{
Util.renameFile(partialFile, saveFile);
mediaStoreService.saveInMediaStore(DownloadFile.this);
}
else
{
Util.renameFile(partialFile, completeFile);
}
}
}
catch (Exception x)
{
Util.close(out);
Util.delete(completeFile);
Util.delete(saveFile);
if (!isCancelled())
{
failed = true;
Timber.w(x, "Failed to download '%s'.", song);
}
}
finally
{
Util.close(in);
Util.close(out);
if (wakeLock != null)
{
wakeLock.release();
Timber.i("Released wake lock %s", wakeLock);
}
if (wifiLock != null)
{
wifiLock.release();
}
new CacheCleaner(context).cleanSpace();
downloader.getValue().checkDownloads();
}
}
@NotNull
@Override
public String toString()
{
return String.format("DownloadTask (%s)", song);
}
private void downloadAndSaveCoverArt(MusicService musicService)
{
try
{
if (!TextUtils.isEmpty(song.getCoverArt())) {
int size = Util.getMinDisplayMetric(context);
musicService.getCoverArt(context, song, size, true, true);
}
}
catch (Exception x)
{
Timber.e(x, "Failed to get cover art.");
}
}
private long copy(final InputStream in, OutputStream out) throws IOException
{
// Start a thread that will close the input stream if the task is
// cancelled, thus causing the copy() method to return.
new Thread()
{
@Override
public void run()
{
while (true)
{
Util.sleepQuietly(3000L);
if (isCancelled())
{
Util.close(in);
return;
}
if (!isRunning())
{
return;
}
}
}
}.start();
byte[] buffer = new byte[1024 * 16];
long count = 0;
int n;
long lastLog = System.currentTimeMillis();
while (!isCancelled() && (n = in.read(buffer)) != -1)
{
out.write(buffer, 0, n);
count += n;
long now = System.currentTimeMillis();
if (now - lastLog > 3000L)
{ // Only every so often.
Timber.i("Downloaded %s of %s", Util.formatBytes(count), song);
lastLog = now;
}
}
return count;
}
}
}

View File

@ -108,7 +108,7 @@ public interface MusicService
* Return response {@link InputStream} and a {@link Boolean} that indicates if this response is
* partial.
*/
Pair<InputStream, Boolean> getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception;
Pair<InputStream, Boolean> getDownloadInputStream(MusicDirectory.Entry song, long offset, int maxBitrate) throws Exception;
// TODO: Refactor and remove this call (see RestMusicService implementation)
String getVideoUrl(Context context, String id, boolean useFlash) throws Exception;

View File

@ -893,7 +893,7 @@ public class OfflineMusicService implements MusicService
}
@Override
public Pair<InputStream, Boolean> getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) {
public Pair<InputStream, Boolean> getDownloadInputStream(MusicDirectory.Entry song, long offset, int maxBitrate) {
Timber.w("OfflineMusicService.getDownloadInputStream was called but it isn't available");
return null;
}

View File

@ -229,7 +229,7 @@ public class CacheCleaner
for (DownloadFile downloadFile : downloader.getValue().getDownloads())
{
filesToNotDelete.add(downloadFile.getPartialFile());
filesToNotDelete.add(downloadFile.getCompleteFile());
filesToNotDelete.add(downloadFile.getCompleteOrSaveFile());
}
filesToNotDelete.add(FileUtil.getMusicDirectory(context));

View File

@ -549,6 +549,28 @@ public class FileUtil
return index == -1 ? name : name.substring(0, index);
}
/**
* Returns the file name of a .partial file of the given file.
*
* @param name The filename in question.
* @return The .partial file name
*/
public static String getPartialFile(String name)
{
return String.format("%s.partial.%s", FileUtil.getBaseName(name), FileUtil.getExtension(name));
}
/**
* Returns the file name of a .complete file of the given file.
*
* @param name The filename in question.
* @return The .complete file name
*/
public static String getCompleteFile(String name)
{
return String.format("%s.complete.%s", FileUtil.getBaseName(name), FileUtil.getExtension(name));
}
public static <T extends Serializable> boolean serialize(Context context, T obj, String fileName)
{
File file = new File(context.getCacheDir(), fileName);

View File

@ -194,7 +194,7 @@ public class StreamProxy implements Runnable
while (isRunning && !client.isClosed())
{
// See if there's more to send
File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteFile() : downloadFile.getPartialFile();
File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile();
int cbSentThisBatch = 0;
if (file.exists())

View File

@ -343,6 +343,23 @@ public class Util
toast.show();
}
/**
* Formats an Int to a percentage string
* For instance:
* <ul>
* <li><code>format(99)</code> returns <em>"99 %"</em>.</li>
* </ul>
*
* @param percent The percent as a range from 0 - 100
* @return The formatted string.
*/
public static synchronized String formatPercentage(int percent)
{
return Math.min(Math.max(percent,0),100) + " %";
}
/**
* Converts a byte-count to a formatted string suitable for display to the user.
* For instance:

View File

@ -0,0 +1,377 @@
/*
* DownloadFile.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.net.wifi.WifiManager.WifiLock
import android.os.PowerManager
import android.os.PowerManager.WakeLock
import android.text.TextUtils
import androidx.lifecycle.MutableLiveData
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.RandomAccessFile
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* This class represents a singe Song or Video that can be downloaded.
*
* @author Sindre Mehus
* @version $Id$
*/
class DownloadFile(
private val context: Context,
val song: MusicDirectory.Entry,
private val save: Boolean
) {
val partialFile: File
val completeFile: File
private val saveFile: File = FileUtil.getSongFile(context, song)
private val mediaStoreService: MediaStoreService
private var downloadTask: CancellableTask? = null
var isFailed = false
private val desiredBitRate: Int = Util.getMaxBitRate(context)
@Volatile
private var isPlaying = false
@Volatile
private var saveWhenDone = false
@Volatile
private var completeWhenDone = false
private val downloader = inject(Downloader::class.java)
val progress: MutableLiveData<Int> = MutableLiveData(0)
init {
partialFile = File(saveFile.parent, FileUtil.getPartialFile(saveFile.name))
completeFile = File(saveFile.parent, FileUtil.getCompleteFile(saveFile.name))
mediaStoreService = MediaStoreService(context)
}
/**
* Returns the effective bit rate.
*/
fun getBitRate(): Int {
return if (song.bitRate == null) desiredBitRate else song.bitRate!!
}
@Synchronized
fun download() {
FileUtil.createDirectoryForParent(saveFile)
isFailed = false
downloadTask = DownloadTask()
downloadTask!!.start()
}
@Synchronized
fun cancelDownload() {
if (downloadTask != null) {
downloadTask!!.cancel()
}
}
val completeOrSaveFile: File
get() = if (saveFile.exists()) {
saveFile
} else {
completeFile
}
val completeOrPartialFile: File
get() = if (isCompleteFileAvailable) {
completeOrSaveFile
} else {
partialFile
}
val isSaved: Boolean
get() = saveFile.exists()
@get:Synchronized
val isCompleteFileAvailable: Boolean
get() = saveFile.exists() || completeFile.exists()
@get:Synchronized
val isWorkDone: Boolean
get() = saveFile.exists() || completeFile.exists() && !save ||
saveWhenDone || completeWhenDone
@get:Synchronized
val isDownloading: Boolean
get() = downloadTask != null && downloadTask!!.isRunning
@get:Synchronized
val isDownloadCancelled: Boolean
get() = downloadTask != null && downloadTask!!.isCancelled
fun shouldSave(): Boolean {
return save
}
fun delete() {
cancelDownload()
Util.delete(partialFile)
Util.delete(completeFile)
Util.delete(saveFile)
mediaStoreService.deleteFromMediaStore(this)
}
fun unpin() {
if (saveFile.exists()) {
if (!saveFile.renameTo(completeFile)) {
Timber.w(
"Renaming file failed. Original file: %s; Rename to: %s",
saveFile.name, completeFile.name
)
}
}
}
fun cleanup(): Boolean {
var ok = true
if (completeFile.exists() || saveFile.exists()) {
ok = Util.delete(partialFile)
}
if (saveFile.exists()) {
ok = ok and Util.delete(completeFile)
}
return ok
}
// In support of LRU caching.
fun updateModificationDate() {
updateModificationDate(saveFile)
updateModificationDate(partialFile)
updateModificationDate(completeFile)
}
fun setPlaying(isPlaying: Boolean) {
if (!isPlaying) doPendingRename()
this.isPlaying = isPlaying
}
// Do a pending rename after the song has stopped playing
private fun doPendingRename() {
try {
if (saveWhenDone) {
Util.renameFile(completeFile, saveFile)
saveWhenDone = false
} else if (completeWhenDone) {
if (save) {
Util.renameFile(partialFile, saveFile)
mediaStoreService.saveInMediaStore(this@DownloadFile)
} else {
Util.renameFile(partialFile, completeFile)
}
completeWhenDone = false
}
} catch (ex: IOException) {
Timber.w("Failed to rename file %s to %s", completeFile, saveFile)
}
}
override fun toString(): String {
return String.format("DownloadFile (%s)", song)
}
private inner class DownloadTask : CancellableTask() {
override fun execute() {
var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null
var wakeLock: WakeLock? = null
var wifiLock: WifiLock? = null
try {
wakeLock = acquireWakeLock(wakeLock)
wifiLock = Util.createWifiLock(context, toString())
wifiLock.acquire()
if (saveFile.exists()) {
Timber.i("%s already exists. Skipping.", saveFile)
return
}
if (completeFile.exists()) {
if (save) {
if (isPlaying) {
saveWhenDone = true
} else {
Util.renameFile(completeFile, saveFile)
}
} else {
Timber.i("%s already exists. Skipping.", completeFile)
}
return
}
val musicService = getMusicService(context)
// Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean
val duration = song.duration
var fileLength: Long = 0
if (!partialFile.exists()) {
fileLength = partialFile.length()
}
needsDownloading = (
desiredBitRate == 0 || duration == null ||
duration == 0 || fileLength == 0L
)
if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, partial) = musicService
.getDownloadInputStream(song, partialFile.length(), desiredBitRate)
inputStream = inStream
if (partial) {
Timber.i(
"Executed partial HTTP GET, skipping %d bytes",
partialFile.length()
)
}
outputStream = FileOutputStream(partialFile, partial)
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) {
throw Exception(String.format("Download of '%s' was cancelled", song))
}
downloadAndSaveCoverArt(musicService)
}
if (isPlaying) {
completeWhenDone = true
} else {
if (save) {
Util.renameFile(partialFile, saveFile)
mediaStoreService.saveInMediaStore(this@DownloadFile)
} else {
Util.renameFile(partialFile, completeFile)
}
}
} catch (x: Exception) {
Util.close(outputStream)
Util.delete(completeFile)
Util.delete(saveFile)
if (!isCancelled) {
isFailed = true
Timber.w(x, "Failed to download '%s'.", song)
}
} finally {
Util.close(inputStream)
Util.close(outputStream)
if (wakeLock != null) {
wakeLock.release()
Timber.i("Released wake lock %s", wakeLock)
}
wifiLock?.release()
CacheCleaner(context).cleanSpace()
downloader.value.checkDownloads()
}
}
private fun acquireWakeLock(wakeLock: WakeLock?): WakeLock? {
var wakeLock1 = wakeLock
if (Util.isScreenLitOnDownload(context)) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val flags = PowerManager.SCREEN_DIM_WAKE_LOCK or PowerManager.ON_AFTER_RELEASE
wakeLock1 = pm.newWakeLock(flags, toString())
wakeLock1.acquire(10 * 60 * 1000L /*10 minutes*/)
Timber.i("Acquired wake lock %s", wakeLock1)
}
return wakeLock1
}
override fun toString(): String {
return String.format("DownloadTask (%s)", song)
}
private fun downloadAndSaveCoverArt(musicService: MusicService) {
try {
if (!TextUtils.isEmpty(song.coverArt)) {
val size = Util.getMinDisplayMetric(context)
musicService.getCoverArt(context, song, size, true, true)
}
} catch (x: Exception) {
Timber.e(x, "Failed to get cover art.")
}
}
@Throws(IOException::class)
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
while (!isCancelled && bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
onCopy(bytesCopied)
bytes = read(buffer)
}
return bytesCopied
}
}
private fun setProgress(totalBytesCopied: Long) {
if (song.size != null) {
progress.postValue((totalBytesCopied * 100 / song.size!!).toInt())
}
}
private fun updateModificationDate(file: File) {
if (file.exists()) {
val ok = file.setLastModified(System.currentTimeMillis())
if (!ok) {
Timber.i(
"Failed to set last-modified date on %s, trying alternate method",
file
)
try {
// Try alternate method to update last modified date to current time
// Found at https://code.google.com/p/android/issues/detail?id=18624
// According to the bug, this was fixed in Android 8.0 (API 26)
val raf = RandomAccessFile(file, "rw")
val length = raf.length()
raf.setLength(length + 1)
raf.setLength(length)
raf.close()
} catch (e: Exception) {
Timber.w("Failed to set last-modified date on %s", file)
}
}
}
}
}

View File

@ -1,3 +1,10 @@
/*
* LocalMediaPlayer.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.app.PendingIntent
@ -755,7 +762,7 @@ class LocalMediaPlayer(
}
// Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to.
val bitRate = downloadFile.bitRate
val bitRate = downloadFile.getBitRate()
val byteCount = max(100000, bitRate * 1024L / 8L * bufferLength)
// Find out how large the file should grow before resuming playback.

View File

@ -59,7 +59,6 @@ import org.moire.ultrasonic.domain.toDomainEntitiesList
import org.moire.ultrasonic.domain.toDomainEntity
import org.moire.ultrasonic.domain.toDomainEntityList
import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Util
import timber.log.Timber
@ -611,11 +610,9 @@ open class RESTMusicService(
@Throws(Exception::class)
override fun getDownloadInputStream(
context: Context,
song: MusicDirectory.Entry,
offset: Long,
maxBitrate: Int,
task: CancellableTask
maxBitrate: Int
): Pair<InputStream, Boolean> {
val songOffset = if (offset < 0) 0 else offset

View File

@ -225,59 +225,7 @@ class SongView(context: Context) : UpdateView(context), Checkable {
downloadFile = mediaPlayerControllerLazy.value.getDownloadFileForSong(entry)
val partialFile = downloadFile!!.partialFile
if (downloadFile!!.isWorkDone) {
val newLeftImageType =
if (downloadFile!!.isSaved) ImageType.Pin else ImageType.Downloaded
if (leftImageType != newLeftImageType) {
leftImage = if (downloadFile!!.isSaved) pinImage else downloadedImage
leftImageType = newLeftImageType
}
} else {
leftImageType = ImageType.None
leftImage = null
}
val rightImageType: ImageType
val rightImage: Drawable?
if (
downloadFile!!.isDownloading &&
!downloadFile!!.isDownloadCancelled &&
partialFile.exists()
) {
viewHolder?.status?.text = Util.formatLocalizedBytes(
partialFile.length(), this.context
)
rightImageType = ImageType.Downloading
rightImage = downloadingImage
} else {
rightImageType = ImageType.None
rightImage = null
val statusText = viewHolder?.status?.text
if (!statusText.isNullOrEmpty()) viewHolder?.status?.text = null
}
if (previousLeftImageType != leftImageType || previousRightImageType != rightImageType) {
previousLeftImageType = leftImageType
previousRightImageType = rightImageType
if (viewHolder?.status != null) {
viewHolder?.status?.setCompoundDrawablesWithIntrinsicBounds(
leftImage, null, rightImage, null
)
if (rightImage === downloadingImage) {
val frameAnimation = rightImage as AnimationDrawable?
frameAnimation!!.setVisible(true, true)
frameAnimation.start()
}
}
}
updateDownloadStatus(downloadFile!!)
if (entry?.starred != true) {
if (viewHolder?.star?.drawable !== starHollowDrawable) {
@ -325,6 +273,56 @@ class SongView(context: Context) : UpdateView(context), Checkable {
}
}
private fun updateDownloadStatus(downloadFile: DownloadFile) {
if (downloadFile.isWorkDone) {
val newLeftImageType =
if (downloadFile.isSaved) ImageType.Pin else ImageType.Downloaded
if (leftImageType != newLeftImageType) {
leftImage = if (downloadFile.isSaved) pinImage else downloadedImage
leftImageType = newLeftImageType
}
} else {
leftImageType = ImageType.None
leftImage = null
}
val rightImageType: ImageType
val rightImage: Drawable?
if (downloadFile.isDownloading && !downloadFile.isDownloadCancelled) {
viewHolder?.status?.text = Util.formatPercentage(downloadFile.progress.value!!)
rightImageType = ImageType.Downloading
rightImage = downloadingImage
} else {
rightImageType = ImageType.None
rightImage = null
val statusText = viewHolder?.status?.text
if (!statusText.isNullOrEmpty()) viewHolder?.status?.text = null
}
if (previousLeftImageType != leftImageType || previousRightImageType != rightImageType) {
previousLeftImageType = leftImageType
previousRightImageType = rightImageType
if (viewHolder?.status != null) {
viewHolder?.status?.setCompoundDrawablesWithIntrinsicBounds(
leftImage, null, rightImage, null
)
if (rightImage === downloadingImage) {
val frameAnimation = rightImage as AnimationDrawable?
frameAnimation!!.setVisible(true, true)
frameAnimation.start()
}
}
}
}
override fun setChecked(b: Boolean) {
viewHolder?.check?.isChecked = b
}

View File

@ -5,13 +5,13 @@
a:orientation="horizontal">
<org.moire.ultrasonic.util.MyViewFlipper
a:id="@+id/download_playlist_flipper"
a:id="@+id/current_playing_playlist_flipper"
a:layout_width="0dp"
a:layout_height="fill_parent"
a:layout_weight="1">
<FrameLayout
a:id="@+id/download_album_art_layout"
a:id="@+id/current_playing_album_art_layout"
a:layout_width="fill_parent"
a:layout_height="fill_parent"
a:layout_weight="1"
@ -19,7 +19,7 @@
a:orientation="horizontal">
<ImageView
a:id="@+id/download_album_art_image"
a:id="@+id/current_playing_album_art_image"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:alpha="0.2"
@ -106,7 +106,7 @@
</LinearLayout>
<LinearLayout
a:id="@+id/download_visualizer_view_layout"
a:id="@+id/current_playing_visualizer_layout"
a:layout_width="fill_parent"
a:layout_height="60dip"
a:layout_gravity="bottom|center_horizontal"
@ -121,7 +121,7 @@
</LinearLayout>
</FrameLayout>
<include layout="@layout/download_playlist"/>
<include layout="@layout/current_playlist"/>
</org.moire.ultrasonic.util.MyViewFlipper>
</LinearLayout>

View File

@ -5,13 +5,13 @@
a:orientation="vertical" >
<org.moire.ultrasonic.util.MyViewFlipper
a:id="@+id/download_playlist_flipper"
a:id="@+id/current_playing_playlist_flipper"
a:layout_width="fill_parent"
a:layout_height="0dip"
a:layout_weight="1" >
<RelativeLayout
a:id="@+id/download_album_art_layout"
a:id="@+id/current_playing_album_art_layout"
a:layout_width="fill_parent"
a:layout_height="fill_parent"
a:layout_weight="1"
@ -19,7 +19,7 @@
a:orientation="vertical" >
<ImageView
a:id="@+id/download_album_art_image"
a:id="@+id/current_playing_album_art_image"
a:layout_width="match_parent"
a:layout_height="match_parent"
a:scaleType="centerCrop"
@ -105,7 +105,7 @@
</LinearLayout>
<LinearLayout
a:id="@+id/download_visualizer_view_layout"
a:id="@+id/current_playing_visualizer_layout"
a:layout_width="fill_parent"
a:layout_height="60dip"
a:layout_gravity="center"
@ -118,7 +118,7 @@
</LinearLayout>
</RelativeLayout>
<include layout="@layout/download_playlist" />
<include layout="@layout/current_playlist" />
</org.moire.ultrasonic.util.MyViewFlipper>
<include layout="@layout/player_media_info" />

View File

@ -1,33 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:a="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
a:orientation="vertical"
a:layout_width="fill_parent"
a:layout_height="fill_parent"
a:layout_weight="1">
<TextView
a:id="@+id/download_empty"
a:text="@string/download.empty"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:padding="10dip"/>
<com.mobeta.android.dslv.DragSortListView
a:id="@+id/download_list"
a:layout_width="fill_parent"
a:layout_height="0dip"
a:layout_weight="1"
a:fastScrollEnabled="true"
a:textFilterEnabled="true"
app:drag_handle_id="@+id/song_drag"
app:remove_enabled="true"
app:remove_mode="flingRemove"
app:fling_handle_id="@+id/song_drag"
app:drag_start_mode="onMove"
app:float_background_color="?attr/color_background"
app:float_alpha="0.7" />
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:a="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
a:orientation="vertical"
a:layout_width="fill_parent"
a:layout_height="fill_parent"
a:layout_weight="1">
<TextView
a:id="@+id/playlist_empty"
a:text="@string/download.empty"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:padding="10dip"/>
<com.mobeta.android.dslv.DragSortListView
a:id="@+id/playlist_view"
a:layout_width="fill_parent"
a:layout_height="0dip"
a:layout_weight="1"
a:fastScrollEnabled="true"
a:textFilterEnabled="true"
app:drag_handle_id="@+id/song_drag"
app:remove_enabled="true"
app:remove_mode="flingRemove"
app:fling_handle_id="@+id/song_drag"
app:drag_start_mode="onMove"
app:float_background_color="?attr/color_background"
app:float_alpha="0.7" />
</LinearLayout>

View File

@ -8,7 +8,7 @@
a:layout_marginRight="12dp" >
<ImageView
a:id="@+id/download_shuffle"
a:id="@+id/button_shuffle"
a:layout_width="0dip"
a:layout_height="26dp"
a:layout_alignParentLeft="true"
@ -17,10 +17,11 @@
a:adjustViewBounds="true"
a:focusable="true"
a:scaleType="fitCenter"
a:src="?attr/media_shuffle" />
a:src="?attr/media_shuffle"
a:contentDescription="@string/buttons.shuffle" />
<org.moire.ultrasonic.view.AutoRepeatButton
a:id="@+id/download_previous"
a:id="@+id/button_previous"
a:layout_width="0dip"
a:layout_height="42dp"
a:layout_gravity="center"
@ -28,10 +29,11 @@
a:adjustViewBounds="true"
a:focusable="true"
a:scaleType="fitCenter"
a:src="?attr/media_previous" />
a:src="?attr/media_previous"
a:contentDescription="@string/buttons.previous" />
<ImageView
a:id="@+id/download_start"
a:id="@+id/button_start"
a:layout_width="0dip"
a:layout_height="74dp"
a:layout_weight="2"
@ -39,20 +41,22 @@
a:focusable="true"
a:scaleType="fitCenter"
a:src="?attr/media_play"
tools:visibility="gone" />
tools:visibility="gone"
a:contentDescription="@string/buttons.play" />
<ImageView
a:id="@+id/download_pause"
a:id="@+id/button_pause"
a:layout_width="0dip"
a:layout_height="74dp"
a:layout_weight="2"
a:adjustViewBounds="true"
a:focusable="true"
a:scaleType="fitCenter"
a:src="?attr/media_pause" />
a:src="?attr/media_pause"
a:contentDescription="@string/buttons.pause" />
<ImageView
a:id="@+id/download_stop"
a:id="@+id/button_stop"
a:layout_width="0dip"
a:layout_height="74dp"
a:layout_weight="2"
@ -60,10 +64,11 @@
a:focusable="true"
a:scaleType="fitCenter"
a:src="?attr/media_stop"
tools:visibility="gone" />
tools:visibility="gone"
a:contentDescription="@string/buttons.stop" />
<org.moire.ultrasonic.view.AutoRepeatButton
a:id="@+id/download_next"
a:id="@+id/button_next"
a:layout_width="0dip"
a:layout_height="42dp"
a:layout_gravity="center"
@ -71,10 +76,11 @@
a:adjustViewBounds="true"
a:focusable="true"
a:scaleType="fitCenter"
a:src="?attr/media_next" />
a:src="?attr/media_next"
a:contentDescription="@string/buttons.next"/>
<ImageView
a:id="@+id/download_repeat"
a:id="@+id/button_repeat"
a:layout_width="0dip"
a:layout_height="26dp"
a:layout_gravity="center"
@ -82,6 +88,7 @@
a:adjustViewBounds="true"
a:focusable="true"
a:scaleType="fitCenter"
a:src="?attr/media_repeat_off" />
a:src="?attr/media_repeat_off"
a:contentDescription="@string/buttons.repeat" />
</LinearLayout>

View File

@ -15,7 +15,7 @@
a:orientation="vertical">
<TextView
a:id="@+id/download_song_title"
a:id="@+id/current_playing_song"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:ellipsize="start"
@ -26,7 +26,7 @@
tools:text="Title" />
<TextView
a:id="@+id/download_artist"
a:id="@+id/current_playing_artist"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:ellipsize="start"
@ -36,7 +36,7 @@
tools:text="Artist" />
<TextView
a:id="@+id/download_album"
a:id="@+id/current_playing_album"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:ellipsize="start"
@ -56,7 +56,7 @@
a:orientation="vertical">
<TextView
a:id="@+id/download_track"
a:id="@+id/current_playing_track"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:ellipsize="start"
@ -65,7 +65,7 @@
a:textAppearance="?android:attr/textAppearanceSmall" />
<TextView
a:id="@+id/download_total_duration"
a:id="@+id/current_total_duration"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:ellipsize="start"

View File

@ -8,7 +8,7 @@
a:layout_marginRight="12dp" >
<SeekBar
a:id="@+id/download_progress_bar"
a:id="@+id/current_playing_progress_bar"
a:layout_width="fill_parent"
a:layout_height="32dp"
a:indeterminate="false" />
@ -18,7 +18,7 @@
a:layout_height="match_parent">
<TextView
a:id="@+id/download_position"
a:id="@+id/current_playing_position"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:layout_alignParentLeft="true"
@ -27,7 +27,7 @@
a:textAppearance="?android:attr/textAppearanceSmall" />
<TextView
a:id="@+id/download_duration"
a:id="@+id/current_playing_duration"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:layout_alignParentRight="true"

View File

@ -15,6 +15,13 @@
<string name="button_bar.chat">Chat</string>
<string name="button_bar.home">Ultrasonic Main</string>
<string name="button_bar.now_playing">Now Playing</string>
<string name="buttons.play">Play</string>
<string name="buttons.pause">Pause</string>
<string name="buttons.repeat">Repeat</string>
<string name="buttons.shuffle">Shuffle</string>
<string name="buttons.stop">Stop</string>
<string name="buttons.next">Next</string>
<string name="buttons.previous">Previous</string>
<string name="podcasts.label">Podcast</string>
<string name="podcasts_channels.empty">No podcasts channels registered</string>
<string name="button_bar.podcasts">Podcast</string>