mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-05-08 03:21:05 +03:00
Merge pull request #583 from ultrasonic/download-metadata
Download metadata
This commit is contained in:
commit
a7a895af96
@ -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:
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
@ -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)) {
|
||||
|
@ -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() {
|
@ -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 isn’t 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()
|
||||
}
|
||||
|
||||
|
@ -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(), ""
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -51,7 +51,7 @@ class ImageLoaderProvider(val context: Context) : KoinComponent {
|
||||
ImageLoaderConfig(
|
||||
Util.getMaxDisplayMetric(),
|
||||
defaultSize,
|
||||
FileUtil.getAlbumArtDirectory()
|
||||
FileUtil.albumArtDirectory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
}
|
479
ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt
Normal file
479
ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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" +
|
||||
|
@ -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))
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 d’utilisateur 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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user