Merge pull request #583 from ultrasonic/download-metadata

Download metadata
This commit is contained in:
tzugen 2021-10-04 15:06:21 +02:00 committed by GitHub
commit a7a895af96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 799 additions and 911 deletions

View File

@ -70,7 +70,7 @@ style:
excludeImportStatements: false
MagicNumber:
# 100 common in percentage, 1000 in milliseconds
ignoreNumbers: ['-1', '0', '1', '2', '100', '1000']
ignoreNumbers: ['-1', '0', '1', '2', '10', '100', '256', '512', '1000', '1024']
ignoreEnums: true
ignorePropertyDeclaration: true
UnnecessaryAbstractClass:

View File

@ -18,11 +18,11 @@
*/
package org.moire.ultrasonic.util;
import timber.log.Timber;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import timber.log.Timber;
/**
* @author Sindre Mehus
* @version $Id$
@ -93,7 +93,7 @@ public abstract class CancellableTask
thread.get().start();
}
public static interface OnCancelListener
public interface OnCancelListener
{
void onCancel();
}

View File

@ -1,151 +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;
/**
* @author Sindre Mehus
* @version $Id$
*/
public final class Constants
{
// Character encoding used throughout.
public static final String UTF_8 = "UTF-8";
// REST protocol version and client ID.
// Note: Keep it as low as possible to maintain compatibility with older servers.
public static final String REST_PROTOCOL_VERSION = "1.7.0";
public static final String REST_CLIENT_ID = "Ultrasonic";
// Names for intent extras.
public static final String INTENT_EXTRA_NAME_ID = "subsonic.id";
public static final String INTENT_EXTRA_NAME_NAME = "subsonic.name";
public static final String INTENT_EXTRA_NAME_ARTIST = "subsonic.artist";
public static final String INTENT_EXTRA_NAME_TITLE = "subsonic.title";
public static final String INTENT_EXTRA_NAME_AUTOPLAY = "subsonic.playall";
public static final String INTENT_EXTRA_NAME_QUERY = "subsonic.query";
public static final String INTENT_EXTRA_NAME_PLAYLIST_ID = "subsonic.playlist.id";
public static final String INTENT_EXTRA_NAME_PODCAST_CHANNEL_ID = "subsonic.podcastChannel.id";
public static final String INTENT_EXTRA_NAME_PARENT_ID = "subsonic.parent.id";
public static final String INTENT_EXTRA_NAME_PLAYLIST_NAME = "subsonic.playlist.name";
public static final String INTENT_EXTRA_NAME_SHARE_ID = "subsonic.share.id";
public static final String INTENT_EXTRA_NAME_SHARE_NAME = "subsonic.share.name";
public static final String INTENT_EXTRA_NAME_ALBUM_LIST_TYPE = "subsonic.albumlisttype";
public static final String INTENT_EXTRA_NAME_ALBUM_LIST_TITLE = "subsonic.albumlisttitle";
public static final String INTENT_EXTRA_NAME_ALBUM_LIST_SIZE = "subsonic.albumlistsize";
public static final String INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET = "subsonic.albumlistoffset";
public static final String INTENT_EXTRA_NAME_SHUFFLE = "subsonic.shuffle";
public static final String INTENT_EXTRA_NAME_REFRESH = "subsonic.refresh";
public static final String INTENT_EXTRA_NAME_STARRED = "subsonic.starred";
public static final String INTENT_EXTRA_NAME_RANDOM = "subsonic.random";
public static final String INTENT_EXTRA_NAME_GENRE_NAME = "subsonic.genre";
public static final String INTENT_EXTRA_NAME_IS_ALBUM = "subsonic.isalbum";
public static final String INTENT_EXTRA_NAME_VIDEOS = "subsonic.videos";
public static final String INTENT_EXTRA_NAME_SHOW_PLAYER = "subsonic.showplayer";
public static final String INTENT_EXTRA_NAME_APPEND = "subsonic.append";
// Names for Intent Actions
public static final String CMD_PROCESS_KEYCODE = "org.moire.ultrasonic.CMD_PROCESS_KEYCODE";
public static final String CMD_PLAY = "org.moire.ultrasonic.CMD_PLAY";
public static final String CMD_RESUME_OR_PLAY = "org.moire.ultrasonic.CMD_RESUME_OR_PLAY";
public static final String CMD_TOGGLEPAUSE = "org.moire.ultrasonic.CMD_TOGGLEPAUSE";
public static final String CMD_PAUSE = "org.moire.ultrasonic.CMD_PAUSE";
public static final String CMD_STOP = "org.moire.ultrasonic.CMD_STOP";
public static final String CMD_PREVIOUS = "org.moire.ultrasonic.CMD_PREVIOUS";
public static final String CMD_NEXT = "org.moire.ultrasonic.CMD_NEXT";
// Preferences keys.
public static final String PREFERENCES_KEY_SERVER_INSTANCE = "serverInstanceId";
public static final String PREFERENCES_KEY_SERVERS_EDIT = "editServers";
public static final String PREFERENCES_KEY_THEME = "theme";
public static final String PREFERENCES_KEY_THEME_LIGHT = "light";
public static final String PREFERENCES_KEY_THEME_DARK = "dark";
public static final String PREFERENCES_KEY_THEME_BLACK = "black";
public static final String PREFERENCES_KEY_DISPLAY_BITRATE_WITH_ARTIST = "displayBitrateWithArtist";
public static final String PREFERENCES_KEY_USE_FOLDER_FOR_ALBUM_ARTIST = "useFolderForAlbumArtist";
public static final String PREFERENCES_KEY_SHOW_TRACK_NUMBER = "showTrackNumber";
public static final String PREFERENCES_KEY_MAX_BITRATE_WIFI = "maxBitrateWifi";
public static final String PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile";
public static final String PREFERENCES_KEY_CACHE_SIZE = "cacheSize";
public static final String PREFERENCES_KEY_CACHE_LOCATION = "cacheLocation";
public static final String PREFERENCES_KEY_PRELOAD_COUNT = "preloadCount";
public static final String PREFERENCES_KEY_HIDE_MEDIA = "hideMedia";
public static final String PREFERENCES_KEY_MEDIA_BUTTONS = "mediaButtons";
public static final String PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD = "screenLitOnDownload";
public static final String PREFERENCES_KEY_SCROBBLE = "scrobble";
public static final String PREFERENCES_KEY_SERVER_SCALING = "serverScaling";
public static final String PREFERENCES_KEY_REPEAT_MODE = "repeatMode";
public static final String PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD = "wifiRequiredForDownload";
public static final String PREFERENCES_KEY_BUFFER_LENGTH = "bufferLength";
public static final String PREFERENCES_KEY_NETWORK_TIMEOUT = "networkTimeout";
public static final String PREFERENCES_KEY_SHOW_NOTIFICATION = "showNotification";
public static final String PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION = "alwaysShowNotification";
public static final String PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS = "showLockScreen";
public static final String PREFERENCES_KEY_MAX_ALBUMS = "maxAlbums";
public static final String PREFERENCES_KEY_MAX_SONGS = "maxSongs";
public static final String PREFERENCES_KEY_MAX_ARTISTS = "maxArtists";
public static final String PREFERENCES_KEY_DEFAULT_ALBUMS = "defaultAlbums";
public static final String PREFERENCES_KEY_DEFAULT_SONGS = "defaultSongs";
public static final String PREFERENCES_KEY_DEFAULT_ARTISTS = "defaultArtists";
public static final String PREFERENCES_KEY_SHOW_NOW_PLAYING = "showNowPlaying";
public static final String PREFERENCES_KEY_GAPLESS_PLAYBACK = "gaplessPlayback";
public static final String PREFERENCES_KEY_PLAYBACK_CONTROL_SETTINGS = "playbackControlSettings";
public static final String PREFERENCES_KEY_CLEAR_SEARCH_HISTORY = "clearSearchHistory";
public static final String PREFERENCES_KEY_DOWNLOAD_TRANSITION = "transitionToDownloadOnPlay";
public static final String PREFERENCES_KEY_INCREMENT_TIME = "incrementTime";
public static final String PREFERENCES_KEY_ID3_TAGS = "useId3Tags";
public static final String PREFERENCES_KEY_SHOW_ARTIST_PICTURE = "showArtistPicture";
public static final String PREFERENCES_KEY_TEMP_LOSS = "tempLoss";
public static final String PREFERENCES_KEY_CHAT_REFRESH_INTERVAL = "chatRefreshInterval";
public static final String PREFERENCES_KEY_DIRECTORY_CACHE_TIME = "directoryCacheTime";
public static final String PREFERENCES_KEY_CLEAR_PLAYLIST = "clearPlaylist";
public static final String PREFERENCES_KEY_CLEAR_BOOKMARK = "clearBookmark";
public static final String PREFERENCES_KEY_DISC_SORT = "discAndTrackSort";
public static final String PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS = "sendBluetoothNotifications";
public static final String PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART = "sendBluetoothAlbumArt";
public static final String PREFERENCES_KEY_DISABLE_SEND_NOW_PLAYING_LIST = "disableNowPlayingListSending";
public static final String PREFERENCES_KEY_VIEW_REFRESH = "viewRefresh";
public static final String PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS = "sharingAlwaysAskForDetails";
public static final String PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION = "sharingDefaultDescription";
public static final String PREFERENCES_KEY_DEFAULT_SHARE_GREETING = "sharingDefaultGreeting";
public static final String PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration";
public static final String PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST = "showAllSongsByArtist";
public static final String PREFERENCES_KEY_USE_FIVE_STAR_RATING = "use_five_star_rating";
public static final String PREFERENCES_KEY_CATEGORY_NOTIFICATIONS = "notificationsCategory";
public static final String PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted";
public static final String PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE = "resumeOnBluetoothDevice";
public static final String PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE = "pauseOnBluetoothDevice";
public static final String PREFERENCES_KEY_SINGLE_BUTTON_PLAY_PAUSE = "singleButtonPlayPause";
public static final String PREFERENCES_KEY_DEBUG_LOG_TO_FILE = "debugLogToFile";
public static final int PREFERENCE_VALUE_ALL = 0;
public static final int PREFERENCE_VALUE_A2DP = 1;
public static final int PREFERENCE_VALUE_DISABLED = 2;
public static final String FILENAME_DOWNLOADS_SER = "downloadstate.ser";
public static final String ALBUM_ART_FILE = "folder.jpeg";
public static final String STARRED = "starred";
public static final String ALPHABETICAL_BY_NAME = "alphabeticalByName";
public static final int RESULT_CLOSE_ALL = 1337;
private Constants()
{
}
}

View File

@ -1,536 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.text.TextUtils;
import org.moire.ultrasonic.app.UApp;
import org.moire.ultrasonic.domain.MusicDirectory;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.regex.Pattern;
import kotlin.Lazy;
import timber.log.Timber;
import static org.koin.java.KoinJavaComponent.inject;
/**
* @author Sindre Mehus
*/
public class FileUtil
{
private static final String[] FILE_SYSTEM_UNSAFE = {"/", "\\", "..", ":", "\"", "?", "*", "<", ">", "|"};
private static final String[] FILE_SYSTEM_UNSAFE_DIR = {"\\", "..", ":", "\"", "?", "*", "<", ">", "|"};
private static final List<String> MUSIC_FILE_EXTENSIONS = Arrays.asList("mp3", "ogg", "aac", "flac", "m4a", "wav", "wma", "opus");
private static final List<String> VIDEO_FILE_EXTENSIONS = Arrays.asList("flv", "mp4", "m4v", "wmv", "avi", "mov", "mpg", "mkv");
private static final List<String> PLAYLIST_FILE_EXTENSIONS = Collections.singletonList("m3u");
private static final Pattern TITLE_WITH_TRACK = Pattern.compile("^\\d\\d-.*");
public static final String SUFFIX_LARGE = ".jpeg";
public static final String SUFFIX_SMALL = ".jpeg-small";
private static final Lazy<PermissionUtil> permissionUtil = inject(PermissionUtil.class);
public static File getSongFile(MusicDirectory.Entry song)
{
File dir = getAlbumDirectory(song);
// Do not generate new name for offline files. Offline files will have their Path as their Id.
if (!TextUtils.isEmpty(song.getId()))
{
if (song.getId().startsWith(dir.getAbsolutePath())) return new File(song.getId());
}
// Generate a file name for the song
StringBuilder fileName = new StringBuilder(256);
Integer track = song.getTrack();
//check if filename already had track number
if (!TITLE_WITH_TRACK.matcher(song.getTitle()).matches()) {
if (track != null) {
if (track < 10) {
fileName.append('0');
}
fileName.append(track).append('-');
}
}
fileName.append(fileSystemSafe(song.getTitle())).append('.');
if (!TextUtils.isEmpty(song.getTranscodedSuffix())) {
fileName.append(song.getTranscodedSuffix());
} else {
fileName.append(song.getSuffix());
}
return new File(dir, fileName.toString());
}
public static File getPlaylistFile(String server, String name)
{
File playlistDir = getPlaylistDirectory(server);
return new File(playlistDir, String.format("%s.m3u", fileSystemSafe(name)));
}
public static File getPlaylistDirectory()
{
File playlistDir = new File(getUltrasonicDirectory(), "playlists");
ensureDirectoryExistsAndIsReadWritable(playlistDir);
return playlistDir;
}
public static File getPlaylistDirectory(String server)
{
File playlistDir = new File(getPlaylistDirectory(), server);
ensureDirectoryExistsAndIsReadWritable(playlistDir);
return playlistDir;
}
/**
* Get the album art file for a given album entry
* @param entry The album entry
* @return File object. Not guaranteed that it exists
*/
public static File getAlbumArtFile(MusicDirectory.Entry entry)
{
File albumDir = getAlbumDirectory(entry);
return getAlbumArtFile(albumDir);
}
/**
* Get the cache key for a given album entry
* @param entry The album entry
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
public static String getAlbumArtKey(MusicDirectory.Entry entry, boolean large)
{
File albumDir = getAlbumDirectory(entry);
return getAlbumArtKey(albumDir, large);
}
/**
* Get the cache key for a given album entry
* @param albumDir The album directory
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
public static String getAlbumArtKey(File albumDir, boolean large)
{
if (albumDir == null) {
return null;
}
String suffix = (large) ? SUFFIX_LARGE : SUFFIX_SMALL;
return String.format(Locale.ROOT, "%s%s", Util.md5Hex(albumDir.getPath()), suffix);
}
public static File getAvatarFile(String username)
{
File albumArtDir = getAlbumArtDirectory();
if (albumArtDir == null || username == null)
{
return null;
}
String md5Hex = Util.md5Hex(username);
return new File(albumArtDir, String.format("%s%s", md5Hex, SUFFIX_LARGE));
}
/**
* Get the album art file for a given album directory
* @param albumDir The album directory
* @return File object. Not guaranteed that it exists
*/
public static File getAlbumArtFile(File albumDir)
{
File albumArtDir = getAlbumArtDirectory();
String key = getAlbumArtKey(albumDir, true);
if (key == null || albumArtDir == null)
{
return null;
}
return new File(albumArtDir, key);
}
/**
* Get the album art file for a given cache key
* @param cacheKey The key (== the filename)
* @return File object. Not guaranteed that it exists
*/
public static File getAlbumArtFile(String cacheKey)
{
File albumArtDir = getAlbumArtDirectory();
if (albumArtDir == null || cacheKey == null)
{
return null;
}
return new File(albumArtDir, cacheKey);
}
public static File getAlbumArtDirectory()
{
File albumArtDir = new File(getUltrasonicDirectory(), "artwork");
ensureDirectoryExistsAndIsReadWritable(albumArtDir);
ensureDirectoryExistsAndIsReadWritable(new File(albumArtDir, ".nomedia"));
return albumArtDir;
}
public static File getAlbumDirectory(MusicDirectory.Entry entry)
{
if (entry == null)
{
return null;
}
File dir;
if (!TextUtils.isEmpty(entry.getPath()))
{
File f = new File(fileSystemSafeDir(entry.getPath()));
dir = new File(String.format("%s/%s", getMusicDirectory().getPath(), entry.isDirectory() ? f.getPath() : f.getParent()));
}
else
{
String artist = fileSystemSafe(entry.getArtist());
String album = fileSystemSafe(entry.getAlbum());
if ("unnamed".equals(album))
{
album = fileSystemSafe(entry.getTitle());
}
dir = new File(String.format("%s/%s/%s", getMusicDirectory().getPath(), artist, album));
}
return dir;
}
public static void createDirectoryForParent(File file)
{
File dir = file.getParentFile();
if (!dir.exists())
{
if (!dir.mkdirs())
{
Timber.e("Failed to create directory %s", dir);
}
}
}
private static File getOrCreateDirectory(String name)
{
File dir = new File(getUltrasonicDirectory(), name);
if (!dir.exists() && !dir.mkdirs())
{
Timber.e("Failed to create %s", name);
}
return dir;
}
public static File getUltrasonicDirectory()
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
return new File(Environment.getExternalStorageDirectory(), "Android/data/org.moire.ultrasonic");
// After Android M, the location of the files must be queried differently. GetExternalFilesDir will always return a directory which Ultrasonic can access without any extra privileges.
return UApp.Companion.applicationContext().getExternalFilesDir(null);
}
public static File getDefaultMusicDirectory()
{
return getOrCreateDirectory("music");
}
public static File getMusicDirectory()
{
File defaultMusicDirectory = getDefaultMusicDirectory();
String path = Settings.getPreferences().getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, defaultMusicDirectory.getPath());
File dir = new File(path);
boolean hasAccess = ensureDirectoryExistsAndIsReadWritable(dir);
if (!hasAccess) permissionUtil.getValue().handlePermissionFailed(null);
return hasAccess ? dir : defaultMusicDirectory;
}
public static boolean ensureDirectoryExistsAndIsReadWritable(File dir)
{
if (dir == null)
{
return false;
}
if (dir.exists())
{
if (!dir.isDirectory())
{
Timber.w("%s exists but is not a directory.", dir);
return false;
}
}
else
{
if (dir.mkdirs())
{
Timber.i("Created directory %s", dir);
}
else
{
Timber.w("Failed to create directory %s", dir);
return false;
}
}
if (!dir.canRead())
{
Timber.w("No read permission for directory %s", dir);
return false;
}
if (!dir.canWrite())
{
Timber.w("No write permission for directory %s", dir);
return false;
}
return true;
}
/**
* Makes a given filename safe by replacing special characters like slashes ("/" and "\")
* with dashes ("-").
*
* @param filename The filename in question.
* @return The filename with special characters replaced by hyphens.
*/
private static String fileSystemSafe(String filename)
{
if (filename == null || filename.trim().isEmpty())
{
return "unnamed";
}
for (String s : FILE_SYSTEM_UNSAFE)
{
filename = filename.replace(s, "-");
}
return filename;
}
/**
* Makes a given filename safe by replacing special characters like colons (":")
* with dashes ("-").
*
* @param path The path of the directory in question.
* @return The the directory name with special characters replaced by hyphens.
*/
private static String fileSystemSafeDir(String path)
{
if (path == null || path.trim().isEmpty())
{
return "";
}
for (String s : FILE_SYSTEM_UNSAFE_DIR)
{
path = path.replace(s, "-");
}
return path;
}
/**
* Similar to {@link File#listFiles()}, but returns a sorted set.
* Never returns {@code null}, instead a warning is logged, and an empty set is returned.
*/
public static SortedSet<File> listFiles(File dir)
{
File[] files = dir.listFiles();
if (files == null)
{
Timber.w("Failed to list children for %s", dir.getPath());
return new TreeSet<>();
}
return new TreeSet<>(Arrays.asList(files));
}
public static SortedSet<File> listMediaFiles(File dir)
{
SortedSet<File> files = listFiles(dir);
Iterator<File> iterator = files.iterator();
while (iterator.hasNext())
{
File file = iterator.next();
if (!file.isDirectory() && !isMediaFile(file))
{
iterator.remove();
}
}
return files;
}
private static boolean isMediaFile(File file)
{
String extension = getExtension(file.getName());
return MUSIC_FILE_EXTENSIONS.contains(extension) || VIDEO_FILE_EXTENSIONS.contains(extension);
}
public static boolean isPlaylistFile(File file)
{
String extension = getExtension(file.getName());
return PLAYLIST_FILE_EXTENSIONS.contains(extension);
}
/**
* Returns the extension (the substring after the last dot) of the given file. The dot
* is not included in the returned extension.
*
* @param name The filename in question.
* @return The extension, or an empty string if no extension is found.
*/
public static String getExtension(String name)
{
int index = name.lastIndexOf('.');
return index == -1 ? "" : name.substring(index + 1).toLowerCase();
}
/**
* Returns the base name (the substring before the last dot) of the given file. The dot
* is not included in the returned basename.
*
* @param name The filename in question.
* @return The base name, or an empty string if no basename is found.
*/
public static String getBaseName(String name)
{
int index = name.lastIndexOf('.');
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);
ObjectOutputStream out = null;
try
{
out = new ObjectOutputStream(new FileOutputStream(file));
out.writeObject(obj);
Timber.i("Serialized object to %s", file);
return true;
}
catch (Throwable x)
{
Timber.w("Failed to serialize object to %s", file);
return false;
}
finally
{
Util.close(out);
}
}
@SuppressWarnings({"unchecked"})
public static <T extends Serializable> T deserialize(Context context, String fileName)
{
File file = new File(context.getCacheDir(), fileName);
if (!file.exists() || !file.isFile())
{
return null;
}
ObjectInputStream in = null;
try
{
in = new ObjectInputStream(new FileInputStream(file));
Object object = in.readObject();
T result = (T) object;
Timber.i("Deserialized object from %s", file);
return result;
}
catch (Throwable x)
{
Timber.w(x,"Failed to deserialize object from %s", file);
return null;
}
finally
{
Util.close(in);
}
}
}

View File

@ -307,7 +307,7 @@ class NavigationActivity : AppCompatActivity() {
val editor = preferences.edit()
editor.putString(
Constants.PREFERENCES_KEY_CACHE_LOCATION,
FileUtil.getDefaultMusicDirectory().path
FileUtil.defaultMusicDirectory.path
)
editor.apply()
}

View File

@ -18,6 +18,8 @@ import timber.log.Timber
/**
* This class can be used to retrieve the properties of the Active Server
* It caches the settings read up from the DB to improve performance.
*
* TODO: There seems to be some confusion whether offline id is 0 or -1. Clean this up (carefully!)
*/
class ActiveServerProvider(
private val repository: ServerSettingDao
@ -30,9 +32,8 @@ class ActiveServerProvider(
* Get the settings of the current Active Server
* @return The Active Server Settings
*/
fun getActiveServer(): ServerSetting {
val serverId = getActiveServerId()
@JvmOverloads
fun getActiveServer(serverId: Int = getActiveServerId()): ServerSetting {
if (serverId > 0) {
if (cachedServer != null && cachedServer!!.id == serverId) return cachedServer!!
@ -95,16 +96,29 @@ class ActiveServerProvider(
return cachedDatabase!!
}
if (activeServer < 1) {
return offlineMetaDatabase
}
Timber.i("Switching to new database, id:$activeServer")
cachedServerId = activeServer
val db = Room.databaseBuilder(
return Room.databaseBuilder(
UApp.applicationContext(),
MetaDatabase::class.java,
METADATA_DB + cachedServerId
)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
return db
}
val offlineMetaDatabase: MetaDatabase by lazy {
Room.databaseBuilder(
UApp.applicationContext(),
MetaDatabase::class.java,
METADATA_DB + 0
)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
}
@Synchronized

View File

@ -17,6 +17,16 @@ interface ArtistsDao {
@JvmSuppressWildcards
fun set(objects: List<Artist>)
/**
* Insert an object in the database.
*
* @param obj the object to be inserted.
* @return The SQLite row id
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@JvmSuppressWildcards
fun insert(obj: Artist): Long
/**
* Clear the whole database
*/
@ -28,4 +38,10 @@ interface ArtistsDao {
*/
@Query("SELECT * FROM artists")
fun get(): List<Artist>
/**
* Get artist by id
*/
@Query("SELECT * FROM artists WHERE id LIKE :id")
fun get(id: String): Artist
}

View File

@ -97,6 +97,16 @@ interface GenericDao<T> {
@JvmSuppressWildcards
fun set(objects: List<T>)
/**
* Insert an object in the database.
*
* @param obj the object to be inserted.
* @return The SQLite row id
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@JvmSuppressWildcards
fun insert(obj: T): Long
/**
* Insert an object in the database.
*

View File

@ -6,6 +6,10 @@ import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.MusicFolder
/**
* This database is used to store and cache the ID3 metadata
*/
@Database(
entities = [Artist::class, Index::class, MusicFolder::class],
version = 1

View File

@ -14,8 +14,8 @@ import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.S
import java.text.Collator
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
/**
@ -59,13 +59,14 @@ class ArtistRowAdapter(
if (Settings.shouldShowArtistPicture) {
holder.coverArt.visibility = View.VISIBLE
val key = FileUtil.getArtistArtKey(itemList[listPosition].name, false)
imageLoader.loadImage(
holder.coverArt,
MusicDirectory.Entry("-1").apply {
coverArt = holder.coverArtId
artist = itemList[listPosition].name
},
false, 0, R.drawable.ic_contact_picture
view = holder.coverArt,
id = holder.coverArtId,
key = key,
large = false,
size = 0,
defaultResourceId = R.drawable.ic_contact_picture
)
} else {
holder.coverArt.visibility = View.GONE

View File

@ -93,10 +93,27 @@ class ImageLoader(
defaultResourceId: Int = R.drawable.unknown_album
) {
val id = entry?.coverArt
val key = FileUtil.getAlbumArtKey(entry, large)
loadImage(view, id, key, large, size, defaultResourceId)
}
/**
* Load the cover of a given entry into an ImageView
*/
@JvmOverloads
@Suppress("LongParameterList", "ComplexCondition")
fun loadImage(
view: View?,
id: String?,
key: String?,
large: Boolean,
size: Int,
defaultResourceId: Int = R.drawable.unknown_album
) {
val requestedSize = resolveSize(size, large)
if (id != null && id.isNotEmpty() && view is ImageView) {
val key = FileUtil.getAlbumArtKey(entry, large)
if (id != null && key != null && id.isNotEmpty() && view is ImageView) {
val request = ImageRequest.CoverArt(
id, key, view, requestedSize,
placeHolderDrawableRes = defaultResourceId,
@ -162,8 +179,7 @@ class ImageLoader(
var inputStream: InputStream? = null
try {
inputStream = response.stream
val bytes = Util.toByteArray(inputStream)
val bytes = inputStream!!.readBytes()
var outputStream: OutputStream? = null
try {
outputStream = FileOutputStream(file)

View File

@ -94,7 +94,7 @@ class FileLoggerTree : Timber.DebugTree() {
if (next) fileNum++
file = File(
FileUtil.getUltrasonicDirectory(),
FileUtil.ultrasonicDirectory,
FILENAME.replace("*", fileNum.toString())
)
}
@ -162,7 +162,7 @@ class FileLoggerTree : Timber.DebugTree() {
}
private fun getLogFileList(): Array<out File>? {
val directory = FileUtil.getUltrasonicDirectory()
val directory = FileUtil.ultrasonicDirectory
return directory.listFiles { t -> t.name.matches(fileNameRegex) }
}
}

View File

@ -7,10 +7,6 @@
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
@ -21,7 +17,8 @@ import java.io.OutputStream
import java.io.RandomAccessFile
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.Artist
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
@ -33,10 +30,7 @@ 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$
* This class represents a single Song or Video that can be downloaded.
*/
class DownloadFile(
val song: MusicDirectory.Entry,
@ -64,6 +58,7 @@ class DownloadFile(
private val downloader: Downloader by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject()
val progress: MutableLiveData<Int> = MutableLiveData(0)
@ -206,16 +201,12 @@ class DownloadFile(
}
private inner class DownloadTask : CancellableTask() {
val musicService = getMusicService()
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(toString())
wifiLock.acquire()
if (saveFile.exists()) {
Timber.i("%s already exists. Skipping.", saveFile)
return
@ -234,8 +225,6 @@ class DownloadFile(
return
}
val musicService = getMusicService()
// Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean
val duration = song.duration
@ -280,6 +269,11 @@ class DownloadFile(
if (isCancelled) {
throw Exception(String.format("Download of '%s' was cancelled", song))
}
if (song.artistId != null) {
cacheMetadata(song.artistId!!)
}
downloadAndSaveCoverArt()
}
@ -307,33 +301,38 @@ class DownloadFile(
} finally {
Util.close(inputStream)
Util.close(outputStream)
if (wakeLock != null) {
wakeLock.release()
Timber.i("Released wake lock %s", wakeLock)
}
wifiLock?.release()
CacheCleaner().cleanSpace()
downloader.checkDownloads()
}
}
private fun acquireWakeLock(wakeLock: WakeLock?): WakeLock? {
var wakeLock1 = wakeLock
if (Settings.isScreenLitOnDownload) {
val context = UApp.applicationContext()
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 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(song.coverArt)) {

View File

@ -1,5 +1,6 @@
package org.moire.ultrasonic.service
import android.net.wifi.WifiManager
import java.util.ArrayList
import java.util.PriorityQueue
import java.util.concurrent.Executors
@ -29,6 +30,8 @@ class Downloader(
private val localMediaPlayer: LocalMediaPlayer
) : KoinComponent {
val playlist: MutableList<DownloadFile> = ArrayList()
var started: Boolean = false
private val downloadQueue: PriorityQueue<DownloadFile> = PriorityQueue<DownloadFile>()
private val activelyDownloading: MutableList<DownloadFile> = ArrayList()
@ -37,27 +40,20 @@ class Downloader(
private val downloadFileCache = LRUCache<MusicDirectory.Entry, DownloadFile>(100)
private var executorService: ScheduledExecutorService? = null
private var wifiLock: WifiManager.WifiLock? = null
var playlistUpdateRevision: Long = 0
private set
val downloadChecker = Runnable {
try {
Timber.w("checking Downloads")
Timber.w("Checking Downloads")
checkDownloadsInternal()
} catch (all: Exception) {
Timber.e(all, "checkDownloads() failed.")
}
}
fun onCreate() {
executorService = Executors.newSingleThreadScheduledExecutor()
executorService!!.scheduleWithFixedDelay(
downloadChecker, CHECK_INTERVAL, CHECK_INTERVAL, TimeUnit.SECONDS
)
Timber.i("Downloader created")
}
fun onDestroy() {
stop()
clearPlaylist()
@ -65,16 +61,42 @@ class Downloader(
Timber.i("Downloader destroyed")
}
fun start() {
started = true
if (executorService == null) {
executorService = Executors.newSingleThreadScheduledExecutor()
executorService!!.scheduleWithFixedDelay(
downloadChecker, 0L, CHECK_INTERVAL, TimeUnit.SECONDS
)
Timber.i("Downloader started")
}
if (wifiLock == null) {
wifiLock = Util.createWifiLock(toString())
wifiLock?.acquire()
}
}
fun stop() {
if (executorService != null) executorService!!.shutdown()
started = false
executorService?.shutdown()
executorService = null
wifiLock?.release()
wifiLock = null
MediaPlayerService.runningInstance?.notifyDownloaderStopped()
Timber.i("Downloader stopped")
}
fun checkDownloads() {
executorService?.execute(downloadChecker)
if (executorService == null || executorService!!.isTerminated) {
start()
} else {
executorService?.execute(downloadChecker)
}
}
@Synchronized
@Suppress("ComplexMethod")
fun checkDownloadsInternal() {
if (
!Util.isExternalStoragePresent() ||
@ -120,13 +142,24 @@ class Downloader(
while (activelyDownloading.size < PARALLEL_DOWNLOADS && downloadQueue.size > 0) {
val task = downloadQueue.remove()
activelyDownloading.add(task)
task.download()
startDownloadOnService(task)
// The next file on the playlist is currently downloading
if (playlist.indexOf(task) == 1) {
localMediaPlayer.setNextPlayerState(PlayerState.DOWNLOADING)
}
}
// Stop Executor service when done downloading
if (activelyDownloading.size == 0) {
stop()
}
}
private fun startDownloadOnService(task: DownloadFile) {
MediaPlayerService.executeOnStartedMediaPlayerService {
task.download()
}
}
private fun cleanupActiveDownloads() {

View File

@ -48,6 +48,9 @@ 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() {
@ -79,7 +82,6 @@ class MediaPlayerService : Service() {
override fun onCreate() {
super.onCreate()
downloader.onCreate()
shufflePlayBuffer.onCreate()
localMediaPlayer.init()
@ -156,6 +158,10 @@ class MediaPlayerService : Service() {
}
}
fun notifyDownloaderStopped() {
stopIfIdle()
}
@Synchronized
fun seekTo(position: Int) {
if (jukeboxMediaPlayer.isEnabled) {
@ -581,22 +587,27 @@ class MediaPlayerService : Service() {
// Clear old actions
notificationBuilder!!.clearActions()
// Add actions
val compactActions = addActions(context, notificationBuilder!!, playerState, song)
// Configure shortcut actions
style.setShowActionsInCompactView(*compactActions)
notificationBuilder!!.setStyle(style)
// Set song title, artist and cover if possible
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()
}

View File

@ -52,7 +52,7 @@ class OfflineMusicService : MusicService, KoinComponent {
override fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index> {
val indexes: MutableList<Index> = ArrayList()
val root = FileUtil.getMusicDirectory()
val root = FileUtil.musicDirectory
for (file in FileUtil.listFiles(root)) {
if (file.isDirectory) {
val index = Index(file.path)
@ -121,7 +121,7 @@ class OfflineMusicService : MusicService, KoinComponent {
val artists: MutableList<Artist> = ArrayList()
val albums: MutableList<MusicDirectory.Entry> = ArrayList()
val songs: MutableList<MusicDirectory.Entry> = ArrayList()
val root = FileUtil.getMusicDirectory()
val root = FileUtil.musicDirectory
var closeness: Int
for (artistFile in FileUtil.listFiles(root)) {
val artistName = artistFile.name
@ -250,7 +250,7 @@ class OfflineMusicService : MusicService, KoinComponent {
}
override fun getRandomSongs(size: Int): MusicDirectory {
val root = FileUtil.getMusicDirectory()
val root = FileUtil.musicDirectory
val children: MutableList<File> = LinkedList()
listFilesRecursively(root, children)
val result = MusicDirectory()
@ -503,7 +503,7 @@ class OfflineMusicService : MusicService, KoinComponent {
entry.isDirectory = file.isDirectory
entry.parent = file.parent
entry.size = file.length()
val root = FileUtil.getMusicDirectory().path
val root = FileUtil.musicDirectory.path
entry.path = file.path.replaceFirst(
String.format(Locale.ROOT, "^%s/", root).toRegex(), ""
)

View File

@ -42,7 +42,6 @@ import org.moire.ultrasonic.domain.toDomainEntityList
import org.moire.ultrasonic.domain.toIndexList
import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.FileUtilKt
import org.moire.ultrasonic.util.Settings
import timber.log.Timber
@ -242,7 +241,7 @@ open class RESTMusicService(
activeServerProvider.getActiveServer().name, name
)
FileUtilKt.savePlaylist(playlistFile, playlist, name)
FileUtil.savePlaylist(playlistFile, playlist, name)
}
@Throws(Exception::class)

View File

@ -51,7 +51,7 @@ class ImageLoaderProvider(val context: Context) : KoinComponent {
ImageLoaderConfig(
Util.getMaxDisplayMetric(),
defaultSize,
FileUtil.getAlbumArtDirectory()
FileUtil.albumArtDirectory
)
}
}

View File

@ -1,20 +1,8 @@
/*
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 2021 (C) Jozsef Varga
* CancellationToken.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util

View File

@ -0,0 +1,125 @@
/*
* Constants.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
object Constants {
// Character encoding used throughout.
const val UTF_8 = "UTF-8"
// REST protocol version and client ID.
// Note: Keep it as low as possible to maintain compatibility with older servers.
const val REST_PROTOCOL_VERSION = "1.7.0"
const val REST_CLIENT_ID = "Ultrasonic"
// Names for intent extras.
const val INTENT_EXTRA_NAME_ID = "subsonic.id"
const val INTENT_EXTRA_NAME_NAME = "subsonic.name"
const val INTENT_EXTRA_NAME_ARTIST = "subsonic.artist"
const val INTENT_EXTRA_NAME_TITLE = "subsonic.title"
const val INTENT_EXTRA_NAME_AUTOPLAY = "subsonic.playall"
const val INTENT_EXTRA_NAME_QUERY = "subsonic.query"
const val INTENT_EXTRA_NAME_PLAYLIST_ID = "subsonic.playlist.id"
const val INTENT_EXTRA_NAME_PODCAST_CHANNEL_ID = "subsonic.podcastChannel.id"
const val INTENT_EXTRA_NAME_PARENT_ID = "subsonic.parent.id"
const val INTENT_EXTRA_NAME_PLAYLIST_NAME = "subsonic.playlist.name"
const val INTENT_EXTRA_NAME_SHARE_ID = "subsonic.share.id"
const val INTENT_EXTRA_NAME_SHARE_NAME = "subsonic.share.name"
const val INTENT_EXTRA_NAME_ALBUM_LIST_TYPE = "subsonic.albumlisttype"
const val INTENT_EXTRA_NAME_ALBUM_LIST_TITLE = "subsonic.albumlisttitle"
const val INTENT_EXTRA_NAME_ALBUM_LIST_SIZE = "subsonic.albumlistsize"
const val INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET = "subsonic.albumlistoffset"
const val INTENT_EXTRA_NAME_SHUFFLE = "subsonic.shuffle"
const val INTENT_EXTRA_NAME_REFRESH = "subsonic.refresh"
const val INTENT_EXTRA_NAME_STARRED = "subsonic.starred"
const val INTENT_EXTRA_NAME_RANDOM = "subsonic.random"
const val INTENT_EXTRA_NAME_GENRE_NAME = "subsonic.genre"
const val INTENT_EXTRA_NAME_IS_ALBUM = "subsonic.isalbum"
const val INTENT_EXTRA_NAME_VIDEOS = "subsonic.videos"
const val INTENT_EXTRA_NAME_SHOW_PLAYER = "subsonic.showplayer"
const val INTENT_EXTRA_NAME_APPEND = "subsonic.append"
// Names for Intent Actions
const val CMD_PROCESS_KEYCODE = "org.moire.ultrasonic.CMD_PROCESS_KEYCODE"
const val CMD_PLAY = "org.moire.ultrasonic.CMD_PLAY"
const val CMD_RESUME_OR_PLAY = "org.moire.ultrasonic.CMD_RESUME_OR_PLAY"
const val CMD_TOGGLEPAUSE = "org.moire.ultrasonic.CMD_TOGGLEPAUSE"
const val CMD_PAUSE = "org.moire.ultrasonic.CMD_PAUSE"
const val CMD_STOP = "org.moire.ultrasonic.CMD_STOP"
const val CMD_PREVIOUS = "org.moire.ultrasonic.CMD_PREVIOUS"
const val CMD_NEXT = "org.moire.ultrasonic.CMD_NEXT"
// Preferences keys.
const val PREFERENCES_KEY_SERVER_INSTANCE = "serverInstanceId"
const val PREFERENCES_KEY_SERVERS_EDIT = "editServers"
const val PREFERENCES_KEY_THEME = "theme"
const val PREFERENCES_KEY_THEME_LIGHT = "light"
const val PREFERENCES_KEY_THEME_DARK = "dark"
const val PREFERENCES_KEY_THEME_BLACK = "black"
const val PREFERENCES_KEY_DISPLAY_BITRATE_WITH_ARTIST = "displayBitrateWithArtist"
const val PREFERENCES_KEY_USE_FOLDER_FOR_ALBUM_ARTIST = "useFolderForAlbumArtist"
const val PREFERENCES_KEY_SHOW_TRACK_NUMBER = "showTrackNumber"
const val PREFERENCES_KEY_MAX_BITRATE_WIFI = "maxBitrateWifi"
const val PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile"
const val PREFERENCES_KEY_CACHE_SIZE = "cacheSize"
const val PREFERENCES_KEY_CACHE_LOCATION = "cacheLocation"
const val PREFERENCES_KEY_PRELOAD_COUNT = "preloadCount"
const val PREFERENCES_KEY_HIDE_MEDIA = "hideMedia"
const val PREFERENCES_KEY_MEDIA_BUTTONS = "mediaButtons"
const val PREFERENCES_KEY_SCROBBLE = "scrobble"
const val PREFERENCES_KEY_SERVER_SCALING = "serverScaling"
const val PREFERENCES_KEY_REPEAT_MODE = "repeatMode"
const val PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD = "wifiRequiredForDownload"
const val PREFERENCES_KEY_BUFFER_LENGTH = "bufferLength"
const val PREFERENCES_KEY_NETWORK_TIMEOUT = "networkTimeout"
const val PREFERENCES_KEY_SHOW_NOTIFICATION = "showNotification"
const val PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION = "alwaysShowNotification"
const val PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS = "showLockScreen"
const val PREFERENCES_KEY_MAX_ALBUMS = "maxAlbums"
const val PREFERENCES_KEY_MAX_SONGS = "maxSongs"
const val PREFERENCES_KEY_MAX_ARTISTS = "maxArtists"
const val PREFERENCES_KEY_DEFAULT_ALBUMS = "defaultAlbums"
const val PREFERENCES_KEY_DEFAULT_SONGS = "defaultSongs"
const val PREFERENCES_KEY_DEFAULT_ARTISTS = "defaultArtists"
const val PREFERENCES_KEY_SHOW_NOW_PLAYING = "showNowPlaying"
const val PREFERENCES_KEY_GAPLESS_PLAYBACK = "gaplessPlayback"
const val PREFERENCES_KEY_PLAYBACK_CONTROL_SETTINGS = "playbackControlSettings"
const val PREFERENCES_KEY_CLEAR_SEARCH_HISTORY = "clearSearchHistory"
const val PREFERENCES_KEY_DOWNLOAD_TRANSITION = "transitionToDownloadOnPlay"
const val PREFERENCES_KEY_INCREMENT_TIME = "incrementTime"
const val PREFERENCES_KEY_ID3_TAGS = "useId3Tags"
const val PREFERENCES_KEY_SHOW_ARTIST_PICTURE = "showArtistPicture"
const val PREFERENCES_KEY_TEMP_LOSS = "tempLoss"
const val PREFERENCES_KEY_CHAT_REFRESH_INTERVAL = "chatRefreshInterval"
const val PREFERENCES_KEY_DIRECTORY_CACHE_TIME = "directoryCacheTime"
const val PREFERENCES_KEY_CLEAR_PLAYLIST = "clearPlaylist"
const val PREFERENCES_KEY_CLEAR_BOOKMARK = "clearBookmark"
const val PREFERENCES_KEY_DISC_SORT = "discAndTrackSort"
const val PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS = "sendBluetoothNotifications"
const val PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART = "sendBluetoothAlbumArt"
const val PREFERENCES_KEY_DISABLE_SEND_NOW_PLAYING_LIST = "disableNowPlayingListSending"
const val PREFERENCES_KEY_VIEW_REFRESH = "viewRefresh"
const val PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS = "sharingAlwaysAskForDetails"
const val PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION = "sharingDefaultDescription"
const val PREFERENCES_KEY_DEFAULT_SHARE_GREETING = "sharingDefaultGreeting"
const val PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration"
const val PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST = "showAllSongsByArtist"
const val PREFERENCES_KEY_USE_FIVE_STAR_RATING = "use_five_star_rating"
const val PREFERENCES_KEY_CATEGORY_NOTIFICATIONS = "notificationsCategory"
const val PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted"
const val PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE = "resumeOnBluetoothDevice"
const val PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE = "pauseOnBluetoothDevice"
const val PREFERENCES_KEY_SINGLE_BUTTON_PLAY_PAUSE = "singleButtonPlayPause"
const val PREFERENCES_KEY_DEBUG_LOG_TO_FILE = "debugLogToFile"
const val PREFERENCE_VALUE_ALL = 0
const val PREFERENCE_VALUE_A2DP = 1
const val PREFERENCE_VALUE_DISABLED = 2
const val FILENAME_DOWNLOADS_SER = "downloadstate.ser"
const val ALBUM_ART_FILE = "folder.jpeg"
const val STARRED = "starred"
const val ALPHABETICAL_BY_NAME = "alphabeticalByName"
const val RESULT_CLOSE_ALL = 1337
}

View File

@ -0,0 +1,479 @@
/*
* FileUtil.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.content.Context
import android.os.Build
import android.os.Environment
import android.text.TextUtils
import java.io.BufferedWriter
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.FileWriter
import java.io.IOException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.Serializable
import java.util.Locale
import java.util.SortedSet
import java.util.TreeSet
import java.util.regex.Pattern
import org.koin.java.KoinJavaComponent
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.MusicDirectory
import timber.log.Timber
object FileUtil {
private val FILE_SYSTEM_UNSAFE = arrayOf("/", "\\", "..", ":", "\"", "?", "*", "<", ">", "|")
private val FILE_SYSTEM_UNSAFE_DIR = arrayOf("\\", "..", ":", "\"", "?", "*", "<", ">", "|")
private val MUSIC_FILE_EXTENSIONS =
listOf("mp3", "ogg", "aac", "flac", "m4a", "wav", "wma", "opus")
private val VIDEO_FILE_EXTENSIONS =
listOf("flv", "mp4", "m4v", "wmv", "avi", "mov", "mpg", "mkv")
private val PLAYLIST_FILE_EXTENSIONS = listOf("m3u")
private val TITLE_WITH_TRACK = Pattern.compile("^\\d\\d-.*")
const val SUFFIX_LARGE = ".jpeg"
const val SUFFIX_SMALL = ".jpeg-small"
private const val UNNAMED = "unnamed"
private val permissionUtil = KoinJavaComponent.inject<PermissionUtil>(
PermissionUtil::class.java
)
fun getSongFile(song: MusicDirectory.Entry): File {
val dir = getAlbumDirectory(song)
// Do not generate new name for offline files. Offline files will have their Path as their Id.
if (!TextUtils.isEmpty(song.id)) {
if (song.id.startsWith(dir.absolutePath)) return File(song.id)
}
// Generate a file name for the song
val fileName = StringBuilder(256)
val track = song.track
// check if filename already had track number
if (song.title != null && !TITLE_WITH_TRACK.matcher(song.title!!).matches()) {
if (track != null) {
if (track < 10) {
fileName.append('0')
}
fileName.append(track).append('-')
}
}
fileName.append(fileSystemSafe(song.title)).append('.')
if (!TextUtils.isEmpty(song.transcodedSuffix)) {
fileName.append(song.transcodedSuffix)
} else {
fileName.append(song.suffix)
}
return File(dir, fileName.toString())
}
@JvmStatic
fun getPlaylistFile(server: String?, name: String?): File {
val playlistDir = getPlaylistDirectory(server)
return File(playlistDir, String.format(Locale.ROOT, "%s.m3u", fileSystemSafe(name)))
}
@JvmStatic
val playlistDirectory: File
get() {
val playlistDir = File(ultrasonicDirectory, "playlists")
ensureDirectoryExistsAndIsReadWritable(playlistDir)
return playlistDir
}
@JvmStatic
fun getPlaylistDirectory(server: String? = null): File {
val playlistDir: File
if (server != null) {
playlistDir = File(playlistDirectory, server)
} else {
playlistDir = playlistDirectory
}
ensureDirectoryExistsAndIsReadWritable(playlistDir)
return playlistDir
}
/**
* Get the album art file for a given album entry
* @param entry The album entry
* @return File object. Not guaranteed that it exists
*/
fun getAlbumArtFile(entry: MusicDirectory.Entry): File {
val albumDir = getAlbumDirectory(entry)
return getAlbumArtFile(albumDir)
}
/**
* Get the cache key for a given album entry
* @param entry The album entry
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
fun getAlbumArtKey(entry: MusicDirectory.Entry?, large: Boolean): String? {
if (entry == null) return null
val albumDir = getAlbumDirectory(entry)
return getAlbumArtKey(albumDir, large)
}
/**
* Get the cache key for a given artist
* @param name The artist name
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
fun getArtistArtKey(name: String?, large: Boolean): String {
val artist = fileSystemSafe(name)
val dir = File(String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.path, artist, UNNAMED))
return getAlbumArtKey(dir, large)
}
/**
* Get the cache key for a given album entry
* @param albumDir The album directory
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
private fun getAlbumArtKey(albumDir: File, large: Boolean): String {
val suffix = if (large) SUFFIX_LARGE else SUFFIX_SMALL
return String.format(Locale.ROOT, "%s%s", Util.md5Hex(albumDir.path), suffix)
}
fun getAvatarFile(username: String?): File? {
if (username == null) {
return null
}
val albumArtDir = albumArtDirectory
val md5Hex = Util.md5Hex(username)
return File(albumArtDir, String.format(Locale.ROOT, "%s%s", md5Hex, SUFFIX_LARGE))
}
/**
* Get the album art file for a given album directory
* @param albumDir The album directory
* @return File object. Not guaranteed that it exists
*/
@JvmStatic
fun getAlbumArtFile(albumDir: File): File {
val albumArtDir = albumArtDirectory
val key = getAlbumArtKey(albumDir, true)
return File(albumArtDir, key)
}
/**
* Get the album art file for a given cache key
* @param cacheKey The key (== the filename)
* @return File object. Not guaranteed that it exists
*/
@JvmStatic
fun getAlbumArtFile(cacheKey: String?): File? {
val albumArtDir = albumArtDirectory
return if (cacheKey == null) {
null
} else File(albumArtDir, cacheKey)
}
val albumArtDirectory: File
get() {
val albumArtDir = File(ultrasonicDirectory, "artwork")
ensureDirectoryExistsAndIsReadWritable(albumArtDir)
ensureDirectoryExistsAndIsReadWritable(File(albumArtDir, ".nomedia"))
return albumArtDir
}
fun getAlbumDirectory(entry: MusicDirectory.Entry): File {
val dir: File
if (!TextUtils.isEmpty(entry.path)) {
val f = File(fileSystemSafeDir(entry.path))
dir = File(
String.format(
Locale.ROOT,
"%s/%s",
musicDirectory.path,
if (entry.isDirectory) f.path else f.parent ?: ""
)
)
} else {
val artist = fileSystemSafe(entry.artist)
var album = fileSystemSafe(entry.album)
if (UNNAMED == album) {
album = fileSystemSafe(entry.title)
}
dir = File(String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.path, artist, album))
}
return dir
}
fun createDirectoryForParent(file: File) {
val dir = file.parentFile
if (dir != null && !dir.exists()) {
if (!dir.mkdirs()) {
Timber.e("Failed to create directory %s", dir)
}
}
}
@Suppress("SameParameterValue")
private fun getOrCreateDirectory(name: String): File {
val dir = File(ultrasonicDirectory, name)
if (!dir.exists() && !dir.mkdirs()) {
Timber.e("Failed to create %s", name)
}
return dir
}
// After Android M, the location of the files must be queried differently.
// GetExternalFilesDir will always return a directory which Ultrasonic
// can access without any extra privileges.
@JvmStatic
val ultrasonicDirectory: File
get() {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) File(
Environment.getExternalStorageDirectory(),
"Android/data/org.moire.ultrasonic"
) else UApp.applicationContext().getExternalFilesDir(null)!!
}
// After Android M, the location of the files must be queried differently.
// GetExternalFilesDir will always return a directory which Ultrasonic
// can access without any extra privileges.
@JvmStatic
val defaultMusicDirectory: File
get() = getOrCreateDirectory("music")
@JvmStatic
val musicDirectory: File
get() {
val path = Settings.preferences
.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, defaultMusicDirectory.path)
val dir = File(path!!)
val hasAccess = ensureDirectoryExistsAndIsReadWritable(dir)
if (!hasAccess) permissionUtil.value.handlePermissionFailed(null)
return if (hasAccess) dir else defaultMusicDirectory
}
@JvmStatic
@Suppress("ReturnCount")
fun ensureDirectoryExistsAndIsReadWritable(dir: File?): Boolean {
if (dir == null) {
return false
}
if (dir.exists()) {
if (!dir.isDirectory) {
Timber.w("%s exists but is not a directory.", dir)
return false
}
} else {
if (dir.mkdirs()) {
Timber.i("Created directory %s", dir)
} else {
Timber.w("Failed to create directory %s", dir)
return false
}
}
if (!dir.canRead()) {
Timber.w("No read permission for directory %s", dir)
return false
}
if (!dir.canWrite()) {
Timber.w("No write permission for directory %s", dir)
return false
}
return true
}
/**
* Makes a given filename safe by replacing special characters like slashes ("/" and "\")
* with dashes ("-").
*
* @param name The filename in question.
* @return The filename with special characters replaced by hyphens.
*/
private fun fileSystemSafe(name: String?): String {
if (name == null || name.trim { it <= ' ' }.isEmpty()) {
return UNNAMED
}
var filename: String = name
for (s in FILE_SYSTEM_UNSAFE) {
filename = filename.replace(s, "-")
}
return filename
}
/**
* Makes a given filename safe by replacing special characters like colons (":")
* with dashes ("-").
*
* @param path The path of the directory in question.
* @return The the directory name with special characters replaced by hyphens.
*/
private fun fileSystemSafeDir(path: String?): String {
var filepath = path
if (filepath == null || filepath.trim { it <= ' ' }.isEmpty()) {
return ""
}
for (s in FILE_SYSTEM_UNSAFE_DIR) {
filepath = filepath!!.replace(s, "-")
}
return filepath!!
}
/**
* Similar to [File.listFiles], but returns a sorted set.
* Never returns `null`, instead a warning is logged, and an empty set is returned.
*/
@JvmStatic
fun listFiles(dir: File): SortedSet<File> {
val files = dir.listFiles()
if (files == null) {
Timber.w("Failed to list children for %s", dir.path)
return TreeSet()
}
return TreeSet(files.asList())
}
fun listMediaFiles(dir: File): SortedSet<File> {
val files = listFiles(dir)
val iterator = files.iterator()
while (iterator.hasNext()) {
val file = iterator.next()
if (!file.isDirectory && !isMediaFile(file)) {
iterator.remove()
}
}
return files
}
private fun isMediaFile(file: File): Boolean {
val extension = getExtension(file.name)
return MUSIC_FILE_EXTENSIONS.contains(extension) ||
VIDEO_FILE_EXTENSIONS.contains(extension)
}
fun isPlaylistFile(file: File): Boolean {
val extension = getExtension(file.name)
return PLAYLIST_FILE_EXTENSIONS.contains(extension)
}
/**
* Returns the extension (the substring after the last dot) of the given file. The dot
* is not included in the returned extension.
*
* @param name The filename in question.
* @return The extension, or an empty string if no extension is found.
*/
fun getExtension(name: String): String {
val index = name.lastIndexOf('.')
return if (index == -1) "" else name.substring(index + 1).lowercase(Locale.ROOT)
}
/**
* Returns the base name (the substring before the last dot) of the given file. The dot
* is not included in the returned basename.
*
* @param name The filename in question.
* @return The base name, or an empty string if no basename is found.
*/
fun getBaseName(name: String): String {
val index = name.lastIndexOf('.')
return if (index == -1) name else 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
*/
fun getPartialFile(name: String): String {
return String.format(Locale.ROOT, "%s.partial.%s", getBaseName(name), 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
*/
fun getCompleteFile(name: String): String {
return String.format(Locale.ROOT, "%s.complete.%s", getBaseName(name), getExtension(name))
}
@JvmStatic
fun <T : Serializable?> serialize(context: Context, obj: T, fileName: String): Boolean {
val file = File(context.cacheDir, fileName)
var out: ObjectOutputStream? = null
return try {
out = ObjectOutputStream(FileOutputStream(file))
out.writeObject(obj)
Timber.i("Serialized object to %s", file)
true
} catch (ignored: Exception) {
Timber.w("Failed to serialize object to %s", file)
false
} finally {
Util.close(out)
}
}
@Suppress("UNCHECKED_CAST")
@JvmStatic
fun <T : Serializable?> deserialize(context: Context, fileName: String): T? {
val file = File(context.cacheDir, fileName)
if (!file.exists() || !file.isFile) {
return null
}
var inStream: ObjectInputStream? = null
return try {
inStream = ObjectInputStream(FileInputStream(file))
val readObject = inStream.readObject()
val result = readObject as T
Timber.i("Deserialized object from %s", file)
result
} catch (all: Throwable) {
Timber.w(all, "Failed to deserialize object from %s", file)
null
} finally {
Util.close(inStream)
}
}
fun savePlaylist(
playlistFile: File?,
playlist: MusicDirectory,
name: String
) {
val fw = FileWriter(playlistFile)
val bw = BufferedWriter(fw)
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
var filePath = getSongFile(e).absolutePath
if (!File(filePath).exists()) {
val ext = getExtension(filePath)
val base = getBaseName(filePath)
filePath = "$base.complete.$ext"
}
fw.write(filePath + "\n")
}
} catch (e: IOException) {
Timber.w("Failed to save playlist: %s", name)
throw e
} finally {
bw.close()
fw.close()
}
}
}

View File

@ -1,47 +0,0 @@
/*
* FileUtil.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import org.moire.ultrasonic.domain.MusicDirectory
import timber.log.Timber
// TODO: Convert FileUtil.java and merge into here.
object FileUtilKt {
fun savePlaylist(
playlistFile: File?,
playlist: MusicDirectory,
name: String
) {
val fw = FileWriter(playlistFile)
val bw = BufferedWriter(fw)
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
var filePath = FileUtil.getSongFile(e).absolutePath
if (!File(filePath).exists()) {
val ext = FileUtil.getExtension(filePath)
val base = FileUtil.getBaseName(filePath)
filePath = "$base.complete.$ext"
}
fw.write(filePath + "\n")
}
} catch (e: IOException) {
Timber.w("Failed to save playlist: %s", name)
throw e
} finally {
bw.close()
fw.close()
}
}
}

View File

@ -24,15 +24,6 @@ import org.moire.ultrasonic.domain.RepeatMode
object Settings {
private val PATTERN = Pattern.compile(":")
val isScreenLitOnDownload: Boolean
get() {
val preferences = preferences
return preferences.getBoolean(
Constants.PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD,
false
)
}
var repeatMode: RepeatMode
get() {
val preferences = preferences

View File

@ -20,7 +20,7 @@ class SubsonicUncaughtExceptionHandler(
var printWriter: PrintWriter? = null
try {
file = File(FileUtil.getUltrasonicDirectory(), STACKTRACE_NAME)
file = File(FileUtil.ultrasonicDirectory, STACKTRACE_NAME)
printWriter = PrintWriter(file)
val logMessage = String.format(
"Android API level: %s\nUltrasonic version name: %s\n" +

View File

@ -37,14 +37,11 @@ import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.annotation.AnyRes
import androidx.media.utils.MediaConstants
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.UnsupportedEncodingException
import java.security.MessageDigest
import java.text.DecimalFormat
@ -115,38 +112,6 @@ object Util {
}
}
/**
* Get the contents of an `InputStream` as a `byte[]`.
*
*
* This method buffers the input internally, so there is no need to use a
* `BufferedInputStream`.
*
* @param input the `InputStream` to read from
* @return the requested byte array
* @throws NullPointerException if the input is null
* @throws java.io.IOException if an I/O error occurs
*/
@Throws(IOException::class)
fun toByteArray(input: InputStream?): ByteArray {
val output = ByteArrayOutputStream()
copy(input!!, output)
return output.toByteArray()
}
@Throws(IOException::class)
@Suppress("MagicNumber")
fun copy(input: InputStream, output: OutputStream): Long {
val buffer = ByteArray(KBYTE * 4)
var count: Long = 0
var n: Int
while (-1 != input.read(buffer).also { n = it }) {
output.write(buffer, 0, n)
count += n.toLong()
}
return count
}
@Throws(IOException::class)
fun atomicCopy(from: File, to: File) {
val tmp = File(String.format(Locale.ROOT, "%s.tmp", to.path))

View File

@ -248,8 +248,6 @@
<string name="settings.preload_unlimited">Neomezeně</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Pokračovat po připojení sluchátek</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">Aplikace spustí pozastavené přehrávání po připojení kabelu sluchátek do přístroje.</string>
<string name="settings.screen_lit_summary">Nezamčení displeje při stahování zlepšuje rychlost stahování.</string>
<string name="settings.screen_lit_title">Nezamykat displej</string>
<string name="settings.search_1">1</string>
<string name="settings.search_10">10</string>
<string name="settings.search_100">100</string>

View File

@ -247,8 +247,6 @@
<string name="settings.preload_unlimited">Unbegrenzt</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Fortsetzen mit Kopfhörer</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">Die App setzt eine pausierte Wiedergabe beim Anschließen der Kopfhörer fort.</string>
<string name="settings.screen_lit_summary">Wenn der Bildschirm während des Herunterladens eingeschaltet bleibt, wird die Download-Geschwindigkeit verbessert.</string>
<string name="settings.screen_lit_title">Bildschirm eingeschaltet lassen</string>
<string name="settings.search_1">1</string>
<string name="settings.search_10">10</string>
<string name="settings.search_100">100</string>

View File

@ -260,8 +260,6 @@
<string name="settings.preload_unlimited">Ilimitado</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Reanudar al insertar los auriculares</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">La aplicación reanudará la reproducción en pausa al insertar los auriculares en el dispositivo.</string>
<string name="settings.screen_lit_summary">Mantener la pantalla encendida mientras descarga mejora la velocidad de la misma.</string>
<string name="settings.screen_lit_title">Mantener la pantalla encendida</string>
<string name="settings.scrobble_summary">Recuerda configurar tu nombre de usuario y contraseña en los servicios de Scrobble en el servidor</string>
<string name="settings.scrobble_title">Hacer Scrobble de mis reproducciones</string>
<string name="settings.search_1">1</string>

View File

@ -260,8 +260,6 @@
<string name="settings.preload_unlimited">Illimité</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Reprise à l\'insertion des écouteurs</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">L\'application reprendra la lecture lors de l\'insertion du casque dans l\'appareil.</string>
<string name="settings.screen_lit_summary">Garder l\'écran allumé pendant le téléchargement permet d\'améliorer la vitesse de téléchargement.</string>
<string name="settings.screen_lit_title">Garder l\'écran allumé</string>
<string name="settings.scrobble_summary">Pensez à configurer votre nom dutilisateur et votre mot de passe dans le(s) service(s) Scrobble sur le serveur.</string>
<string name="settings.scrobble_title">Scrobbler mes lectures</string>
<string name="settings.search_1">1</string>

View File

@ -258,8 +258,6 @@
<string name="settings.preload_unlimited">Korlátlan</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Folytatás a fejhallgató behelyezésekor</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">Az alkalmazás folytatja a szüneteltetett lejátszást a fejhallgató behelyezésekor a készülékbe.</string>
<string name="settings.screen_lit_summary">Képernyő ébrentartása a letöltés alatt, a magasabb letöltési sebesség érdekében.</string>
<string name="settings.screen_lit_title">Képernyő ébrentartása</string>
<string name="settings.scrobble_summary">Ne felejtsd el beállítani a Scrobble szolgáltatónál használt felhasználóneved és jelszavad a szervereden</string>
<string name="settings.scrobble_title">Scrobble engedélyezése</string>
<string name="settings.search_1">1</string>

View File

@ -242,8 +242,6 @@
<string name="settings.preload_5">5 canzoni</string>
<string name="settings.preload_unlimited">Illimitato</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Riprendi all\'inserimento delle cuffie</string>
<string name="settings.screen_lit_summary">Mantenere lo schermo acceso durante il download migliora la sua velocità.</string>
<string name="settings.screen_lit_title">Mantieni lo schemo acceso </string>
<string name="settings.search_1">1</string>
<string name="settings.search_10">10</string>
<string name="settings.search_100">100</string>

View File

@ -258,8 +258,6 @@
<string name="settings.preload_unlimited">Ongelimiteerd</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Hervatten bij aansluiten van hoofdtelefoon</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">Het afspelen wordt hervat zodra er een hoofdtelefoon wordt aangesloten.</string>
<string name="settings.screen_lit_summary">Door het scherm aan te houden tijdens het downloaden, wordt de downloadsnelheid verhoogd.</string>
<string name="settings.screen_lit_title">Scherm aan houden</string>
<string name="settings.scrobble_summary">Let op: stel je gebruikersnaam en wachtwoord van je scrobble-dienst(en) in op je Subsonic-server</string>
<string name="settings.scrobble_title">Scrobbelen</string>
<string name="settings.search_1">1</string>

View File

@ -246,8 +246,6 @@
<string name="settings.preload_unlimited">Bez limitu</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Wznawiaj po podłączeniu słuchawek</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">Aplikacja wznowi zatrzymane odtwarzanie po podpięciu słuchawek.</string>
<string name="settings.screen_lit_summary">Podtrzymywanie włączonego ekranu poprawia prędkość pobierania.</string>
<string name="settings.screen_lit_title">Podtrzymuj ekran włączony</string>
<string name="settings.search_1">1</string>
<string name="settings.search_10">10</string>
<string name="settings.search_100">100</string>

View File

@ -260,8 +260,6 @@
<string name="settings.preload_unlimited">Ilimitado</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Retomar ao Inserir Fone de Ouvido</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">O aplicativo retomará a reprodução em pausa na inserção dos fones de ouvido no dispositivo.</string>
<string name="settings.screen_lit_summary">Manter a tela ligada enquanto baixando aumenta a velocidade de download.</string>
<string name="settings.screen_lit_title">Manter a Tela Ligada</string>
<string name="settings.scrobble_summary">Lembre-se de configurar usuário e senha nos serviços Scrobble do servidor</string>
<string name="settings.scrobble_title">Registre Minhas Músicas</string>
<string name="settings.search_1">1</string>

View File

@ -246,8 +246,6 @@
<string name="settings.preload_unlimited">Ilimitado</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Retomar ao inserir Auscultadores</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">O aplicativo retomará a reprodução em pausa na inserção dos auscultadores no dispositivo.</string>
<string name="settings.screen_lit_summary">Manter o ecrã ligado enquanto descarrega aumenta a velocidade de download.</string>
<string name="settings.screen_lit_title">Manter o Ecrã Ligado</string>
<string name="settings.search_1">1</string>
<string name="settings.search_10">10</string>
<string name="settings.search_100">100</string>

View File

@ -260,8 +260,6 @@
<string name="settings.preload_unlimited">Неограниченный</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Возобновить подключение наушников</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">Приложение возобновит приостановленное воспроизведение после того, как в устройство будут вставлены проводные наушники.</string>
<string name="settings.screen_lit_summary">Сохранение экрана во время загрузки повышает скорость загрузки.</string>
<string name="settings.screen_lit_title">Оставьте экран включенным</string>
<string name="settings.scrobble_summary">Не забудьте установить своего пользователя и пароль в Скроббл сервисах на сервере.</string>
<string name="settings.scrobble_title">Скробблить мои воспроизведения</string>
<string name="settings.search_1">1</string>

View File

@ -262,8 +262,6 @@
<string name="settings.preload_unlimited">Unlimited</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Resume on headphones insertion</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">App will resume paused playback on wired headphones insertion into device.</string>
<string name="settings.screen_lit_summary">Keeping the screen on while downloading improves download speed.</string>
<string name="settings.screen_lit_title">Keep Screen On</string>
<string name="settings.scrobble_summary">Remember to set up your user and password in the Scrobble service(s) on the server</string>
<string name="settings.scrobble_title">Scrobble my plays</string>
<string name="settings.search_1">1</string>
@ -404,6 +402,7 @@
<string name="settings.debug.log_keep">Keep files</string>
<string name="settings.debug.log_delete">Delete files</string>
<string name="settings.debug.log_deleted">Deleted log files.</string>
<string name="notification.downloading_title">Downloading media in the background…</string>
<string name="permissions.access_error">Ultrasonic can\'t access the music file cache. Cache location was reset to the default path.</string>
<string name="permissions.message_box_title">Warning</string>

View File

@ -349,12 +349,6 @@
a:summary="@string/settings.hide_media_summary"
a:title="@string/settings.hide_media_title"
app:iconSpaceReserved="false"/>
<CheckBoxPreference
a:defaultValue="true"
a:key="screenLitOnDownload"
a:summary="@string/settings.screen_lit_summary"
a:title="@string/settings.screen_lit_title"
app:iconSpaceReserved="false"/>
</PreferenceCategory>
<PreferenceCategory
a:title="@string/feature_flags_category_title"