Merge branch 'develop' into api30

This commit is contained in:
Nite 2021-12-10 11:00:59 +01:00
commit 90638e5fd7
No known key found for this signature in database
GPG Key ID: 1D1AD59B1C6386C1
154 changed files with 4594 additions and 5427 deletions

View File

@ -11,21 +11,4 @@ data class Artist(
override var coverArt: String? = null,
override var albumCount: Long? = null,
override var closeness: Int = 0
) : ArtistOrIndex(id) {
fun compareTo(other: Artist): Int {
when {
this.closeness == other.closeness -> {
return 0
}
this.closeness > other.closeness -> {
return -1
}
else -> {
return 1
}
}
}
override fun compareTo(other: Identifiable) = compareTo(other as Artist)
}
) : ArtistOrIndex(id)

View File

@ -15,4 +15,21 @@ abstract class ArtistOrIndex(
open var albumCount: Long? = null,
@Ignore
open var closeness: Int = 0
) : GenericEntry()
) : GenericEntry() {
fun compareTo(other: ArtistOrIndex): Int {
when {
this.closeness == other.closeness -> {
return 0
}
this.closeness > other.closeness -> {
return -1
}
else -> {
return 1
}
}
}
override fun compareTo(other: Identifiable) = compareTo(other as ArtistOrIndex)
}

View File

@ -3,14 +3,17 @@ package org.moire.ultrasonic.domain
import androidx.room.Ignore
abstract class GenericEntry : Identifiable {
abstract override val id: String
@Ignore
open val name: String? = null
override fun compareTo(other: Identifiable): Int {
return this.id.toInt().compareTo(other.id.toInt())
}
}
interface Identifiable : Comparable<Identifiable> {
val id: String
val longId: Long
get() = id.hashCode().toLong()
override fun compareTo(other: Identifiable): Int {
return longId.compareTo(other.longId)
}
}

View File

@ -5,71 +5,90 @@ import androidx.room.PrimaryKey
import java.io.Serializable
import java.util.Date
class MusicDirectory {
class MusicDirectory : ArrayList<MusicDirectory.Child>() {
var name: String? = null
private val children = mutableListOf<Entry>()
fun addAll(entries: Collection<Entry>) {
children.addAll(entries)
}
fun addFirst(child: Entry) {
children.add(0, child)
}
fun addChild(child: Entry) {
children.add(child)
}
fun findChild(id: String): Entry? = children.lastOrNull { it.id == id }
fun getAllChild(): List<Entry> = children.toList()
@JvmOverloads
fun getChildren(
includeDirs: Boolean = true,
includeFiles: Boolean = true
): List<Entry> {
): List<Child> {
if (includeDirs && includeFiles) {
return children
return toList()
}
return children.filter { it.isDirectory && includeDirs || !it.isDirectory && includeFiles }
return filter { it.isDirectory && includeDirs || !it.isDirectory && includeFiles }
}
fun getTracks(): List<Entry> {
return mapNotNull {
it as? Entry
}
}
fun getAlbums(): List<Album> {
return mapNotNull {
it as? Album
}
}
abstract class Child : GenericEntry() {
abstract override var id: String
abstract var parent: String?
abstract var isDirectory: Boolean
abstract var album: String?
abstract var title: String?
abstract override val name: String?
abstract val discNumber: Int?
abstract var coverArt: String?
abstract val songCount: Long?
abstract val created: Date?
abstract var artist: String?
abstract val artistId: String?
abstract val duration: Int?
abstract val year: Int?
abstract val genre: String?
abstract var starred: Boolean
abstract var path: String?
abstract var closeness: Int
abstract var isVideo: Boolean
}
// TODO: Rename to Track
@Entity
data class Entry(
@PrimaryKey override var id: String,
var parent: String? = null,
var isDirectory: Boolean = false,
var title: String? = null,
var album: String? = null,
override var parent: String? = null,
override var isDirectory: Boolean = false,
override var title: String? = null,
override var album: String? = null,
var albumId: String? = null,
var artist: String? = null,
var artistId: String? = null,
var track: Int? = 0,
var year: Int? = 0,
var genre: String? = null,
override var artist: String? = null,
override var artistId: String? = null,
var track: Int? = null,
override var year: Int? = null,
override var genre: String? = null,
var contentType: String? = null,
var suffix: String? = null,
var transcodedContentType: String? = null,
var transcodedSuffix: String? = null,
var coverArt: String? = null,
override var coverArt: String? = null,
var size: Long? = null,
var songCount: Long? = null,
var duration: Int? = null,
override var songCount: Long? = null,
override var duration: Int? = null,
var bitRate: Int? = null,
var path: String? = null,
var isVideo: Boolean = false,
var starred: Boolean = false,
var discNumber: Int? = null,
override var path: String? = null,
override var isVideo: Boolean = false,
override var starred: Boolean = false,
override var discNumber: Int? = null,
var type: String? = null,
var created: Date? = null,
var closeness: Int = 0,
override var created: Date? = null,
override var closeness: Int = 0,
var bookmarkPosition: Int = 0,
var userRating: Int? = null,
var averageRating: Float? = null
) : Serializable, GenericEntry() {
var averageRating: Float? = null,
override var name: String? = null
) : Serializable, Child() {
fun setDuration(duration: Long) {
this.duration = duration.toInt()
}
@ -94,4 +113,27 @@ class MusicDirectory {
override fun compareTo(other: Identifiable) = compareTo(other as Entry)
}
data class Album(
@PrimaryKey override var id: String,
override var parent: String? = null,
override var album: String? = null,
override var title: String? = null,
override val name: String? = null,
override val discNumber: Int = 0,
override var coverArt: String? = null,
override val songCount: Long? = null,
override val created: Date? = null,
override var artist: String? = null,
override val artistId: String? = null,
override val duration: Int = 0,
override val year: Int = 0,
override val genre: String? = null,
override var starred: Boolean = false,
override var path: String? = null,
override var closeness: Int = 0,
) : Child() {
override var isDirectory = true
override var isVideo = false
}
}

View File

@ -1,12 +1,13 @@
package org.moire.ultrasonic.domain
import org.moire.ultrasonic.domain.MusicDirectory.Album
import org.moire.ultrasonic.domain.MusicDirectory.Entry
/**
* The result of a search. Contains matching artists, albums and songs.
*/
data class SearchResult(
val artists: List<Artist>,
val albums: List<Entry>,
val songs: List<Entry>
val artists: List<ArtistOrIndex> = listOf(),
val albums: List<Album> = listOf(),
val songs: List<Entry> = listOf()
)

View File

@ -2,9 +2,9 @@ package org.moire.ultrasonic.api.subsonic
import org.amshove.kluent.`should be equal to`
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.Album
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.api.subsonic.models.AlbumListType.BY_GENRE
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
/**
* Integration tests for [SubsonicAPIDefinition] for getAlbumList call.
@ -28,8 +28,8 @@ class SubsonicApiGetAlbumListRequestTest : SubsonicAPIClientTest() {
assertResponseSuccessful(response)
with(response.body()!!.albumList) {
size `should be equal to` 2
this[1] `should be equal to` MusicDirectoryChild(
id = "9997", parent = "9996", isDir = true,
this[1] `should be equal to` Album(
id = "9997", parent = "9996",
title = "Endless Forms Most Beautiful", album = "Endless Forms Most Beautiful",
artist = "Nightwish", year = 2015, genre = "Symphonic Metal",
coverArt = "9997", playCount = 11,

View File

@ -3,6 +3,7 @@ package org.moire.ultrasonic.api.subsonic
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should not be`
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.Album
import org.moire.ultrasonic.api.subsonic.models.Artist
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult
@ -32,9 +33,8 @@ class SubsonicApiSearchTwoTest : SubsonicAPIClientTest() {
artistList.size `should be equal to` 1
artistList[0] `should be equal to` Artist(id = "522", name = "The Prodigy")
albumList.size `should be equal to` 1
albumList[0] `should be equal to` MusicDirectoryChild(
id = "8867", parent = "522",
isDir = true, title = "Always Outnumbered, Never Outgunned",
albumList[0] `should be equal to` Album(
id = "8867", parent = "522", title = "Always Outnumbered, Never Outgunned",
album = "Always Outnumbered, Never Outgunned", artist = "The Prodigy",
year = 2004, genre = "Electronic", coverArt = "8867", playCount = 0,
created = parseDate("2016-10-23T20:57:27.000Z")

View File

@ -7,8 +7,8 @@ data class Album(
val id: String = "",
val parent: String = "",
val album: String = "",
val title: String = "",
val name: String = "",
val title: String? = null,
val name: String? = null,
val discNumber: Int = 0,
val coverArt: String = "",
val songCount: Int = 0,
@ -18,6 +18,7 @@ data class Album(
val duration: Int = 0,
val year: Int = 0,
val genre: String = "",
val playCount: Int = 0,
@JsonProperty("song") val songList: List<MusicDirectoryChild> = emptyList(),
@JsonProperty("starred") val starredDate: String = ""
)

View File

@ -4,6 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty
data class SearchTwoResult(
@JsonProperty("artist") val artistList: List<Artist> = emptyList(),
@JsonProperty("album") val albumList: List<MusicDirectoryChild> = emptyList(),
@JsonProperty("album") val albumList: List<Album> = emptyList(),
@JsonProperty("song") val songList: List<MusicDirectoryChild> = emptyList()
)

View File

@ -3,7 +3,7 @@ package org.moire.ultrasonic.api.subsonic.response
import com.fasterxml.jackson.annotation.JsonProperty
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicError
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
import org.moire.ultrasonic.api.subsonic.models.Album
class GetAlbumListResponse(
status: Status,
@ -12,10 +12,10 @@ class GetAlbumListResponse(
) : SubsonicResponse(status, version, error) {
@JsonProperty("albumList") private val albumWrapper = AlbumWrapper()
val albumList: List<MusicDirectoryChild>
val albumList: List<Album>
get() = albumWrapper.albumList
}
private class AlbumWrapper(
@JsonProperty("album") val albumList: List<MusicDirectoryChild> = emptyList()
@JsonProperty("album") val albumList: List<Album> = emptyList()
)

View File

@ -10,7 +10,7 @@ ext.versions = [
androidxcore : "1.6.0",
ktlint : "0.37.1",
ktlintGradle : "10.2.0",
detekt : "1.18.1",
detekt : "1.19.0",
jacoco : "0.8.7",
preferences : "1.1.1",
media : "1.3.1",
@ -31,11 +31,10 @@ ext.versions = [
okhttp : "3.12.13",
koin : "3.0.2",
picasso : "2.71828",
sortListView : "1.0.1",
junit4 : "4.13.2",
junit5 : "5.8.1",
mockito : "4.0.0",
mockito : "4.1.0",
mockitoKotlin : "4.0.0",
kluent : "1.68",
apacheCodecs : "1.15",
@ -46,6 +45,7 @@ ext.versions = [
fsaf : "1.1",
rxJava : "3.1.2",
rxAndroid : "3.0.0",
multiType : "4.3.0",
]
ext.gradlePlugins = [
@ -92,11 +92,11 @@ ext.other = [
picasso : "com.squareup.picasso:picasso:$versions.picasso",
timber : "com.jakewharton.timber:timber:$versions.timber",
fastScroll : "com.simplecityapps:recyclerview-fastscroll:$versions.fastScroll",
sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView",
colorPickerView : "com.github.skydoves:colorpickerview:$versions.colorPicker",
fsaf : "com.github.K1rakishou:Fuck-Storage-Access-Framework:$versions.fsaf",
rxJava : "io.reactivex.rxjava3:rxjava:$versions.rxJava",
rxAndroid : "io.reactivex.rxjava3:rxandroid:$versions.rxAndroid",
multiType : "com.drakeet.multitype:multitype:$versions.multiType",
]
ext.testing = [

View File

@ -42,8 +42,8 @@ empty-blocks:
complexity:
active: true
TooManyFunctions:
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInFiles: 25
thresholdInClasses: 25
thresholdInInterfaces: 20
thresholdInObjects: 30
LabeledExpression:

View File

@ -2,7 +2,8 @@ org.gradle.parallel=true
org.gradle.daemon=true
org.gradle.configureondemand=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx2g
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC
kotlin.incremental=true
kotlin.caching.enabled=true

View File

@ -105,11 +105,11 @@ dependencies {
implementation other.koinAndroid
implementation other.okhttpLogging
implementation other.fastScroll
implementation other.sortListView
implementation other.colorPickerView
implementation other.fsaf
implementation other.rxJava
implementation other.rxAndroid
implementation other.multiType
kapt androidSupport.room

View File

@ -1,387 +0,0 @@
package org.moire.ultrasonic.fragment;
import android.os.Bundle;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ListView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.service.DownloadFile;
import org.moire.ultrasonic.service.MediaPlayerController;
import org.moire.ultrasonic.service.MusicService;
import org.moire.ultrasonic.service.MusicServiceFactory;
import org.moire.ultrasonic.subsonic.ImageLoaderProvider;
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker;
import org.moire.ultrasonic.subsonic.VideoPlayer;
import org.moire.ultrasonic.util.CancellationToken;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.FragmentBackgroundTask;
import org.moire.ultrasonic.util.Util;
import org.moire.ultrasonic.view.EntryAdapter;
import java.util.ArrayList;
import java.util.List;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
/**
* Lists the Bookmarks available on the server
*/
public class BookmarksFragment extends Fragment {
private SwipeRefreshLayout refreshAlbumListView;
private ListView albumListView;
private View albumButtons;
private View emptyView;
private ImageView playNowButton;
private ImageView pinButton;
private ImageView unpinButton;
private ImageView downloadButton;
private ImageView deleteButton;
private final Lazy<MediaPlayerController> mediaPlayerController = inject(MediaPlayerController.class);
private final Lazy<ImageLoaderProvider> imageLoader = inject(ImageLoaderProvider.class);
private final Lazy<NetworkAndStorageChecker> networkAndStorageChecker = inject(NetworkAndStorageChecker.class);
private CancellationToken cancellationToken;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
Util.applyTheme(this.getContext());
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(R.layout.select_album, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
cancellationToken = new CancellationToken();
albumButtons = view.findViewById(R.id.menu_album);
super.onViewCreated(view, savedInstanceState);
refreshAlbumListView = view.findViewById(R.id.select_album_entries_refresh);
albumListView = view.findViewById(R.id.select_album_entries_list);
refreshAlbumListView.setOnRefreshListener(() -> {
enableButtons();
getBookmarks();
});
albumListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
albumListView.setOnItemClickListener((parent, view17, position, id) -> {
if (position >= 0)
{
MusicDirectory.Entry entry = (MusicDirectory.Entry) parent.getItemAtPosition(position);
if (entry != null)
{
if (entry.isVideo())
{
VideoPlayer.Companion.playVideo(getContext(), entry);
}
else
{
enableButtons();
}
}
}
});
ImageView selectButton = view.findViewById(R.id.select_album_select);
playNowButton = view.findViewById(R.id.select_album_play_now);
ImageView playNextButton = view.findViewById(R.id.select_album_play_next);
ImageView playLastButton = view.findViewById(R.id.select_album_play_last);
pinButton = view.findViewById(R.id.select_album_pin);
unpinButton = view.findViewById(R.id.select_album_unpin);
downloadButton = view.findViewById(R.id.select_album_download);
deleteButton = view.findViewById(R.id.select_album_delete);
ImageView oreButton = view.findViewById(R.id.select_album_more);
emptyView = view.findViewById(R.id.select_album_empty);
selectButton.setVisibility(View.GONE);
playNextButton.setVisibility(View.GONE);
playLastButton.setVisibility(View.GONE);
oreButton.setVisibility(View.GONE);
playNowButton.setOnClickListener(view16 -> playNow(getSelectedSongs(albumListView)));
selectButton.setOnClickListener(view15 -> selectAllOrNone());
pinButton.setOnClickListener(view14 -> {
downloadBackground(true);
selectAll(false, false);
});
unpinButton.setOnClickListener(view13 -> {
unpin();
selectAll(false, false);
});
downloadButton.setOnClickListener(view12 -> {
downloadBackground(false);
selectAll(false, false);
});
deleteButton.setOnClickListener(view1 -> {
delete();
selectAll(false, false);
});
registerForContextMenu(albumListView);
FragmentTitle.Companion.setTitle(this, R.string.button_bar_bookmarks);
enableButtons();
getBookmarks();
}
@Override
public void onDestroyView() {
cancellationToken.cancel();
super.onDestroyView();
}
private void getBookmarks()
{
new LoadTask()
{
@Override
protected MusicDirectory load(MusicService service) throws Exception
{
return Util.getSongsFromBookmarks(service.getBookmarks());
}
}.execute();
}
private void playNow(List<MusicDirectory.Entry> songs)
{
if (!getSelectedSongs(albumListView).isEmpty())
{
int position = songs.get(0).getBookmarkPosition();
mediaPlayerController.getValue().restore(songs, 0, position, true, true);
selectAll(false, false);
}
}
private static List<MusicDirectory.Entry> getSelectedSongs(ListView albumListView)
{
List<MusicDirectory.Entry> songs = new ArrayList<>(10);
if (albumListView != null)
{
int count = albumListView.getCount();
for (int i = 0; i < count; i++)
{
if (albumListView.isItemChecked(i))
{
MusicDirectory.Entry song = (MusicDirectory.Entry) albumListView.getItemAtPosition(i);
if (song != null) songs.add(song);
}
}
}
return songs;
}
private void selectAllOrNone()
{
boolean someUnselected = false;
int count = albumListView.getCount();
for (int i = 0; i < count; i++)
{
if (!albumListView.isItemChecked(i) && albumListView.getItemAtPosition(i) instanceof MusicDirectory.Entry)
{
someUnselected = true;
break;
}
}
selectAll(someUnselected, true);
}
private void selectAll(boolean selected, boolean toast)
{
int count = albumListView.getCount();
int selectedCount = 0;
for (int i = 0; i < count; i++)
{
MusicDirectory.Entry entry = (MusicDirectory.Entry) albumListView.getItemAtPosition(i);
if (entry != null && !entry.isDirectory() && !entry.isVideo())
{
albumListView.setItemChecked(i, selected);
selectedCount++;
}
}
// Display toast: N tracks selected / N tracks unselected
if (toast)
{
int toastResId = selected ? R.string.select_album_n_selected : R.string.select_album_n_unselected;
Util.toast(getContext(), getString(toastResId, selectedCount));
}
enableButtons();
}
private void enableButtons()
{
List<MusicDirectory.Entry> selection = getSelectedSongs(albumListView);
boolean enabled = !selection.isEmpty();
boolean unpinEnabled = false;
boolean deleteEnabled = false;
int pinnedCount = 0;
for (MusicDirectory.Entry song : selection)
{
if (song == null) continue;
DownloadFile downloadFile = mediaPlayerController.getValue().getDownloadFileForSong(song);
if (downloadFile.isWorkDone())
{
deleteEnabled = true;
}
if (downloadFile.isSaved())
{
pinnedCount++;
unpinEnabled = true;
}
}
playNowButton.setVisibility(enabled && deleteEnabled ? View.VISIBLE : View.GONE);
pinButton.setVisibility((enabled && !ActiveServerProvider.Companion.isOffline() && selection.size() > pinnedCount) ? View.VISIBLE : View.GONE);
unpinButton.setVisibility(enabled && unpinEnabled ? View.VISIBLE : View.GONE);
downloadButton.setVisibility(enabled && !deleteEnabled && !ActiveServerProvider.Companion.isOffline() ? View.VISIBLE : View.GONE);
deleteButton.setVisibility(enabled && deleteEnabled ? View.VISIBLE : View.GONE);
}
private void downloadBackground(final boolean save)
{
List<MusicDirectory.Entry> songs = getSelectedSongs(albumListView);
if (songs.isEmpty())
{
selectAll(true, false);
songs = getSelectedSongs(albumListView);
}
downloadBackground(save, songs);
}
private void downloadBackground(final boolean save, final List<MusicDirectory.Entry> songs)
{
Runnable onValid = () -> {
networkAndStorageChecker.getValue().warnIfNetworkOrStorageUnavailable();
mediaPlayerController.getValue().downloadBackground(songs, save);
if (save)
{
Util.toast(getContext(), getResources().getQuantityString(R.plurals.select_album_n_songs_pinned, songs.size(), songs.size()));
}
else
{
Util.toast(getContext(), getResources().getQuantityString(R.plurals.select_album_n_songs_downloaded, songs.size(), songs.size()));
}
};
onValid.run();
}
private void delete()
{
List<MusicDirectory.Entry> songs = getSelectedSongs(albumListView);
if (songs.isEmpty())
{
selectAll(true, false);
songs = getSelectedSongs(albumListView);
}
mediaPlayerController.getValue().delete(songs);
}
private void unpin()
{
List<MusicDirectory.Entry> songs = getSelectedSongs(albumListView);
Util.toast(getContext(), getResources().getQuantityString(R.plurals.select_album_n_songs_unpinned, songs.size(), songs.size()));
mediaPlayerController.getValue().unpin(songs);
}
private abstract class LoadTask extends FragmentBackgroundTask<Pair<MusicDirectory, Boolean>>
{
public LoadTask()
{
super(BookmarksFragment.this.getActivity(), true, refreshAlbumListView, cancellationToken);
}
protected abstract MusicDirectory load(MusicService service) throws Exception;
@Override
protected Pair<MusicDirectory, Boolean> doInBackground() throws Throwable
{
MusicService musicService = MusicServiceFactory.getMusicService();
MusicDirectory dir = load(musicService);
boolean valid = musicService.isLicenseValid();
return new Pair<>(dir, valid);
}
@Override
protected void done(Pair<MusicDirectory, Boolean> result)
{
MusicDirectory musicDirectory = result.first;
List<MusicDirectory.Entry> entries = musicDirectory.getChildren();
int songCount = 0;
for (MusicDirectory.Entry entry : entries)
{
if (!entry.isDirectory())
{
songCount++;
}
}
final int listSize = getArguments() == null? 0 : getArguments().getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0);
if (songCount > 0)
{
pinButton.setVisibility(View.VISIBLE);
unpinButton.setVisibility(View.VISIBLE);
downloadButton.setVisibility(View.VISIBLE);
deleteButton.setVisibility(View.VISIBLE);
playNowButton.setVisibility(View.VISIBLE);
}
else
{
pinButton.setVisibility(View.GONE);
unpinButton.setVisibility(View.GONE);
downloadButton.setVisibility(View.GONE);
deleteButton.setVisibility(View.GONE);
playNowButton.setVisibility(View.GONE);
if (listSize == 0 || result.first.getChildren().size() < listSize)
{
albumButtons.setVisibility(View.GONE);
}
}
enableButtons();
emptyView.setVisibility(entries.isEmpty() ? View.VISIBLE : View.GONE);
albumListView.setAdapter(new EntryAdapter(getContext(), imageLoader.getValue().getImageLoader(), entries, true));
}
}
}

View File

@ -76,8 +76,8 @@ public class LyricsFragment extends Fragment {
{
Bundle arguments = getArguments();
if (arguments == null) return null;
String artist = arguments.getString(Constants.INTENT_EXTRA_NAME_ARTIST);
String title = arguments.getString(Constants.INTENT_EXTRA_NAME_TITLE);
String artist = arguments.getString(Constants.INTENT_ARTIST);
String title = arguments.getString(Constants.INTENT_TITLE);
MusicService musicService = MusicServiceFactory.getMusicService();
return musicService.getLyrics(artist, title);
}

View File

@ -102,9 +102,9 @@ public class PlaylistsFragment extends Fragment {
}
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, playlist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName());
bundle.putString(Constants.INTENT_ID, playlist.getId());
bundle.putString(Constants.INTENT_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_PLAYLIST_NAME, playlist.getName());
Navigation.findNavController(getView()).navigate(R.id.trackCollectionFragment, bundle);
}
});
@ -187,16 +187,16 @@ public class PlaylistsFragment extends Fragment {
downloadHandler.getValue().downloadPlaylist(this, playlist.getId(), playlist.getName(), false, false, false, false, true, false, false);
} else if (itemId == R.id.playlist_menu_play_now) {
bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName());
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true);
bundle.putString(Constants.INTENT_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_PLAYLIST_NAME, playlist.getName());
bundle.putBoolean(Constants.INTENT_AUTOPLAY, true);
Navigation.findNavController(getView()).navigate(R.id.trackCollectionFragment, bundle);
} else if (itemId == R.id.playlist_menu_play_shuffled) {
bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName());
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true);
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_SHUFFLE, true);
bundle.putString(Constants.INTENT_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_PLAYLIST_NAME, playlist.getName());
bundle.putBoolean(Constants.INTENT_AUTOPLAY, true);
bundle.putBoolean(Constants.INTENT_SHUFFLE, true);
Navigation.findNavController(getView()).navigate(R.id.trackCollectionFragment, bundle);
} else if (itemId == R.id.playlist_menu_delete) {
deletePlaylist(playlist);

View File

@ -75,7 +75,7 @@ public class PodcastFragment extends Fragment {
}
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_PODCAST_CHANNEL_ID, pc.getId());
bundle.putString(Constants.INTENT_PODCAST_CHANNEL_ID, pc.getId());
Navigation.findNavController(view).navigate(R.id.trackCollectionFragment, bundle);
}
});

View File

@ -1,593 +0,0 @@
package org.moire.ultrasonic.fragment;
import android.app.Activity;
import android.app.SearchManager;
import android.content.Context;
import android.database.Cursor;
import android.os.Bundle;
import android.view.ContextMenu;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ListAdapter;
import android.widget.ListView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SearchView;
import androidx.fragment.app.Fragment;
import androidx.navigation.Navigation;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.jetbrains.annotations.NotNull;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.Artist;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.domain.SearchCriteria;
import org.moire.ultrasonic.domain.SearchResult;
import org.moire.ultrasonic.service.MediaPlayerController;
import org.moire.ultrasonic.service.MusicService;
import org.moire.ultrasonic.service.MusicServiceFactory;
import org.moire.ultrasonic.subsonic.DownloadHandler;
import org.moire.ultrasonic.subsonic.ImageLoaderProvider;
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker;
import org.moire.ultrasonic.subsonic.ShareHandler;
import org.moire.ultrasonic.subsonic.VideoPlayer;
import org.moire.ultrasonic.util.BackgroundTask;
import org.moire.ultrasonic.util.CancellationToken;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.MergeAdapter;
import org.moire.ultrasonic.util.FragmentBackgroundTask;
import org.moire.ultrasonic.util.Settings;
import org.moire.ultrasonic.util.Util;
import org.moire.ultrasonic.view.ArtistAdapter;
import org.moire.ultrasonic.view.EntryAdapter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import kotlin.Lazy;
import timber.log.Timber;
import static org.koin.java.KoinJavaComponent.inject;
/**
* Initiates a search on the media library and displays the results
*/
public class SearchFragment extends Fragment {
private static int DEFAULT_ARTISTS;
private static int DEFAULT_ALBUMS;
private static int DEFAULT_SONGS;
private ListView list;
private View artistsHeading;
private View albumsHeading;
private View songsHeading;
private TextView notFound;
private View moreArtistsButton;
private View moreAlbumsButton;
private View moreSongsButton;
private SearchResult searchResult;
private MergeAdapter mergeAdapter;
private ArtistAdapter artistAdapter;
private ListAdapter moreArtistsAdapter;
private EntryAdapter albumAdapter;
private ListAdapter moreAlbumsAdapter;
private ListAdapter moreSongsAdapter;
private EntryAdapter songAdapter;
private SwipeRefreshLayout searchRefresh;
private final Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
private final Lazy<ImageLoaderProvider> imageLoaderProvider = inject(ImageLoaderProvider.class);
private final Lazy<DownloadHandler> downloadHandler = inject(DownloadHandler.class);
private final Lazy<ShareHandler> shareHandler = inject(ShareHandler.class);
private final Lazy<NetworkAndStorageChecker> networkAndStorageChecker = inject(NetworkAndStorageChecker.class);
private CancellationToken cancellationToken;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
Util.applyTheme(this.getContext());
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(R.layout.search, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
cancellationToken = new CancellationToken();
FragmentTitle.Companion.setTitle(this, R.string.search_title);
setHasOptionsMenu(true);
DEFAULT_ARTISTS = Settings.getDefaultArtists();
DEFAULT_ALBUMS = Settings.getDefaultAlbums();
DEFAULT_SONGS = Settings.getDefaultSongs();
View buttons = LayoutInflater.from(getContext()).inflate(R.layout.search_buttons, list, false);
if (buttons != null)
{
artistsHeading = buttons.findViewById(R.id.search_artists);
albumsHeading = buttons.findViewById(R.id.search_albums);
songsHeading = buttons.findViewById(R.id.search_songs);
notFound = buttons.findViewById(R.id.search_not_found);
moreArtistsButton = buttons.findViewById(R.id.search_more_artists);
moreAlbumsButton = buttons.findViewById(R.id.search_more_albums);
moreSongsButton = buttons.findViewById(R.id.search_more_songs);
}
list = view.findViewById(R.id.search_list);
searchRefresh = view.findViewById(R.id.search_entries_refresh);
searchRefresh.setEnabled(false); // TODO: It should be enabled if it is a good feature to refresh search results
list.setOnItemClickListener(new AdapterView.OnItemClickListener()
{
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id)
{
if (view == moreArtistsButton)
{
expandArtists();
}
else if (view == moreAlbumsButton)
{
expandAlbums();
}
else if (view == moreSongsButton)
{
expandSongs();
}
else
{
Object item = parent.getItemAtPosition(position);
if (item instanceof Artist)
{
onArtistSelected((Artist) item);
}
else if (item instanceof MusicDirectory.Entry)
{
MusicDirectory.Entry entry = (MusicDirectory.Entry) item;
if (entry.isDirectory())
{
onAlbumSelected(entry, false);
}
else if (entry.isVideo())
{
onVideoSelected(entry);
}
else
{
onSongSelected(entry, true);
}
}
}
}
});
registerForContextMenu(list);
// Fragment was started with a query (e.g. from voice search), try to execute search right away
Bundle arguments = getArguments();
if (arguments != null) {
String query = arguments.getString(Constants.INTENT_EXTRA_NAME_QUERY);
boolean autoPlay = arguments.getBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false);
if (query != null) {
mergeAdapter = new MergeAdapter();
list.setAdapter(mergeAdapter);
search(query, autoPlay);
return;
}
}
// Fragment was started from the Menu, create empty list
populateList();
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
Activity activity = getActivity();
if (activity == null) return;
SearchManager searchManager = (SearchManager) activity.getSystemService(Context.SEARCH_SERVICE);
inflater.inflate(R.menu.search, menu);
MenuItem searchItem = menu.findItem(R.id.search_item);
final SearchView searchView = (SearchView) searchItem.getActionView();
searchView.setSearchableInfo(searchManager.getSearchableInfo(getActivity().getComponentName()));
Bundle arguments = getArguments();
final boolean autoPlay = arguments != null && arguments.getBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false);
String query = arguments == null? null : arguments.getString(Constants.INTENT_EXTRA_NAME_QUERY);
// If started with a query, enter it to the searchView
if (query != null) {
searchView.setQuery(query, false);
searchView.clearFocus();
}
searchView.setOnSuggestionListener(new SearchView.OnSuggestionListener() {
@Override
public boolean onSuggestionSelect(int position) { return true; }
@Override
public boolean onSuggestionClick(int position) {
Timber.d("onSuggestionClick: %d", position);
Cursor cursor= searchView.getSuggestionsAdapter().getCursor();
cursor.moveToPosition(position);
String suggestion = cursor.getString(2); // TODO: Try to do something with this magic const -- 2 is the index of col containing suggestion name.
searchView.setQuery(suggestion,true);
return true;
}
});
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
Timber.d("onQueryTextSubmit: %s", query);
mergeAdapter = new MergeAdapter();
list.setAdapter(mergeAdapter);
searchView.clearFocus();
search(query, autoPlay);
return true;
}
@Override
public boolean onQueryTextChange(String newText) { return true; }
});
searchView.setIconifiedByDefault(false);
searchItem.expandActionView();
}
@Override
public void onCreateContextMenu(@NotNull ContextMenu menu, @NotNull View view, ContextMenu.ContextMenuInfo menuInfo)
{
super.onCreateContextMenu(menu, view, menuInfo);
if (getActivity() == null) return;
AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;
Object selectedItem = list.getItemAtPosition(info.position);
boolean isArtist = selectedItem instanceof Artist;
boolean isAlbum = selectedItem instanceof MusicDirectory.Entry && ((MusicDirectory.Entry) selectedItem).isDirectory();
MenuInflater inflater = getActivity().getMenuInflater();
if (!isArtist && !isAlbum)
{
inflater.inflate(R.menu.select_song_context, menu);
}
else
{
inflater.inflate(R.menu.generic_context_menu, menu);
}
MenuItem shareButton = menu.findItem(R.id.menu_item_share);
MenuItem downloadMenuItem = menu.findItem(R.id.menu_download);
if (downloadMenuItem != null)
{
downloadMenuItem.setVisible(!ActiveServerProvider.Companion.isOffline());
}
if (ActiveServerProvider.Companion.isOffline() || isArtist)
{
if (shareButton != null)
{
shareButton.setVisible(false);
}
}
}
@Override
public boolean onContextItemSelected(MenuItem menuItem)
{
AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo();
if (info == null)
{
return true;
}
Object selectedItem = list.getItemAtPosition(info.position);
Artist artist = selectedItem instanceof Artist ? (Artist) selectedItem : null;
MusicDirectory.Entry entry = selectedItem instanceof MusicDirectory.Entry ? (MusicDirectory.Entry) selectedItem : null;
String entryId = null;
if (entry != null)
{
entryId = entry.getId();
}
String id = artist != null ? artist.getId() : entryId;
if (id == null)
{
return true;
}
List<MusicDirectory.Entry> songs = new ArrayList<>(1);
int itemId = menuItem.getItemId();
if (itemId == R.id.menu_play_now) {
downloadHandler.getValue().downloadRecursively(this, id, false, false, true, false, false, false, false, false);
} else if (itemId == R.id.menu_play_next) {
downloadHandler.getValue().downloadRecursively(this, id, false, true, false, true, false, true, false, false);
} else if (itemId == R.id.menu_play_last) {
downloadHandler.getValue().downloadRecursively(this, id, false, true, false, false, false, false, false, false);
} else if (itemId == R.id.menu_pin) {
downloadHandler.getValue().downloadRecursively(this, id, true, true, false, false, false, false, false, false);
} else if (itemId == R.id.menu_unpin) {
downloadHandler.getValue().downloadRecursively(this, id, false, false, false, false, false, false, true, false);
} else if (itemId == R.id.menu_download) {
downloadHandler.getValue().downloadRecursively(this, id, false, false, false, false, true, false, false, false);
} else if (itemId == R.id.song_menu_play_now) {
if (entry != null) {
songs = new ArrayList<>(1);
songs.add(entry);
downloadHandler.getValue().download(this, false, false, true, false, false, songs);
}
} else if (itemId == R.id.song_menu_play_next) {
if (entry != null) {
songs = new ArrayList<>(1);
songs.add(entry);
downloadHandler.getValue().download(this, true, false, false, true, false, songs);
}
} else if (itemId == R.id.song_menu_play_last) {
if (entry != null) {
songs = new ArrayList<>(1);
songs.add(entry);
downloadHandler.getValue().download(this, true, false, false, false, false, songs);
}
} else if (itemId == R.id.song_menu_pin) {
if (entry != null) {
songs.add(entry);
Util.toast(getContext(), getResources().getQuantityString(R.plurals.select_album_n_songs_pinned, songs.size(), songs.size()));
downloadBackground(true, songs);
}
} else if (itemId == R.id.song_menu_download) {
if (entry != null) {
songs.add(entry);
Util.toast(getContext(), getResources().getQuantityString(R.plurals.select_album_n_songs_downloaded, songs.size(), songs.size()));
downloadBackground(false, songs);
}
} else if (itemId == R.id.song_menu_unpin) {
if (entry != null) {
songs.add(entry);
Util.toast(getContext(), getResources().getQuantityString(R.plurals.select_album_n_songs_unpinned, songs.size(), songs.size()));
mediaPlayerControllerLazy.getValue().unpin(songs);
}
} else if (itemId == R.id.menu_item_share) {
if (entry != null) {
songs = new ArrayList<>(1);
songs.add(entry);
shareHandler.getValue().createShare(this, songs, searchRefresh, cancellationToken);
}
return super.onContextItemSelected(menuItem);
} else {
return super.onContextItemSelected(menuItem);
}
return true;
}
@Override
public void onDestroyView() {
cancellationToken.cancel();
super.onDestroyView();
}
private void downloadBackground(final boolean save, final List<MusicDirectory.Entry> songs)
{
Runnable onValid = new Runnable()
{
@Override
public void run()
{
networkAndStorageChecker.getValue().warnIfNetworkOrStorageUnavailable();
mediaPlayerControllerLazy.getValue().downloadBackground(songs, save);
}
};
onValid.run();
}
private void search(final String query, final boolean autoplay)
{
final int maxArtists = Settings.getMaxArtists();
final int maxAlbums = Settings.getMaxAlbums();
final int maxSongs = Settings.getMaxSongs();
BackgroundTask<SearchResult> task = new FragmentBackgroundTask<SearchResult>(getActivity(), true, searchRefresh, cancellationToken)
{
@Override
protected SearchResult doInBackground() throws Throwable
{
SearchCriteria criteria = new SearchCriteria(query, maxArtists, maxAlbums, maxSongs);
MusicService service = MusicServiceFactory.getMusicService();
return service.search(criteria);
}
@Override
protected void done(SearchResult result)
{
searchResult = result;
populateList();
if (autoplay)
{
autoplay();
}
}
};
task.execute();
}
private void populateList()
{
mergeAdapter = new MergeAdapter();
if (searchResult != null)
{
List<Artist> artists = searchResult.getArtists();
if (!artists.isEmpty())
{
mergeAdapter.addView(artistsHeading);
List<Artist> displayedArtists = new ArrayList<>(artists.subList(0, Math.min(DEFAULT_ARTISTS, artists.size())));
artistAdapter = new ArtistAdapter(getContext(), displayedArtists);
mergeAdapter.addAdapter(artistAdapter);
if (artists.size() > DEFAULT_ARTISTS)
{
moreArtistsAdapter = mergeAdapter.addView(moreArtistsButton, true);
}
}
List<MusicDirectory.Entry> albums = searchResult.getAlbums();
if (!albums.isEmpty())
{
mergeAdapter.addView(albumsHeading);
List<MusicDirectory.Entry> displayedAlbums = new ArrayList<>(albums.subList(0, Math.min(DEFAULT_ALBUMS, albums.size())));
albumAdapter = new EntryAdapter(getContext(), imageLoaderProvider.getValue().getImageLoader(), displayedAlbums, false);
mergeAdapter.addAdapter(albumAdapter);
if (albums.size() > DEFAULT_ALBUMS)
{
moreAlbumsAdapter = mergeAdapter.addView(moreAlbumsButton, true);
}
}
List<MusicDirectory.Entry> songs = searchResult.getSongs();
if (!songs.isEmpty())
{
mergeAdapter.addView(songsHeading);
List<MusicDirectory.Entry> displayedSongs = new ArrayList<>(songs.subList(0, Math.min(DEFAULT_SONGS, songs.size())));
songAdapter = new EntryAdapter(getContext(), imageLoaderProvider.getValue().getImageLoader(), displayedSongs, false);
mergeAdapter.addAdapter(songAdapter);
if (songs.size() > DEFAULT_SONGS)
{
moreSongsAdapter = mergeAdapter.addView(moreSongsButton, true);
}
}
boolean empty = searchResult.getArtists().isEmpty() && searchResult.getAlbums().isEmpty() && searchResult.getSongs().isEmpty();
if (empty) mergeAdapter.addView(notFound, false);
}
list.setAdapter(mergeAdapter);
}
private void expandArtists()
{
artistAdapter.clear();
for (Artist artist : searchResult.getArtists())
{
artistAdapter.add(artist);
}
artistAdapter.notifyDataSetChanged();
mergeAdapter.removeAdapter(moreArtistsAdapter);
mergeAdapter.notifyDataSetChanged();
}
private void expandAlbums()
{
albumAdapter.clear();
for (MusicDirectory.Entry album : searchResult.getAlbums())
{
albumAdapter.add(album);
}
albumAdapter.notifyDataSetChanged();
mergeAdapter.removeAdapter(moreAlbumsAdapter);
mergeAdapter.notifyDataSetChanged();
}
private void expandSongs()
{
songAdapter.clear();
for (MusicDirectory.Entry song : searchResult.getSongs())
{
songAdapter.add(song);
}
songAdapter.notifyDataSetChanged();
mergeAdapter.removeAdapter(moreSongsAdapter);
mergeAdapter.notifyDataSetChanged();
}
private void onArtistSelected(Artist artist)
{
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, artist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, artist.getId());
Navigation.findNavController(getView()).navigate(R.id.searchToSelectAlbum, bundle);
}
private void onAlbumSelected(MusicDirectory.Entry album, boolean autoplay)
{
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, album.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, album.getTitle());
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, album.isDirectory());
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, autoplay);
Navigation.findNavController(getView()).navigate(R.id.searchToSelectAlbum, bundle);
}
private void onSongSelected(MusicDirectory.Entry song, boolean append)
{
MediaPlayerController mediaPlayerController = mediaPlayerControllerLazy.getValue();
if (mediaPlayerController != null)
{
if (!append)
{
mediaPlayerController.clear();
}
mediaPlayerController.addToPlaylist(Collections.singletonList(song), false, false, false, false, false);
if (true)
{
mediaPlayerController.play(mediaPlayerController.getPlaylistSize() - 1);
}
Util.toast(getContext(), getResources().getQuantityString(R.plurals.select_album_n_songs_added, 1, 1));
}
}
private void onVideoSelected(MusicDirectory.Entry entry)
{
VideoPlayer.Companion.playVideo(getContext(), entry);
}
private void autoplay()
{
if (!searchResult.getSongs().isEmpty())
{
onSongSelected(searchResult.getSongs().get(0), false);
}
else if (!searchResult.getAlbums().isEmpty())
{
onAlbumSelected(searchResult.getAlbums().get(0), true);
}
}
}

View File

@ -75,9 +75,9 @@ public class SelectGenreFragment extends Fragment {
if (genre != null)
{
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_GENRE_NAME, genre.getName());
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, Settings.getMaxSongs());
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0);
bundle.putString(Constants.INTENT_GENRE_NAME, genre.getName());
bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, Settings.getMaxSongs());
bundle.putInt(Constants.INTENT_ALBUM_LIST_OFFSET, 0);
Navigation.findNavController(view).navigate(R.id.trackCollectionFragment, bundle);
}
}

View File

@ -104,8 +104,8 @@ public class SharesFragment extends Fragment {
}
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_SHARE_ID, share.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_SHARE_NAME, share.getName());
bundle.putString(Constants.INTENT_SHARE_ID, share.getId());
bundle.putString(Constants.INTENT_SHARE_NAME, share.getName());
Navigation.findNavController(view).navigate(R.id.trackCollectionFragment, bundle);
}
});

View File

@ -191,7 +191,7 @@ public class UltrasonicAppWidgetProvider extends AppWidgetProvider
{
Intent intent = new Intent(context, NavigationActivity.class).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
if (playerActive)
intent.putExtra(Constants.INTENT_EXTRA_NAME_SHOW_PLAYER, true);
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true);
intent.setAction("android.intent.action.MAIN");
intent.addCategory("android.intent.category.LAUNCHER");

View File

@ -1,114 +0,0 @@
package org.moire.ultrasonic.util;
import android.content.Context;
import org.moire.ultrasonic.domain.MusicDirectory;
import java.util.HashSet;
import java.util.Set;
public class AlbumHeader
{
private boolean isAllVideo;
private long totalDuration;
private Set<String> artists;
private Set<String> grandParents;
private Set<String> genres;
private Set<Integer> years;
public boolean getIsAllVideo()
{
return isAllVideo;
}
public long getTotalDuration()
{
return totalDuration;
}
public Set<String> getArtists()
{
return artists;
}
public Set<String> getGrandParents()
{
return this.grandParents;
}
public Set<String> getGenres()
{
return this.genres;
}
public Set<Integer> getYears()
{
return this.years;
}
public AlbumHeader()
{
this.artists = new HashSet<String>();
this.grandParents = new HashSet<String>();
this.genres = new HashSet<String>();
this.years = new HashSet<Integer>();
this.isAllVideo = true;
this.totalDuration = 0;
}
public static AlbumHeader processEntries(Context context, Iterable<MusicDirectory.Entry> entries)
{
AlbumHeader albumHeader = new AlbumHeader();
for (MusicDirectory.Entry entry : entries)
{
if (!entry.isVideo())
{
albumHeader.isAllVideo = false;
}
if (!entry.isDirectory())
{
if (Settings.getShouldUseFolderForArtistName())
{
albumHeader.processGrandParents(entry);
}
if (entry.getArtist() != null)
{
Integer duration = entry.getDuration();
if (duration != null)
{
albumHeader.totalDuration += duration;
}
albumHeader.artists.add(entry.getArtist());
}
if (entry.getGenre() != null)
{
albumHeader.genres.add(entry.getGenre());
}
if (entry.getYear() != null)
{
albumHeader.years.add(entry.getYear());
}
}
}
return albumHeader;
}
private void processGrandParents(MusicDirectory.Entry entry)
{
String grandParent = Util.getGrandparent(entry.getPath());
if (grandParent != null)
{
this.grandParents.add(grandParent);
}
}
}

View File

@ -1,72 +0,0 @@
package org.moire.ultrasonic.util;
import org.moire.ultrasonic.domain.MusicDirectory;
import java.io.Serializable;
import java.util.Comparator;
public class EntryByDiscAndTrackComparator implements Comparator<MusicDirectory.Entry>, Serializable
{
private static final long serialVersionUID = 5540441864560835223L;
@Override
public int compare(MusicDirectory.Entry x, MusicDirectory.Entry y)
{
Integer discX = x.getDiscNumber();
Integer discY = y.getDiscNumber();
Integer trackX = x.getTrack();
Integer trackY = y.getTrack();
String albumX = x.getAlbum();
String albumY = y.getAlbum();
String pathX = x.getPath();
String pathY = y.getPath();
int albumComparison = compare(albumX, albumY);
if (albumComparison != 0)
{
return albumComparison;
}
int discComparison = compare(discX == null ? 0 : discX, discY == null ? 0 : discY);
if (discComparison != 0)
{
return discComparison;
}
int trackComparison = compare(trackX == null ? 0 : trackX, trackY == null ? 0 : trackY);
if (trackComparison != 0)
{
return trackComparison;
}
return compare(pathX == null ? "" : pathX, pathY == null ? "" : pathY);
}
private static int compare(long a, long b)
{
return Long.compare(a, b);
}
private static int compare(String a, String b)
{
if (a == null && b == null)
{
return 0;
}
if (a == null)
{
return -1;
}
if (b == null)
{
return 1;
}
return a.compareTo(b);
}
}

View File

@ -19,6 +19,7 @@ public abstract class LoadingTask<T> extends BackgroundTask<T>
this.cancel = cancel;
}
@Override
public void execute()
{

View File

@ -100,8 +100,8 @@ public class ShufflePlayBuffer
synchronized (buffer)
{
buffer.addAll(songs.getChildren());
Timber.i("Refilled shuffle play buffer with %d songs.", songs.getChildren().size());
buffer.addAll(songs.getTracks());
Timber.i("Refilled shuffle play buffer with %d songs.", songs.getTracks().size());
}
}
catch (Exception x)

View File

@ -1,179 +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.view;
import android.content.Context;
import android.graphics.drawable.Drawable;
import timber.log.Timber;
import android.view.LayoutInflater;
import android.view.View;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.service.MusicService;
import org.moire.ultrasonic.service.MusicServiceFactory;
import org.moire.ultrasonic.imageloader.ImageLoader;
import org.moire.ultrasonic.util.Settings;
import org.moire.ultrasonic.util.Util;
/**
* Used to display albums in a {@code ListView}.
*
* @author Sindre Mehus
*/
public class AlbumView extends UpdateView
{
private static Drawable starDrawable;
private static Drawable starHollowDrawable;
private static String theme;
private final Context context;
private MusicDirectory.Entry entry;
private EntryAdapter.AlbumViewHolder viewHolder;
private final ImageLoader imageLoader;
private boolean maximized = false;
public AlbumView(Context context, ImageLoader imageLoader)
{
super(context);
this.context = context;
this.imageLoader = imageLoader;
String theme = Settings.getTheme();
boolean themesMatch = theme.equals(AlbumView.theme);
AlbumView.theme = theme;
if (starHollowDrawable == null || !themesMatch)
{
starHollowDrawable = Util.getDrawableFromAttribute(context, R.attr.star_hollow);
}
if (starDrawable == null || !themesMatch)
{
starDrawable = Util.getDrawableFromAttribute(context, R.attr.star_full);
}
}
public void setLayout()
{
LayoutInflater.from(context).inflate(R.layout.album_list_item_legacy, this, true);
viewHolder = new EntryAdapter.AlbumViewHolder();
viewHolder.title = findViewById(R.id.album_title);
viewHolder.artist = findViewById(R.id.album_artist);
viewHolder.cover_art = findViewById(R.id.album_coverart);
viewHolder.star = findViewById(R.id.album_star);
setTag(viewHolder);
}
public void setViewHolder(EntryAdapter.AlbumViewHolder viewHolder)
{
this.viewHolder = viewHolder;
this.viewHolder.cover_art.invalidate();
setTag(this.viewHolder);
}
public MusicDirectory.Entry getEntry()
{
return this.entry;
}
public boolean isMaximized() {
return maximized;
}
public void maximizeOrMinimize() {
maximized = !maximized;
if (this.viewHolder.title != null) {
this.viewHolder.title.setSingleLine(!maximized);
}
if (this.viewHolder.artist != null) {
this.viewHolder.artist.setSingleLine(!maximized);
}
}
public void setAlbum(final MusicDirectory.Entry album)
{
viewHolder.cover_art.setTag(album);
imageLoader.loadImage(viewHolder.cover_art, album, false, 0);
this.entry = album;
String title = album.getTitle();
String artist = album.getArtist();
boolean starred = album.getStarred();
viewHolder.title.setText(title);
viewHolder.artist.setText(artist);
viewHolder.artist.setVisibility(artist == null ? View.GONE : View.VISIBLE);
viewHolder.star.setImageDrawable(starred ? starDrawable : starHollowDrawable);
if (ActiveServerProvider.Companion.isOffline() || "-1".equals(album.getId()))
{
viewHolder.star.setVisibility(View.GONE);
}
else
{
viewHolder.star.setOnClickListener(new View.OnClickListener()
{
@Override
public void onClick(View view)
{
final boolean isStarred = album.getStarred();
final String id = album.getId();
if (!isStarred)
{
viewHolder.star.setImageDrawable(starDrawable);
album.setStarred(true);
}
else
{
viewHolder.star.setImageDrawable(starHollowDrawable);
album.setStarred(false);
}
final MusicService musicService = MusicServiceFactory.getMusicService();
new Thread(new Runnable()
{
@Override
public void run()
{
boolean useId3 = Settings.getShouldUseId3Tags();
try
{
if (!isStarred)
{
musicService.star(!useId3 ? id : null, useId3 ? id : null, null);
}
else
{
musicService.unstar(!useId3 ? id : null, useId3 ? id : null, null);
}
}
catch (Exception e)
{
Timber.e(e);
}
}
}).start();
}
});
}
}
}

View File

@ -1,116 +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 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.view;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.SectionIndexer;
import android.widget.TextView;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Artist;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
/**
* @author Sindre Mehus
*/
public class ArtistAdapter extends ArrayAdapter<Artist> implements SectionIndexer
{
private final LayoutInflater layoutInflater;
// Both arrays are indexed by section ID.
private final Object[] sections;
private final Integer[] positions;
public ArtistAdapter(Context context, List<Artist> artists)
{
super(context, R.layout.generic_text_list_item, artists);
layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
Collection<String> sectionSet = new LinkedHashSet<String>(30);
List<Integer> positionList = new ArrayList<Integer>(30);
for (int i = 0; i < artists.size(); i++)
{
Artist artist = artists.get(i);
String index = artist.getIndex();
if (!sectionSet.contains(index))
{
sectionSet.add(index);
positionList.add(i);
}
}
sections = sectionSet.toArray(new Object[0]);
positions = positionList.toArray(new Integer[0]);
}
@NonNull
@Override
public View getView(
int position,
@Nullable View convertView,
@NonNull ViewGroup parent
) {
View rowView = convertView;
if (rowView == null) {
rowView = layoutInflater.inflate(R.layout.generic_text_list_item, parent, false);
}
((TextView) rowView).setText(getItem(position).getName());
return rowView;
}
@Override
public Object[] getSections()
{
return sections;
}
@Override
public int getPositionForSection(int section)
{
return positions.length > section ? positions[section] : 0;
}
@Override
public int getSectionForPosition(int pos)
{
for (int i = 0; i < sections.length - 1; i++)
{
if (pos < positions[i + 1])
{
return i;
}
}
return sections.length - 1;
}
}

View File

@ -1,144 +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 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.CheckedTextView;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.moire.ultrasonic.domain.MusicDirectory.Entry;
import org.moire.ultrasonic.imageloader.ImageLoader;
import java.util.List;
/**
* This is the adapter for the display of a single list item (song, album, etc)
*
* @author Sindre Mehus
*/
public class EntryAdapter extends ArrayAdapter<Entry>
{
private final Context context;
private final ImageLoader imageLoader;
private final boolean checkable;
public EntryAdapter(Context context, ImageLoader imageLoader, List<Entry> entries, boolean checkable)
{
super(context, android.R.layout.simple_list_item_1, entries);
this.context = context;
this.imageLoader = imageLoader;
this.checkable = checkable;
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
Entry entry = getItem(position);
if (entry.isDirectory())
{
AlbumView view;
if (convertView instanceof AlbumView)
{
AlbumView currentView = (AlbumView) convertView;
if (currentView.getEntry().equals(entry))
{
return currentView;
}
else
{
AlbumViewHolder viewHolder = (AlbumViewHolder) currentView.getTag();
view = currentView;
view.setViewHolder(viewHolder);
}
}
else
{
view = new AlbumView(context, imageLoader);
view.setLayout();
}
view.setAlbum(entry);
return view;
}
else
{
SongView view;
if (convertView instanceof SongView)
{
SongView currentView = (SongView) convertView;
if (currentView.getEntry().equals(entry))
{
currentView.update();
return currentView;
}
else
{
SongViewHolder viewHolder = (SongViewHolder) convertView.getTag();
view = currentView;
view.setViewHolder(viewHolder);
}
}
else
{
view = new SongView(context);
view.setLayout(entry);
}
view.setSong(entry, checkable, false);
return view;
}
}
public static class SongViewHolder
{
CheckedTextView check;
TextView track;
TextView title;
TextView status;
TextView artist;
TextView duration;
LinearLayout rating;
ImageView fiveStar1;
ImageView fiveStar2;
ImageView fiveStar3;
ImageView fiveStar4;
ImageView fiveStar5;
ImageView star;
ImageView drag;
}
public static class AlbumViewHolder
{
TextView artist;
ImageView cover_art;
ImageView star;
TextView title;
}
}

View File

@ -48,7 +48,7 @@ public class GenreAdapter extends ArrayAdapter<Genre> implements SectionIndexer
public GenreAdapter(Context context, List<Genre> genres)
{
super(context, R.layout.generic_text_list_item, genres);
super(context, R.layout.list_item_generic, genres);
layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
@ -75,7 +75,7 @@ public class GenreAdapter extends ArrayAdapter<Genre> implements SectionIndexer
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View rowView = convertView;
if (rowView == null) {
rowView = layoutInflater.inflate(R.layout.generic_text_list_item, parent, false);
rowView = layoutInflater.inflate(R.layout.list_item_generic, parent, false);
}
((TextView) rowView).setText(getItem(position).getName());

View File

@ -20,7 +20,7 @@ package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.LayoutInflater;
import android.widget.TextView;
import android.widget.LinearLayout;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Playlist;
@ -30,9 +30,9 @@ import org.moire.ultrasonic.domain.Playlist;
*
* @author Sindre Mehus
*/
public class PlaylistView extends UpdateView
public class PlaylistView extends LinearLayout
{
private Context context;
private final Context context;
private PlaylistAdapter.ViewHolder viewHolder;
public PlaylistView(Context context)
@ -45,7 +45,7 @@ public class PlaylistView extends UpdateView
{
LayoutInflater.from(context).inflate(R.layout.playlist_list_item, this, true);
viewHolder = new PlaylistAdapter.ViewHolder();
viewHolder.name = (TextView) findViewById(R.id.playlist_name);
viewHolder.name = findViewById(R.id.playlist_name);
setTag(viewHolder);
}
@ -58,6 +58,5 @@ public class PlaylistView extends UpdateView
public void setPlaylist(Playlist playlist)
{
viewHolder.name.setText(playlist.getName());
update();
}
}

View File

@ -20,7 +20,7 @@ package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.LayoutInflater;
import android.widget.TextView;
import android.widget.LinearLayout;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Playlist;
@ -30,12 +30,12 @@ import org.moire.ultrasonic.domain.Playlist;
*
* @author Sindre Mehus
*/
public class PodcatsChannelItemView extends UpdateView
public class PodcastChannelItemView extends LinearLayout
{
private Context context;
private final Context context;
private PlaylistAdapter.ViewHolder viewHolder;
public PodcatsChannelItemView(Context context)
public PodcastChannelItemView(Context context)
{
super(context);
this.context = context;
@ -45,7 +45,7 @@ public class PodcatsChannelItemView extends UpdateView
{
LayoutInflater.from(context).inflate(R.layout.playlist_list_item, this, true);
viewHolder = new PlaylistAdapter.ViewHolder();
viewHolder.name = (TextView) findViewById(R.id.playlist_name);
viewHolder.name = findViewById(R.id.playlist_name);
setTag(viewHolder);
}
@ -58,6 +58,5 @@ public class PodcatsChannelItemView extends UpdateView
public void setPlaylist(Playlist playlist)
{
viewHolder.name.setText(playlist.getName());
update();
}
}

View File

@ -20,7 +20,7 @@ package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.LayoutInflater;
import android.widget.TextView;
import android.widget.LinearLayout;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Share;
@ -30,9 +30,9 @@ import org.moire.ultrasonic.domain.Share;
*
* @author Joshua Bahnsen
*/
public class ShareView extends UpdateView
public class ShareView extends LinearLayout
{
private Context context;
private final Context context;
private ShareAdapter.ViewHolder viewHolder;
public ShareView(Context context)
@ -45,8 +45,8 @@ public class ShareView extends UpdateView
{
LayoutInflater.from(context).inflate(R.layout.share_list_item, this, true);
viewHolder = new ShareAdapter.ViewHolder();
viewHolder.url = (TextView) findViewById(R.id.share_url);
viewHolder.description = (TextView) findViewById(R.id.share_description);
viewHolder.url = findViewById(R.id.share_url);
viewHolder.description = findViewById(R.id.share_description);
setTag(viewHolder);
}
@ -60,6 +60,5 @@ public class ShareView extends UpdateView
{
viewHolder.url.setText(share.getName());
viewHolder.description.setText(share.getDescription());
update();
}
}

View File

@ -1,55 +0,0 @@
package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.service.DownloadFile;
import java.util.List;
public class SongListAdapter extends ArrayAdapter<DownloadFile>
{
Context context;
public SongListAdapter(Context context, final List<DownloadFile> entries)
{
super(context, android.R.layout.simple_list_item_1, entries);
this.context = context;
}
@Override
public View getView(final int position, final View convertView, final ViewGroup parent)
{
DownloadFile downloadFile = getItem(position);
MusicDirectory.Entry entry = downloadFile.getSong();
SongView view;
if (convertView instanceof SongView)
{
SongView currentView = (SongView) convertView;
if (currentView.getEntry().equals(entry))
{
currentView.update();
return currentView;
}
else
{
EntryAdapter.SongViewHolder viewHolder = (EntryAdapter.SongViewHolder) convertView.getTag();
view = currentView;
view.setViewHolder(viewHolder);
}
}
else
{
view = new SongView(this.context);
view.setLayout(entry);
}
view.setSong(entry, false, true);
return view;
}
}

View File

@ -1,155 +0,0 @@
package org.moire.ultrasonic.view;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.LinearLayout;
import org.moire.ultrasonic.util.Settings;
import org.moire.ultrasonic.util.Util;
import java.util.ArrayList;
import java.util.Collection;
import java.util.WeakHashMap;
import timber.log.Timber;
/**
* A View that is periodically refreshed
* @deprecated
* Use LiveData to ensure that the content is up-to-date
**/
public class UpdateView extends LinearLayout
{
private static final WeakHashMap<UpdateView, ?> INSTANCES = new WeakHashMap<UpdateView, Object>();
private static Handler backgroundHandler;
private static Handler uiHandler;
private static Runnable updateRunnable;
public UpdateView(Context context)
{
super(context);
setLayoutParams(new AbsListView.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
INSTANCES.put(this, null);
startUpdater();
}
@Override
public void setPressed(boolean pressed)
{
}
private static synchronized void startUpdater()
{
if (uiHandler != null)
{
return;
}
uiHandler = new Handler();
updateRunnable = new Runnable()
{
@Override
public void run()
{
updateAll();
}
};
new Thread(new Runnable()
{
@Override
public void run()
{
Thread.currentThread().setName("startUpdater");
Looper.prepare();
backgroundHandler = new Handler(Looper.myLooper());
uiHandler.post(updateRunnable);
Looper.loop();
}
}).start();
}
private static void updateAll()
{
try
{
Collection<UpdateView> views = new ArrayList<UpdateView>();
for (UpdateView view : INSTANCES.keySet())
{
if (view.isShown())
{
views.add(view);
}
}
updateAllLive(views);
}
catch (Throwable x)
{
Timber.w(x, "Error when updating song views.");
}
}
private static void updateAllLive(final Iterable<UpdateView> views)
{
final Runnable runnable = new Runnable()
{
@Override
public void run()
{
try
{
for (UpdateView view : views)
{
view.update();
}
}
catch (Throwable x)
{
Timber.w(x, "Error when updating song views.");
}
uiHandler.postDelayed(updateRunnable, Settings.getViewRefreshInterval());
}
};
backgroundHandler.post(new Runnable()
{
@Override
public void run()
{
try
{
Thread.currentThread().setName("updateAllLive-Background");
for (UpdateView view : views)
{
view.updateBackground();
}
uiHandler.post(runnable);
}
catch (Throwable x)
{
Timber.w(x, "Error when updating song views.");
}
}
});
}
protected void updateBackground()
{
}
protected void update()
{
}
}

View File

@ -39,7 +39,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSettingDao
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.fragment.OnBackPressedHandler
import org.moire.ultrasonic.fragment.ServerSettingsModel
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.MediaPlayerController
@ -315,7 +315,7 @@ class NavigationActivity : AppCompatActivity() {
super.onNewIntent(intent)
if (intent == null) return
if (intent.getBooleanExtra(Constants.INTENT_EXTRA_NAME_SHOW_PLAYER, false)) {
if (intent.getBooleanExtra(Constants.INTENT_SHOW_PLAYER, false)) {
findNavController(R.id.nav_host_fragment).navigate(R.id.playerFragment)
return
}
@ -331,8 +331,8 @@ class NavigationActivity : AppCompatActivity() {
suggestions.saveRecentQuery(query, null)
val bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_QUERY, query)
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, autoPlay)
bundle.putString(Constants.INTENT_QUERY, query)
bundle.putBoolean(Constants.INTENT_AUTOPLAY, autoPlay)
findNavController(R.id.nav_host_fragment).navigate(R.id.searchFragment, bundle)
}
}

View File

@ -0,0 +1,91 @@
package org.moire.ultrasonic.adapters
import java.util.HashSet
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.util.Settings.shouldUseFolderForArtistName
import org.moire.ultrasonic.util.Util.getGrandparent
class AlbumHeader(
var entries: List<MusicDirectory.Child>,
var name: String?
) : Identifiable {
var isAllVideo: Boolean
private set
var totalDuration: Long
private set
var childCount = 0
private val _artists: MutableSet<String>
private val _grandParents: MutableSet<String>
private val _genres: MutableSet<String>
private val _years: MutableSet<Int>
val artists: Set<String>
get() = _artists
val grandParents: Set<String>
get() = _grandParents
val genres: Set<String>
get() = _genres
val years: Set<Int>
get() = _years
private fun processGrandParents(entry: MusicDirectory.Child) {
val grandParent = getGrandparent(entry.path)
if (grandParent != null) {
_grandParents.add(grandParent)
}
}
@Suppress("NestedBlockDepth")
private fun processEntries(list: List<MusicDirectory.Child>) {
entries = list
childCount = entries.size
for (entry in entries) {
if (!entry.isVideo) {
isAllVideo = false
}
if (!entry.isDirectory) {
if (shouldUseFolderForArtistName) {
processGrandParents(entry)
}
if (entry.artist != null) {
val duration = entry.duration
if (duration != null) {
totalDuration += duration.toLong()
}
_artists.add(entry.artist!!)
}
if (entry.genre != null) {
_genres.add(entry.genre!!)
}
if (entry.year != null) {
_years.add(entry.year!!)
}
}
}
}
init {
_artists = HashSet()
_grandParents = HashSet()
_genres = HashSet()
_years = HashSet()
isAllVideo = true
totalDuration = 0
processEntries(entries)
}
override val id: String
get() = "HEADER"
override val longId: Long
get() = -1L
}

View File

@ -1,21 +1,24 @@
/*
* AlbumRowAdapter.kt
* AlbumRowBinder.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
package org.moire.ultrasonic.adapters
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import java.lang.Exception
import com.drakeet.multitype.ItemViewBinder
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.imageloader.ImageLoader
@ -27,22 +30,12 @@ import timber.log.Timber
/**
* Creates a Row in a RecyclerView which contains the details of an Album
*/
class AlbumRowAdapter(
itemList: List<MusicDirectory.Entry>,
onItemClick: (MusicDirectory.Entry) -> Unit,
onContextMenuClick: (MenuItem, MusicDirectory.Entry) -> Boolean,
class AlbumRowBinder(
val onItemClick: (MusicDirectory.Album) -> Unit,
val onContextMenuClick: (MenuItem, MusicDirectory.Album) -> Boolean,
private val imageLoader: ImageLoader,
onMusicFolderUpdate: (String?) -> Unit,
context: Context,
) : GenericRowAdapter<MusicDirectory.Entry>(
onItemClick,
onContextMenuClick,
onMusicFolderUpdate
) {
init {
super.submitList(itemList)
}
context: Context
) : ItemViewBinder<MusicDirectory.Album, AlbumRowBinder.ViewHolder>(), KoinComponent {
private val starDrawable: Drawable =
Util.getDrawableFromAttribute(context, R.attr.star_full)
@ -50,33 +43,30 @@ class AlbumRowAdapter(
Util.getDrawableFromAttribute(context, R.attr.star_hollow)
// Set our layout files
override val layout = R.layout.album_list_item
override val contextMenuLayout = R.menu.artist_context_menu
val layout = R.layout.list_item_album
val contextMenuLayout = R.menu.context_menu_artist
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) {
val listPosition = if (selectFolderHeader != null) position - 1 else position
val entry = currentList[listPosition]
holder.album.text = entry.title
holder.artist.text = entry.artist
holder.details.setOnClickListener { onItemClick(entry) }
holder.details.setOnLongClickListener { view -> createPopupMenu(view, listPosition) }
holder.coverArtId = entry.coverArt
holder.star.setImageDrawable(if (entry.starred) starDrawable else starHollowDrawable)
holder.star.setOnClickListener { onStarClick(entry, holder.star) }
override fun onBindViewHolder(holder: ViewHolder, item: MusicDirectory.Album) {
holder.album.text = item.title
holder.artist.text = item.artist
holder.details.setOnClickListener { onItemClick(item) }
holder.details.setOnLongClickListener {
val popup = Utils.createPopupMenu(holder.itemView)
imageLoader.loadImage(
holder.coverArt, entry,
false, 0, R.drawable.unknown_album
)
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, item)
}
true
}
}
holder.coverArtId = item.coverArt
holder.star.setImageDrawable(if (item.starred) starDrawable else starHollowDrawable)
holder.star.setOnClickListener { onStarClick(item, holder.star) }
override fun getItemCount(): Int {
if (selectFolderHeader != null)
return currentList.size + 1
else
return currentList.size
imageLoader.loadImage(
holder.coverArt, item,
false, 0, R.drawable.unknown_album
)
}
/**
@ -88,22 +78,15 @@ class AlbumRowAdapter(
var album: TextView = view.findViewById(R.id.album_title)
var artist: TextView = view.findViewById(R.id.album_artist)
var details: LinearLayout = view.findViewById(R.id.row_album_details)
var coverArt: ImageView = view.findViewById(R.id.album_coverart)
var coverArt: ImageView = view.findViewById(R.id.coverart)
var star: ImageView = view.findViewById(R.id.album_star)
var coverArtId: String? = null
}
/**
* Creates an instance of our ViewHolder class
*/
override fun newViewHolder(view: View): RecyclerView.ViewHolder {
return ViewHolder(view)
}
/**
* Handles the star / unstar action for an album
*/
private fun onStarClick(entry: MusicDirectory.Entry, star: ImageView) {
private fun onStarClick(entry: MusicDirectory.Album, star: ImageView) {
entry.starred = !entry.starred
star.setImageDrawable(if (entry.starred) starDrawable else starHollowDrawable)
val musicService = getMusicService()
@ -128,4 +111,8 @@ class AlbumRowAdapter(
}
}.start()
}
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false))
}
}

View File

@ -0,0 +1,126 @@
/*
* ArtistRowAdapter.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.adapters
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
/**
* Creates a Row in a RecyclerView which contains the details of an Artist
*/
class ArtistRowBinder(
val onItemClick: (ArtistOrIndex) -> Unit,
val onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean,
private val imageLoader: ImageLoader,
private val enableSections: Boolean = true
) : ItemViewBinder<ArtistOrIndex, ArtistRowBinder.ViewHolder>(),
KoinComponent,
Utils.SectionedBinder {
val layout = R.layout.list_item_artist
val contextMenuLayout = R.menu.context_menu_artist
override fun onBindViewHolder(holder: ViewHolder, item: ArtistOrIndex) {
holder.textView.text = item.name
holder.section.text = getSectionForDisplay(item)
holder.section.isVisible = enableSections
holder.layout.setOnClickListener { onItemClick(item) }
holder.layout.setOnLongClickListener {
val popup = Utils.createPopupMenu(holder.itemView, contextMenuLayout)
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, item)
}
true
}
holder.coverArtId = item.coverArt
if (Settings.shouldShowArtistPicture) {
holder.coverArt.visibility = View.VISIBLE
val key = FileUtil.getArtistArtKey(item.name, false)
imageLoader.loadImage(
view = holder.coverArt,
id = holder.coverArtId,
key = key,
large = false,
size = 0,
defaultResourceId = R.drawable.ic_contact_picture
)
} else {
holder.coverArt.visibility = View.GONE
}
}
override fun getSectionName(item: Identifiable): String {
val index = adapter.items.indexOf(item)
if (index == -1 || item !is ArtistOrIndex) return ""
return getSectionFromName(item.name ?: "")
}
private fun getSectionForDisplay(item: ArtistOrIndex): String {
val index = adapter.items.indexOf(item)
if (index == -1) return " "
if (index == 0) return getSectionFromName(item.name ?: " ")
val previousItem = adapter.items[index - 1]
val previousSectionKey: String
if (previousItem is ArtistOrIndex) {
previousSectionKey = getSectionFromName(previousItem.name ?: " ")
} else {
previousSectionKey = " "
}
val currentSectionKey = getSectionFromName(item.name ?: "")
return if (previousSectionKey == currentSectionKey) "" else currentSectionKey
}
private fun getSectionFromName(name: String): String {
var section = name.first().uppercaseChar()
if (!section.isLetter()) section = '#'
return section.toString()
}
/**
* Creates an instance of our ViewHolder class
*/
class ViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var section: TextView = itemView.findViewById(R.id.row_section)
var textView: TextView = itemView.findViewById(R.id.row_artist_name)
var layout: RelativeLayout = itemView.findViewById(R.id.containing_layout)
var coverArt: ImageView = itemView.findViewById(R.id.coverart)
var coverArtId: String? = null
}
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false))
}
}

View File

@ -0,0 +1,236 @@
/*
* BaseAdapter.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.adapters
import android.annotation.SuppressLint
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import androidx.recyclerview.widget.DiffUtil
import com.drakeet.multitype.MultiTypeAdapter
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.util.BoundedTreeSet
import timber.log.Timber
/**
* The BaseAdapter which extends the MultiTypeAdapter from an external library.
* It provides selection support as well as Diffing the submitted lists for performance.
*
* It should be kept generic enough that it can be used a Base for all lists in the app.
*/
@Suppress("unused", "UNUSED_PARAMETER")
class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView.SectionedAdapter {
// Update the BoundedTreeSet if selection type is changed
internal var selectionType: SelectionType = SelectionType.MULTIPLE
set(newValue) {
field = newValue
selectedSet.setMaxSize(newValue.size)
}
internal var selectedSet: BoundedTreeSet<Long> = BoundedTreeSet(selectionType.size)
internal var selectionRevision: MutableLiveData<Int> = MutableLiveData(0)
private val diffCallback = GenericDiffCallback<T>()
init {
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return getItem(position).longId
}
private fun getItem(position: Int): T {
return mDiffer.currentList[position]
}
override var items: List<Any>
get() = getCurrentList()
set(value) {
throw IllegalAccessException("You must use submitList() to add data to the Adapter")
}
var mDiffer: AsyncListDiffer<T> = AsyncListDiffer(
AdapterListUpdateCallback(this),
AsyncDifferConfig.Builder(diffCallback).build()
)
private val mListener =
ListListener<T> { previousList, currentList ->
this@BaseAdapter.onCurrentListChanged(
previousList,
currentList
)
}
init {
mDiffer.addListListener(mListener)
}
/**
* Submits a new list to be diffed, and displayed.
*
*
* If a list is already being displayed, a diff will be computed on a background thread, which
* will dispatch Adapter.notifyItem events on the main thread.
*
* @param list The new list to be displayed.
*/
fun submitList(list: List<T>?) {
Timber.v("Received fresh list, size %s", list?.size)
mDiffer.submitList(list)
}
/**
* Set the new list to be displayed.
*
*
* If a List is already being displayed, a diff will be computed on a background thread, which
* will dispatch Adapter.notifyItem events on the main thread.
*
*
* The commit callback can be used to know when the List is committed, but note that it
* may not be executed. If List B is submitted immediately after List A, and is
* committed directly, the callback associated with List A will not be run.
*
* @param list The new list to be displayed.
* @param commitCallback Optional runnable that is executed when the List is committed, if
* it is committed.
*/
fun submitList(list: List<T>?, commitCallback: Runnable?) {
mDiffer.submitList(list, commitCallback)
}
override fun getItemCount(): Int {
return mDiffer.currentList.size
}
/**
* Get the current List - any diffing to present this list has already been computed and
* dispatched via the ListUpdateCallback.
*
*
* If a `null` List, or no List has been submitted, an empty list will be returned.
*
*
* The returned list may not be mutated - mutations to content must be done through
* [.submitList].
*
* @return The list currently being displayed.
*
* @see .onCurrentListChanged
*/
fun getCurrentList(): List<T> {
return mDiffer.currentList
}
/**
* Called when the current List is updated.
*
*
* If a `null` List is passed to [.submitList], or no List has been
* submitted, the current List is represented as an empty List.
*
* @param previousList List that was displayed previously.
* @param currentList new List being displayed, will be empty if `null` was passed to
* [.submitList].
*
* @see .getCurrentList
*/
fun onCurrentListChanged(previousList: List<T>, currentList: List<T>) {
// Void
}
fun notifySelected(id: Long) {
selectedSet.add(id)
// Update revision counter
selectionRevision.postValue(selectionRevision.value!! + 1)
}
fun notifyUnselected(id: Long) {
selectedSet.remove(id)
// Update revision counter
selectionRevision.postValue(selectionRevision.value!! + 1)
}
fun notifyChanged() {
// When the download state of an entry was changed by an external process,
// increase the revision counter in order to update the UI
selectionRevision.postValue(selectionRevision.value!! + 1)
}
fun setSelectionStatusOfAll(select: Boolean): Int {
// Clear current selection
selectedSet.clear()
// Update revision counter
selectionRevision.postValue(selectionRevision.value!! + 1)
// Nothing to reselect
if (!select) return 0
// Select them all
getCurrentList().mapNotNullTo(
selectedSet,
{ entry ->
// Exclude any -1 ids, eg. headers and other UI elements
entry.longId.takeIf { it != -1L }
}
)
return selectedSet.count()
}
fun isSelected(longId: Long): Boolean {
return selectedSet.contains(longId)
}
fun hasSingleSelection(): Boolean {
return selectionType == SelectionType.SINGLE
}
fun hasMultipleSelection(): Boolean {
return selectionType == SelectionType.MULTIPLE
}
enum class SelectionType(val size: Int) {
SINGLE(1),
MULTIPLE(Int.MAX_VALUE)
}
/**
* Calculates the differences between data sets
*/
class GenericDiffCallback<T : Identifiable> : DiffUtil.ItemCallback<T>() {
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.id == newItem.id
}
}
override fun getSectionName(position: Int): String {
val type = getItemViewType(position)
val binder = types.getType<Any>(type).delegate
if (binder is Utils.SectionedBinder) {
return binder.getSectionName(items[position] as Identifiable)
}
return ""
}
}

View File

@ -0,0 +1,45 @@
package org.moire.ultrasonic.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Identifiable
/**
* Creates a row in a RecyclerView which can be used as a divide between different sections
*/
class DividerBinder : ItemViewBinder<DividerBinder.Divider, DividerBinder.ViewHolder>() {
// Set our layout files
val layout = R.layout.list_item_divider
val moreButton = R.layout.list_item_more_button
override fun onBindViewHolder(holder: ViewHolder, item: Divider) {
// Set text
holder.textView.setText(item.stringId)
}
override fun onCreateViewHolder(
inflater: LayoutInflater,
parent: ViewGroup
): ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false))
}
// ViewHolder class
class ViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var textView: TextView = itemView.findViewById(R.id.text)
}
// Class to store our data into
data class Divider(val stringId: Int) : Identifiable {
override val id: String
get() = stringId.toString()
}
}

View File

@ -0,0 +1,132 @@
package org.moire.ultrasonic.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import java.lang.ref.WeakReference
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.service.RxBus
/**
* This little view shows the currently selected Folder (or catalog) on the music server.
* When clicked it will drop down a list of all available Folders and allow you to
* select one. The intended usage is to supply a filter to lists of artists, albums, etc
*/
class FolderSelectorBinder(context: Context) :
ItemViewBinder<FolderSelectorBinder.FolderHeader, FolderSelectorBinder.ViewHolder>(),
KoinComponent {
private val weakContext: WeakReference<Context> = WeakReference(context)
// Set our layout files
val layout = R.layout.list_header_folder
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false), weakContext)
}
override fun onBindViewHolder(holder: ViewHolder, item: FolderHeader) {
holder.setData(item)
}
class ViewHolder(
view: View,
private val weakContext: WeakReference<Context>
) : RecyclerView.ViewHolder(view) {
private var data: FolderHeader? = null
private val selectedFolderId: String?
get() = data?.selected
private val musicFolders: List<MusicFolder>
get() = data?.folders ?: mutableListOf()
private val folderName: TextView = itemView.findViewById(R.id.select_folder_name)
private val layout: LinearLayout = itemView.findViewById(R.id.select_folder_header)
init {
folderName.text = weakContext.get()!!.getString(R.string.select_artist_all_folders)
layout.setOnClickListener { onFolderClick() }
}
fun setData(item: FolderHeader) {
data = item
if (selectedFolderId != null) {
for ((id, name) in musicFolders) {
if (id == selectedFolderId) {
folderName.text = name
break
}
}
} else {
folderName.text = weakContext.get()!!.getString(R.string.select_artist_all_folders)
}
}
private fun onFolderClick() {
val popup = PopupMenu(weakContext.get()!!, layout)
var menuItem = popup.menu.add(
MENU_GROUP_MUSIC_FOLDER, -1, 0, R.string.select_artist_all_folders
)
if (selectedFolderId == null || selectedFolderId!!.isEmpty()) {
menuItem.isChecked = true
}
musicFolders.forEachIndexed { i, musicFolder ->
val (id, name) = musicFolder
menuItem = popup.menu.add(MENU_GROUP_MUSIC_FOLDER, i, i + 1, name)
if (id == selectedFolderId) {
menuItem.isChecked = true
}
}
popup.menu.setGroupCheckable(MENU_GROUP_MUSIC_FOLDER, true, true)
popup.setOnMenuItemClickListener { item -> onFolderMenuItemSelected(item) }
popup.show()
}
private fun onFolderMenuItemSelected(menuItem: MenuItem): Boolean {
val selectedFolder = if (menuItem.itemId == -1) null else musicFolders[menuItem.itemId]
val musicFolderName = selectedFolder?.name
?: weakContext.get()!!.getString(R.string.select_artist_all_folders)
data?.selected = selectedFolder?.id
menuItem.isChecked = true
folderName.text = musicFolderName
RxBus.musicFolderChangedEventPublisher.onNext(selectedFolderId)
return true
}
companion object {
const val MENU_GROUP_MUSIC_FOLDER = 10
}
}
data class FolderHeader(
var folders: List<MusicFolder>,
var selected: String?
) : Identifiable {
override val id: String
get() = "FOLDERSELECTOR"
override val longId: Long
get() = -1L
}
}

View File

@ -0,0 +1,104 @@
package org.moire.ultrasonic.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import java.lang.ref.WeakReference
import java.util.Random
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Util
/**
* This Binder can bind a list of entries into a Header
*/
class HeaderViewBinder(
context: Context
) : ItemViewBinder<AlbumHeader, HeaderViewBinder.ViewHolder>(), KoinComponent {
private val weakContext: WeakReference<Context> = WeakReference(context)
private val random: Random = Random()
private val imageLoaderProvider: ImageLoaderProvider by inject()
// Set our layout files
val layout = R.layout.list_header_album
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false))
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val coverArtView: ImageView = itemView.findViewById(R.id.select_album_art)
val titleView: TextView = itemView.findViewById(R.id.select_album_title)
val artistView: TextView = itemView.findViewById(R.id.select_album_artist)
val durationView: TextView = itemView.findViewById(R.id.select_album_duration)
val songCountView: TextView = itemView.findViewById(R.id.select_album_song_count)
val yearView: TextView = itemView.findViewById(R.id.select_album_year)
val genreView: TextView = itemView.findViewById(R.id.select_album_genre)
}
override fun onBindViewHolder(holder: ViewHolder, item: AlbumHeader) {
val context = weakContext.get() ?: return
val resources = context.resources
val artworkSelection = random.nextInt(item.childCount)
imageLoaderProvider.getImageLoader().loadImage(
holder.coverArtView, item.entries[artworkSelection], false,
Util.getAlbumImageSize(context)
)
if (item.name != null) {
holder.titleView.isVisible = true
holder.titleView.text = item.name
} else {
holder.titleView.isVisible = false
}
// Don't show a header if all entries are videos
if (item.isAllVideo) {
return
}
val artist: String = when {
item.artists.size == 1 -> item.artists.iterator().next()
item.grandParents.size == 1 -> item.grandParents.iterator().next()
else -> context.resources.getString(R.string.common_various_artists)
}
holder.artistView.text = artist
val genre: String = if (item.genres.size == 1) {
item.genres.iterator().next()
} else {
context.resources.getString(R.string.common_multiple_genres)
}
holder.genreView.text = genre
val year: String = if (item.years.size == 1) {
item.years.iterator().next().toString()
} else {
resources.getString(R.string.common_multiple_years)
}
holder.yearView.text = year
val songs = resources.getQuantityString(
R.plurals.select_album_n_songs, item.childCount,
item.childCount
)
holder.songCountView.text = songs
val duration = Util.formatTotalDuration(item.totalDuration)
holder.durationView.text = duration
}
}

View File

@ -0,0 +1,44 @@
package org.moire.ultrasonic.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Identifiable
/**
* Creates a row in a RecyclerView which can be used as a divide between different sections
*/
class MoreButtonBinder : ItemViewBinder<MoreButtonBinder.MoreButton, RecyclerView.ViewHolder>() {
// Set our layout files
val layout = R.layout.list_item_more_button
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, item: MoreButton) {
holder.itemView.setOnClickListener {
item.onClick()
}
}
override fun onCreateViewHolder(
inflater: LayoutInflater,
parent: ViewGroup
): RecyclerView.ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false))
}
// ViewHolder class
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
// Class to store our data into
data class MoreButton(
val stringId: Int,
val onClick: (() -> Unit)
) : Identifiable {
override val id: String
get() = stringId.toString()
}
}

View File

@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.util.ServerColor
import org.moire.ultrasonic.util.Util

View File

@ -0,0 +1,147 @@
package org.moire.ultrasonic.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import com.drakeet.multitype.ItemViewBinder
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.Downloader
class TrackViewBinder(
val onItemClick: (DownloadFile) -> Unit,
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
val checkable: Boolean,
val draggable: Boolean,
context: Context,
val lifecycleOwner: LifecycleOwner,
) : ItemViewBinder<Identifiable, TrackViewHolder>(), KoinComponent {
var startDrag: ((TrackViewHolder) -> Unit)? = null
// Set our layout files
val layout = R.layout.list_item_track
val contextMenuLayout = R.menu.context_menu_track
private val downloader: Downloader by inject()
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): TrackViewHolder {
return TrackViewHolder(inflater.inflate(layout, parent, false))
}
@SuppressLint("ClickableViewAccessibility")
@Suppress("LongMethod")
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
val downloadFile: DownloadFile?
val diffAdapter = adapter as BaseAdapter<*>
when (item) {
is MusicDirectory.Entry -> {
downloadFile = downloader.getDownloadFileForSong(item)
}
is DownloadFile -> {
downloadFile = item
}
else -> {
return
}
}
holder.imageHelper = imageHelper
// Remove observer before binding
holder.observableChecked.removeObservers(lifecycleOwner)
holder.setSong(
file = downloadFile,
checkable = checkable,
draggable = draggable,
diffAdapter.isSelected(item.longId)
)
holder.itemView.setOnLongClickListener {
if (onContextMenuClick != null) {
val popup = Utils.createPopupMenu(holder.itemView, contextMenuLayout)
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick.invoke(menuItem, downloadFile)
}
} else {
// Minimize or maximize the Text view (if song title is very long)
if (!downloadFile.song.isDirectory) {
holder.maximizeOrMinimize()
}
}
true
}
holder.itemView.setOnClickListener {
if (checkable && !downloadFile.song.isVideo) {
val nowChecked = !holder.check.isChecked
holder.isChecked = nowChecked
} else {
onItemClick(downloadFile)
}
}
holder.drag.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
startDrag?.invoke(holder)
}
false
}
// Notify the adapter of selection changes
holder.observableChecked.observe(
lifecycleOwner,
{ isCheckedNow ->
if (isCheckedNow) {
diffAdapter.notifySelected(holder.entry!!.longId)
} else {
diffAdapter.notifyUnselected(holder.entry!!.longId)
}
}
)
// Listen to changes in selection status and update ourselves
diffAdapter.selectionRevision.observe(
lifecycleOwner,
{
val newStatus = diffAdapter.isSelected(item.longId)
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
}
)
// Observe download status
downloadFile.status.observe(
lifecycleOwner,
{
holder.updateStatus(it)
diffAdapter.notifyChanged()
}
)
downloadFile.progress.observe(
lifecycleOwner,
{
holder.updateProgress(it)
}
)
}
override fun onViewRecycled(holder: TrackViewHolder) {
holder.dispose()
super.onViewRecycled(holder)
}
}

View File

@ -0,0 +1,294 @@
package org.moire.ultrasonic.adapters
import android.graphics.drawable.AnimationDrawable
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.Checkable
import android.widget.CheckedTextView
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.disposables.Disposable
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.featureflags.Feature
import org.moire.ultrasonic.featureflags.FeatureStorage
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.DownloadStatus
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* Used to display songs and videos in a `ListView`.
*/
class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable, KoinComponent {
var check: CheckedTextView = view.findViewById(R.id.song_check)
private var rating: LinearLayout = view.findViewById(R.id.song_rating)
private var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1)
private var fiveStar2: ImageView = view.findViewById(R.id.song_five_star_2)
private var fiveStar3: ImageView = view.findViewById(R.id.song_five_star_3)
private var fiveStar4: ImageView = view.findViewById(R.id.song_five_star_4)
private var fiveStar5: ImageView = view.findViewById(R.id.song_five_star_5)
var star: ImageView = view.findViewById(R.id.song_star)
var drag: ImageView = view.findViewById(R.id.song_drag)
var track: TextView = view.findViewById(R.id.song_track)
var title: TextView = view.findViewById(R.id.song_title)
var artist: TextView = view.findViewById(R.id.song_artist)
var duration: TextView = view.findViewById(R.id.song_duration)
var progress: TextView = view.findViewById(R.id.song_status)
var entry: MusicDirectory.Entry? = null
private set
var downloadFile: DownloadFile? = null
private set
private var isMaximized = false
private var cachedStatus = DownloadStatus.UNKNOWN
private var statusImage: Drawable? = null
private var isPlayingCached = false
private var rxSubscription: Disposable? = null
var observableChecked = MutableLiveData(false)
private val useFiveStarRating: Boolean by lazy {
val features: FeatureStorage = get()
features.isFeatureEnabled(Feature.FIVE_STAR_RATING)
}
lateinit var imageHelper: Utils.ImageHelper
fun setSong(
file: DownloadFile,
checkable: Boolean,
draggable: Boolean,
isSelected: Boolean = false
) {
val song = file.song
downloadFile = file
entry = song
val entryDescription = Util.readableEntryDescription(song)
artist.text = entryDescription.artist
title.text = entryDescription.title
duration.text = entryDescription.duration
if (Settings.shouldShowTrackNumber && song.track != null && song.track!! > 0) {
track.text = entryDescription.trackNumber
} else {
track.isVisible = false
}
check.isVisible = (checkable && !song.isVideo)
initChecked(isSelected)
drag.isVisible = draggable
if (ActiveServerProvider.isOffline()) {
star.isVisible = false
rating.isVisible = false
} else {
setupStarButtons(song)
}
updateProgress(downloadFile!!.progress.value!!)
updateStatus(downloadFile!!.status.value!!)
if (useFiveStarRating) {
setFiveStars(entry?.userRating ?: 0)
} else {
setSingleStar(entry!!.starred)
}
if (song.isVideo) {
artist.isVisible = false
progress.isVisible = false
}
rxSubscription = RxBus.playerStateObservable.subscribe {
setPlayIcon(it.track == downloadFile)
}
}
fun dispose() {
rxSubscription?.dispose()
}
private fun setPlayIcon(isPlaying: Boolean) {
if (isPlaying && !isPlayingCached) {
isPlayingCached = true
title.setCompoundDrawablesWithIntrinsicBounds(
imageHelper.playingImage, null, null, null
)
} else if (!isPlaying && isPlayingCached) {
isPlayingCached = false
title.setCompoundDrawablesWithIntrinsicBounds(
0, 0, 0, 0
)
}
}
private fun setupStarButtons(song: MusicDirectory.Entry) {
if (useFiveStarRating) {
// Hide single star
star.isVisible = false
val rating = if (song.userRating == null) 0 else song.userRating!!
setFiveStars(rating)
} else {
// Hide five stars
rating.isVisible = false
setSingleStar(song.starred)
star.setOnClickListener {
val isStarred = song.starred
val id = song.id
if (!isStarred) {
star.setImageDrawable(imageHelper.starDrawable)
song.starred = true
} else {
star.setImageDrawable(imageHelper.starHollowDrawable)
song.starred = false
}
Thread {
val musicService = MusicServiceFactory.getMusicService()
try {
if (!isStarred) {
musicService.star(id, null, null)
} else {
musicService.unstar(id, null, null)
}
} catch (all: Exception) {
Timber.e(all)
}
}.start()
}
}
}
@Suppress("MagicNumber")
private fun setFiveStars(rating: Int) {
fiveStar1.setImageDrawable(
if (rating > 0) imageHelper.starDrawable else imageHelper.starHollowDrawable
)
fiveStar2.setImageDrawable(
if (rating > 1) imageHelper.starDrawable else imageHelper.starHollowDrawable
)
fiveStar3.setImageDrawable(
if (rating > 2) imageHelper.starDrawable else imageHelper.starHollowDrawable
)
fiveStar4.setImageDrawable(
if (rating > 3) imageHelper.starDrawable else imageHelper.starHollowDrawable
)
fiveStar5.setImageDrawable(
if (rating > 4) imageHelper.starDrawable else imageHelper.starHollowDrawable
)
}
private fun setSingleStar(starred: Boolean) {
if (starred) {
if (star.drawable !== imageHelper.starDrawable) {
star.setImageDrawable(imageHelper.starDrawable)
}
} else {
if (star.drawable !== imageHelper.starHollowDrawable) {
star.setImageDrawable(imageHelper.starHollowDrawable)
}
}
}
fun updateStatus(status: DownloadStatus) {
if (status == cachedStatus) return
cachedStatus = status
Timber.w("STATUS: %s", status)
when (status) {
DownloadStatus.DONE -> {
statusImage = imageHelper.downloadedImage
progress.text = null
}
DownloadStatus.PINNED -> {
statusImage = imageHelper.pinImage
progress.text = null
}
DownloadStatus.FAILED,
DownloadStatus.ABORTED -> {
statusImage = imageHelper.errorImage
progress.text = null
}
DownloadStatus.DOWNLOADING -> {
statusImage = imageHelper.downloadingImage
}
else -> {
statusImage = null
}
}
updateImages()
}
fun updateProgress(p: Int) {
if (cachedStatus == DownloadStatus.DOWNLOADING) {
progress.text = Util.formatPercentage(p)
} else {
progress.text = null
}
}
private fun updateImages() {
progress.setCompoundDrawablesWithIntrinsicBounds(
null, null, statusImage, null
)
if (statusImage === imageHelper.downloadingImage) {
val frameAnimation = statusImage as AnimationDrawable?
frameAnimation?.setVisible(true, true)
frameAnimation?.start()
}
}
/*
* Set the checked value and re-init the MutableLiveData.
* If we would post a new value, there might be a short glitch where the track is shown with its
* old selection status before the posted value has been processed.
*/
private fun initChecked(newStatus: Boolean) {
observableChecked = MutableLiveData(newStatus)
check.isChecked = newStatus
}
/*
* To be correct, this method doesn't directly set the checked status.
* It only notifies the observable. If the selection tracker accepts the selection
* (might be false for Singular SelectionTrackers) then it will cause the actual modification.
*/
override fun setChecked(newStatus: Boolean) {
observableChecked.postValue(newStatus)
}
override fun isChecked(): Boolean {
return check.isChecked
}
override fun toggle() {
isChecked = isChecked
}
fun maximizeOrMinimize() {
isMaximized = !isMaximized
title.isSingleLine = !isMaximized
artist.isSingleLine = !isMaximized
}
}

View File

@ -0,0 +1,77 @@
package org.moire.ultrasonic.adapters
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.MenuInflater
import android.view.View
import android.widget.PopupMenu
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
object Utils {
@JvmStatic
fun createPopupMenu(view: View, layout: Int = R.menu.context_menu_artist): PopupMenu {
val popup = PopupMenu(view.context, view)
val inflater: MenuInflater = popup.menuInflater
inflater.inflate(layout, popup.menu)
val downloadMenuItem = popup.menu.findItem(R.id.menu_download)
downloadMenuItem?.isVisible = !ActiveServerProvider.isOffline()
var shareButton = popup.menu.findItem(R.id.menu_item_share)
shareButton?.isVisible = !ActiveServerProvider.isOffline()
shareButton = popup.menu.findItem(R.id.song_menu_share)
shareButton?.isVisible = !ActiveServerProvider.isOffline()
popup.show()
return popup
}
/**
* Provides cached drawables for the UI
*/
class ImageHelper(context: Context) {
lateinit var errorImage: Drawable
lateinit var starHollowDrawable: Drawable
lateinit var starDrawable: Drawable
lateinit var pinImage: Drawable
lateinit var downloadedImage: Drawable
lateinit var downloadingImage: Drawable
lateinit var playingImage: Drawable
var theme: String
fun rebuild(context: Context, force: Boolean = false) {
val currentTheme = Settings.theme
val themesMatch = theme == currentTheme
if (!themesMatch) theme = currentTheme
if (!themesMatch || force) {
getDrawables(context)
}
}
init {
theme = Settings.theme
getDrawables(context)
}
private fun getDrawables(context: Context) {
starHollowDrawable = Util.getDrawableFromAttribute(context, R.attr.star_hollow)
starDrawable = Util.getDrawableFromAttribute(context, R.attr.star_full)
pinImage = Util.getDrawableFromAttribute(context, R.attr.pin)
downloadedImage = Util.getDrawableFromAttribute(context, R.attr.downloaded)
errorImage = Util.getDrawableFromAttribute(context, R.attr.error)
downloadingImage = Util.getDrawableFromAttribute(context, R.attr.downloading)
playingImage = Util.getDrawableFromAttribute(context, R.attr.media_play_small)
}
}
interface SectionedBinder {
fun getSectionName(item: Identifiable): String
}
}

View File

@ -9,7 +9,7 @@ import org.moire.ultrasonic.data.AppDatabase
import org.moire.ultrasonic.data.MIGRATION_1_2
import org.moire.ultrasonic.data.MIGRATION_2_3
import org.moire.ultrasonic.data.MIGRATION_3_4
import org.moire.ultrasonic.fragment.ServerSettingsModel
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.util.Settings
const val SP_NAME = "Default_SP"

View File

@ -5,10 +5,10 @@ package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Album
fun Album.toDomainEntity(): MusicDirectory.Entry = MusicDirectory.Entry(
fun Album.toDomainEntity(): MusicDirectory.Album = MusicDirectory.Album(
id = this@toDomainEntity.id,
isDirectory = true,
title = this@toDomainEntity.name,
title = this@toDomainEntity.name ?: this@toDomainEntity.title,
album = this@toDomainEntity.album,
coverArt = this@toDomainEntity.coverArt,
artist = this@toDomainEntity.artist,
artistId = this@toDomainEntity.artistId,
@ -24,4 +24,4 @@ fun Album.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().appl
addAll(this@toMusicDirectoryDomainEntity.songList.map { it.toDomainEntity() })
}
fun List<Album>.toDomainEntityList(): List<MusicDirectory.Entry> = this.map { it.toDomainEntity() }
fun List<Album>.toDomainEntityList(): List<MusicDirectory.Album> = this.map { it.toDomainEntity() }

View File

@ -23,3 +23,7 @@ fun APIArtist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().
name = this@toMusicDirectoryDomainEntity.name
addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity() })
}
fun APIArtist.toDomainEntityList(): List<MusicDirectory.Album> {
return this.albumsList.map { it.toDomainEntity() }
}

View File

@ -1,3 +1,10 @@
/*
* AlbumListFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.os.Bundle
@ -7,14 +14,15 @@ import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.AlbumRowBinder
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.model.AlbumListModel
import org.moire.ultrasonic.util.Constants
/**
* Displays a list of Albums from the media library
* TODO: Check refresh is working
*/
class AlbumListFragment : EntryListFragment<MusicDirectory.Entry, AlbumRowAdapter>() {
class AlbumListFragment : EntryListFragment<MusicDirectory.Album>() {
/**
* The ViewModel to use to get the data
@ -24,53 +32,28 @@ class AlbumListFragment : EntryListFragment<MusicDirectory.Entry, AlbumRowAdapte
/**
* The id of the main layout
*/
override val mainLayout: Int = R.layout.generic_list
override val mainLayout: Int = R.layout.list_layout_generic
/**
* The id of the refresh view
* Whether to refresh the data onViewCreated
*/
override val refreshListId: Int = R.id.generic_list_refresh
/**
* The id of the RecyclerView
*/
override val recyclerViewId = R.id.generic_list_recycler
/**
* The id of the target in the navigation graph where we should go,
* after the user has clicked on an item
*/
override val itemClickTarget: Int = R.id.trackCollectionFragment
override val refreshOnCreation: Boolean = false
/**
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(args: Bundle?): LiveData<List<MusicDirectory.Entry>> {
override fun getLiveData(
args: Bundle?,
refresh: Boolean
): LiveData<List<MusicDirectory.Album>> {
if (args == null) throw IllegalArgumentException("Required arguments are missing")
val refresh = args.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH)
val append = args.getBoolean(Constants.INTENT_EXTRA_NAME_APPEND)
val refresh2 = args.getBoolean(Constants.INTENT_REFRESH) || refresh
val append = args.getBoolean(Constants.INTENT_APPEND)
return listModel.getAlbumList(refresh or append, refreshListView!!, args)
return listModel.getAlbumList(refresh2 or append, refreshListView!!, args)
}
/**
* Provide the Adapter for the RecyclerView with a lazy delegate
*/
override val viewAdapter: AlbumRowAdapter by lazy {
AlbumRowAdapter(
liveDataItems.value ?: listOf(),
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider.getImageLoader(),
onMusicFolderUpdate,
requireContext()
)
}
val newBundleClone: Bundle
get() = arguments?.clone() as Bundle
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -80,21 +63,32 @@ class AlbumListFragment : EntryListFragment<MusicDirectory.Entry, AlbumRowAdapte
override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
// Triggered only when new data needs to be appended to the list
// Add whatever code is needed to append new items to the bottom of the list
val appendArgs = newBundleClone
appendArgs.putBoolean(Constants.INTENT_EXTRA_NAME_APPEND, true)
val appendArgs = getArgumentsClone()
appendArgs.putBoolean(Constants.INTENT_APPEND, true)
getLiveData(appendArgs)
}
}
addOnScrollListener(scrollListener)
}
viewAdapter.register(
AlbumRowBinder(
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider.getImageLoader(),
context = requireContext()
)
)
emptyTextView.setText(R.string.select_album_empty)
}
override fun onItemClick(item: MusicDirectory.Entry) {
override fun onItemClick(item: MusicDirectory.Album) {
val bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, item.id)
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, item.isDirectory)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, item.title)
bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, item.parent)
findNavController().navigate(itemClickTarget, bundle)
bundle.putString(Constants.INTENT_ID, item.id)
bundle.putBoolean(Constants.INTENT_IS_ALBUM, item.isDirectory)
bundle.putString(Constants.INTENT_NAME, item.title)
bundle.putString(Constants.INTENT_PARENT_ID, item.parent)
findNavController().navigate(R.id.trackCollectionFragment, bundle)
}
}

View File

@ -1,16 +1,23 @@
package org.moire.ultrasonic.fragment
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.ArtistRowBinder
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.model.ArtistListModel
import org.moire.ultrasonic.util.Constants
/**
* Displays the list of Artists from the media library
* Displays the list of Artists or Indexes (folders) from the media library
*/
class ArtistListFragment : EntryListFragment<ArtistOrIndex, ArtistRowAdapter>() {
class ArtistListFragment : EntryListFragment<ArtistOrIndex>() {
/**
* The ViewModel to use to get the data
@ -20,42 +27,57 @@ class ArtistListFragment : EntryListFragment<ArtistOrIndex, ArtistRowAdapter>()
/**
* The id of the main layout
*/
override val mainLayout = R.layout.generic_list
/**
* The id of the refresh view
*/
override val refreshListId = R.id.generic_list_refresh
/**
* The id of the RecyclerView
*/
override val recyclerViewId = R.id.generic_list_recycler
/**
* The id of the target in the navigation graph where we should go,
* after the user has clicked on an item
*/
override val itemClickTarget = R.id.selectArtistToSelectAlbum
override val mainLayout = R.layout.list_layout_generic
/**
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(args: Bundle?): LiveData<List<ArtistOrIndex>> {
val refresh = args?.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH) ?: false
return listModel.getItems(refresh, refreshListView!!)
override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData<List<ArtistOrIndex>> {
val refresh2 = args?.getBoolean(Constants.INTENT_REFRESH) ?: false || refresh
return listModel.getItems(refresh2, refreshListView!!)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewAdapter.register(
ArtistRowBinder(
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider.getImageLoader()
)
)
}
/**
* Provide the Adapter for the RecyclerView with a lazy delegate
* There are different targets depending on what list we show.
* If we are showing indexes, we need to go to TrackCollection
* If we are showing artists, we need to go to AlbumList
*/
override val viewAdapter: ArtistRowAdapter by lazy {
ArtistRowAdapter(
liveDataItems.value ?: listOf(),
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider.getImageLoader(),
onMusicFolderUpdate
)
override fun onItemClick(item: ArtistOrIndex) {
Companion.onItemClick(item, findNavController())
}
companion object {
fun onItemClick(item: ArtistOrIndex, navController: NavController) {
val bundle = Bundle()
// Common arguments
bundle.putString(Constants.INTENT_ID, item.id)
bundle.putString(Constants.INTENT_NAME, item.name)
bundle.putString(Constants.INTENT_PARENT_ID, item.id)
bundle.putBoolean(Constants.INTENT_ARTIST, (item is Artist))
// Check type
if (item is Index) {
navController.navigate(R.id.artistsListToTrackCollection, bundle)
} else {
bundle.putString(Constants.INTENT_ALBUM_LIST_TYPE, Constants.ALBUMS_OF_ARTIST)
bundle.putString(Constants.INTENT_ALBUM_LIST_TITLE, item.name)
bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, 1000)
bundle.putInt(Constants.INTENT_ALBUM_LIST_OFFSET, 0)
navController.navigate(R.id.artistsListToAlbumsList, bundle)
}
}
}
}

View File

@ -1,106 +0,0 @@
/*
* ArtistRowAdapter.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.view.MenuItem
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
/**
* Creates a Row in a RecyclerView which contains the details of an Artist
*/
class ArtistRowAdapter(
itemList: List<ArtistOrIndex>,
onItemClick: (ArtistOrIndex) -> Unit,
onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean,
private val imageLoader: ImageLoader,
onMusicFolderUpdate: (String?) -> Unit
) : GenericRowAdapter<ArtistOrIndex>(
onItemClick,
onContextMenuClick,
onMusicFolderUpdate
),
SectionedAdapter {
init {
super.submitList(itemList)
}
// Set our layout files
override val layout = R.layout.artist_list_item
override val contextMenuLayout = R.menu.artist_context_menu
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) {
val listPosition = if (selectFolderHeader != null) position - 1 else position
holder.textView.text = currentList[listPosition].name
holder.section.text = getSectionForArtist(listPosition)
holder.layout.setOnClickListener { onItemClick(currentList[listPosition]) }
holder.layout.setOnLongClickListener { view -> createPopupMenu(view, listPosition) }
holder.coverArtId = currentList[listPosition].coverArt
if (Settings.shouldShowArtistPicture) {
holder.coverArt.visibility = View.VISIBLE
val key = FileUtil.getArtistArtKey(currentList[listPosition].name, false)
imageLoader.loadImage(
view = holder.coverArt,
id = holder.coverArtId,
key = key,
large = false,
size = 0,
defaultResourceId = R.drawable.ic_contact_picture
)
} else {
holder.coverArt.visibility = View.GONE
}
}
}
override fun getSectionName(position: Int): String {
var listPosition = if (selectFolderHeader != null) position - 1 else position
// Show the first artist's initial in the popup when the list is
// scrolled up to the "Select Folder" row
if (listPosition < 0) listPosition = 0
return getSectionFromName(currentList[listPosition].name ?: " ")
}
private fun getSectionForArtist(artistPosition: Int): String {
if (artistPosition == 0)
return getSectionFromName(currentList[artistPosition].name ?: " ")
val previousArtistSection = getSectionFromName(
currentList[artistPosition - 1].name ?: " "
)
val currentArtistSection = getSectionFromName(
currentList[artistPosition].name ?: " "
)
return if (previousArtistSection == currentArtistSection) "" else currentArtistSection
}
private fun getSectionFromName(name: String): String {
var section = name.first().uppercaseChar()
if (!section.isLetter()) section = '#'
return section.toString()
}
/**
* Creates an instance of our ViewHolder class
*/
override fun newViewHolder(view: View): RecyclerView.ViewHolder {
return ViewHolder(view)
}
}

View File

@ -0,0 +1,78 @@
/*
* BookmarksFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.os.Bundle
import android.view.View
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.BaseAdapter
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
/**
* Lists the Bookmarks available on the server
*
* Bookmarks allows to save the play position of tracks, especially useful for longer tracks like
* audio books etc.
*
* Therefore this fragment allows only for singular selection and playback.
*/
class BookmarksFragment : TrackCollectionFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setTitle(this, R.string.button_bar_bookmarks)
viewAdapter.selectionType = BaseAdapter.SelectionType.SINGLE
}
override fun getLiveData(
args: Bundle?,
refresh: Boolean
): LiveData<List<MusicDirectory.Child>> {
listModel.viewModelScope.launch(handler) {
refreshListView?.isRefreshing = true
listModel.getBookmarks()
refreshListView?.isRefreshing = false
}
return listModel.currentList
}
/**
* Set a custom listener to perform the playing, in order to be able to restore
* the playback position
*/
override fun setupButtons(view: View) {
super.setupButtons(view)
playNowButton!!.setOnClickListener {
playNow(getSelectedSongs())
}
}
/**
* Custom playback function which uses the restore functionality. A bit of a hack..
*/
private fun playNow(songs: List<MusicDirectory.Entry>) {
if (songs.isNotEmpty()) {
val position = songs[0].bookmarkPosition
mediaPlayerController.restore(
songs = songs,
currentPlayingIndex = 0,
currentPlayingPosition = position,
autoPlay = true,
newPlaylist = true
)
}
}
}

View File

@ -1,218 +1,83 @@
/*
* DownloadsFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.app.Application
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.CheckedTextView
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.model.GenericListModel
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.DownloadStatus
import org.moire.ultrasonic.service.Downloader
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.view.SongView
class DownloadsFragment : GenericListFragment<DownloadFile, DownloadRowAdapter>() {
/**
* Displays currently running downloads.
* For now its a read-only view, there are no manipulations of the download list possible.
*
* TODO: A consideration would be to base this class on TrackCollectionFragment and thereby inheriting the
* buttons useful to manipulate the list.
*
* TODO: Add code to enable manipulation of the download list
*/
class DownloadsFragment : MultiListFragment<DownloadFile>() {
/**
* The ViewModel to use to get the data
*/
override val listModel: DownloadListModel by viewModels()
/**
* The id of the main layout
*/
override val mainLayout: Int = R.layout.generic_list
/**
* The id of the refresh view
*/
override val refreshListId: Int = R.id.generic_list_refresh
/**
* The id of the RecyclerView
*/
override val recyclerViewId = R.id.generic_list_recycler
/**
* The id of the target in the navigation graph where we should go,
* after the user has clicked on an item
*/
// FIXME
override val itemClickTarget: Int = R.id.trackCollectionFragment
/**
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(args: Bundle?): LiveData<List<DownloadFile>> {
override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData<List<DownloadFile>> {
return listModel.getList()
}
/**
* Provide the Adapter for the RecyclerView with a lazy delegate
*/
override val viewAdapter: DownloadRowAdapter by lazy {
DownloadRowAdapter(
liveDataItems.value ?: listOf(),
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
onMusicFolderUpdate,
requireContext(),
viewLifecycleOwner
)
}
override fun onContextMenuItemSelected(menuItem: MenuItem, item: DownloadFile): Boolean {
// Do nothing
return true
}
override fun onItemClick(item: DownloadFile) {
// Do nothing
}
override fun setTitle(title: String?) {
FragmentTitle.setTitle(this, Util.appContext().getString(R.string.menu_downloads))
}
}
class DownloadRowAdapter(
itemList: List<DownloadFile>,
onItemClick: (DownloadFile) -> Unit,
onContextMenuClick: (MenuItem, DownloadFile) -> Boolean,
onMusicFolderUpdate: (String?) -> Unit,
context: Context,
val lifecycleOwner: LifecycleOwner
) : GenericRowAdapter<DownloadFile>(
onItemClick,
onContextMenuClick,
onMusicFolderUpdate
) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
init {
super.submitList(itemList)
}
private val starDrawable: Drawable =
Util.getDrawableFromAttribute(context, R.attr.star_full)
private val starHollowDrawable: Drawable =
Util.getDrawableFromAttribute(context, R.attr.star_hollow)
// Set our layout files
override val layout = R.layout.song_list_item
override val contextMenuLayout = R.menu.artist_context_menu
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) {
val downloadFile = currentList[position]
val entry = downloadFile.song
holder.title.text = entry.title
holder.artist.text = entry.artist
holder.star.setImageDrawable(if (entry.starred) starDrawable else starHollowDrawable)
// Observe download status
downloadFile.status.observe(
lifecycleOwner,
{
updateDownloadStatus(downloadFile, holder)
}
viewAdapter.register(
TrackViewBinder(
{ },
{ _, _ -> true },
checkable = false,
draggable = false,
context = requireContext(),
lifecycleOwner = viewLifecycleOwner
)
)
downloadFile.progress.observe(
lifecycleOwner,
{
updateDownloadStatus(downloadFile, holder)
}
)
}
val liveDataList = listModel.getList()
emptyTextView.setText(R.string.download_empty)
emptyView.isVisible = liveDataList.value?.isEmpty() ?: true
viewAdapter.submitList(liveDataList.value)
}
private fun updateDownloadStatus(
downloadFile: DownloadFile,
holder: ViewHolder
) {
var image: Drawable? = null
when (downloadFile.status.value) {
DownloadStatus.DONE -> {
image = if (downloadFile.isSaved) SongView.pinImage else SongView.downloadedImage
holder.status.text = null
}
DownloadStatus.DOWNLOADING -> {
holder.status.text = Util.formatPercentage(downloadFile.progress.value!!)
image = SongView.downloadingImage
}
else -> {
holder.status.text = null
}
}
// TODO: Migrate the image animation stuff from SongView into this class
//
// if (image != null) {
// holder.status.setCompoundDrawablesWithIntrinsicBounds(
// image, null, image, null
// )
// }
//
// if (image === SongView.downloadingImage) {
// val frameAnimation = image as AnimationDrawable
//
// frameAnimation.setVisible(true, true)
// frameAnimation.start()
// }
override fun onContextMenuItemSelected(menuItem: MenuItem, item: DownloadFile): Boolean {
// TODO: Add code to enable manipulation of the download list
return true
}
/**
* Holds the view properties of an Item row
*/
class ViewHolder(
view: View
) : RecyclerView.ViewHolder(view) {
var check: CheckedTextView = view.findViewById(R.id.song_check)
var rating: LinearLayout = view.findViewById(R.id.song_rating)
var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1)
var fiveStar2: ImageView = view.findViewById(R.id.song_five_star_2)
var fiveStar3: ImageView = view.findViewById(R.id.song_five_star_3)
var fiveStar4: ImageView = view.findViewById(R.id.song_five_star_4)
var fiveStar5: ImageView = view.findViewById(R.id.song_five_star_5)
var star: ImageView = view.findViewById(R.id.song_star)
var drag: ImageView = view.findViewById(R.id.song_drag)
var track: TextView = view.findViewById(R.id.song_track)
var title: TextView = view.findViewById(R.id.song_title)
var artist: TextView = view.findViewById(R.id.song_artist)
var duration: TextView = view.findViewById(R.id.song_duration)
var status: TextView = view.findViewById(R.id.song_status)
init {
drag.isVisible = false
star.isVisible = false
fiveStar1.isVisible = false
fiveStar2.isVisible = false
fiveStar3.isVisible = false
fiveStar4.isVisible = false
fiveStar5.isVisible = false
check.isVisible = false
}
}
/**
* Creates an instance of our ViewHolder class
*/
override fun newViewHolder(view: View): RecyclerView.ViewHolder {
return ViewHolder(view)
override fun onItemClick(item: DownloadFile) {
// TODO: Add code to enable manipulation of the download list
}
}

View File

@ -33,6 +33,7 @@ import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.ErrorDialog

View File

@ -0,0 +1,198 @@
package org.moire.ultrasonic.fragment
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.FolderSelectorBinder
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
/**
* An extension of the MultiListFragment, with a few helper functions geared
* towards the display of MusicDirectory.Entries.
* @param T: The type of data which will be used (must extend GenericEntry)
*/
abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
/**
* Whether to show the folder selector
*/
fun showFolderHeader(): Boolean {
return listModel.showSelectFolderHeader(arguments) &&
!listModel.isOffline() && !Settings.shouldUseId3Tags
}
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
val isArtist = (item is Artist)
return handleContextMenu(menuItem, item, isArtist, downloadHandler, this)
}
override fun onItemClick(item: T) {
val bundle = Bundle()
bundle.putString(Constants.INTENT_ID, item.id)
bundle.putString(Constants.INTENT_NAME, item.name)
bundle.putString(Constants.INTENT_PARENT_ID, item.id)
bundle.putBoolean(Constants.INTENT_ARTIST, (item is Artist))
findNavController().navigate(R.id.trackCollectionFragment, bundle)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Call a cheap function on ServerSettingsModel to make sure it is initialized by Koin,
// because it can't be initialized from inside the callback
serverSettingsModel.toString()
RxBus.musicFolderChangedEventObservable.subscribe {
if (!listModel.isOffline()) {
val currentSetting = listModel.activeServer
currentSetting.musicFolderId = it
serverSettingsModel.updateItem(currentSetting)
}
listModel.refresh(refreshListView!!, arguments)
}
viewAdapter.register(
FolderSelectorBinder(view.context)
)
}
/**
* What to do when the list has changed
*/
override val defaultObserver: (List<T>) -> Unit = {
emptyView.isVisible = it.isEmpty() && !(refreshListView?.isRefreshing?:false)
if (showFolderHeader()) {
val list = mutableListOf<Identifiable>(folderHeader)
list.addAll(it)
viewAdapter.submitList(list)
} else {
viewAdapter.submitList(it)
}
}
/**
* Get a folder header and update it on changes
*/
private val folderHeader: FolderSelectorBinder.FolderHeader by lazy {
val header = FolderSelectorBinder.FolderHeader(
listModel.musicFolders.value!!,
listModel.activeServer.musicFolderId
)
listModel.musicFolders.observe(
viewLifecycleOwner,
{
header.folders = it
viewAdapter.notifyItemChanged(0)
}
)
header
}
companion object {
@Suppress("LongMethod")
internal fun handleContextMenu(
menuItem: MenuItem,
item: Identifiable,
isArtist: Boolean,
downloadHandler: DownloadHandler,
fragment: Fragment
): Boolean {
when (menuItem.itemId) {
R.id.menu_play_now ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_next ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = true,
background = false,
playNext = true,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_last ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_pin ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = true,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_unpin ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = true,
isArtist = isArtist
)
R.id.menu_download ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false,
isArtist = isArtist
)
else -> return false
}
return true
}
}
}

View File

@ -1,281 +0,0 @@
package org.moire.ultrasonic.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.view.SelectMusicFolderView
/**
* An abstract Model, which can be extended to display a list of items of type T from the API
* @param T: The type of data which will be used (must extend GenericEntry)
* @param TA: The Adapter to use (must extend GenericRowAdapter)
*/
abstract class GenericListFragment<T : Identifiable, TA : GenericRowAdapter<T>> : Fragment() {
internal val activeServerProvider: ActiveServerProvider by inject()
internal val serverSettingsModel: ServerSettingsModel by viewModel()
internal val imageLoaderProvider: ImageLoaderProvider by inject()
protected val downloadHandler: DownloadHandler by inject()
protected var refreshListView: SwipeRefreshLayout? = null
internal var listView: RecyclerView? = null
internal lateinit var viewManager: LinearLayoutManager
internal var selectFolderHeader: SelectMusicFolderView? = null
/**
* The Adapter for the RecyclerView
* Recommendation: Implement this as a lazy delegate
*/
internal abstract val viewAdapter: TA
/**
* The ViewModel to use to get the data
*/
open val listModel: GenericListModel by viewModels()
/**
* The LiveData containing the list provided by the model
* Implement this as a getter
*/
internal lateinit var liveDataItems: LiveData<List<T>>
/**
* The central function to pass a query to the model and return a LiveData object
*/
abstract fun getLiveData(args: Bundle? = null): LiveData<List<T>>
/**
* The id of the target in the navigation graph where we should go,
* after the user has clicked on an item
*/
protected abstract val itemClickTarget: Int
/**
* The id of the RecyclerView
*/
protected abstract val recyclerViewId: Int
/**
* The id of the main layout
*/
abstract val mainLayout: Int
/**
* The id of the refresh view
*/
abstract val refreshListId: Int
/**
* The observer to be called if the available music folders have changed
*/
@Suppress("CommentOverPrivateProperty")
private val musicFolderObserver = { folders: List<MusicFolder> ->
viewAdapter.setFolderList(folders, listModel.activeServer.musicFolderId)
}
/**
* What to do when the user has modified the folder filter
*/
val onMusicFolderUpdate = { selectedFolderId: String? ->
if (!listModel.isOffline()) {
val currentSetting = listModel.activeServer
currentSetting.musicFolderId = selectedFolderId
serverSettingsModel.updateItem(currentSetting)
}
viewAdapter.notifyDataSetChanged()
listModel.refresh(refreshListView!!, arguments)
}
/**
* Whether to show the folder selector
*/
fun showFolderHeader(): Boolean {
return listModel.showSelectFolderHeader(arguments) &&
!listModel.isOffline() && !Settings.shouldUseId3Tags
}
open fun setTitle(title: String?) {
if (title == null) {
FragmentTitle.setTitle(
this,
if (listModel.isOffline())
R.string.music_library_label_offline
else R.string.music_library_label
)
} else {
FragmentTitle.setTitle(this, title)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Set the title if available
setTitle(arguments?.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE))
// Setup refresh handler
refreshListView = view.findViewById(refreshListId)
refreshListView?.setOnRefreshListener {
listModel.refresh(refreshListView!!, arguments)
}
// Populate the LiveData. This starts an API request in most cases
liveDataItems = getLiveData(arguments)
// Register an observer to update our UI when the data changes
liveDataItems.observe(viewLifecycleOwner, { newItems -> viewAdapter.submitList(newItems) })
// Setup the Music folder handling
listModel.getMusicFolders().observe(viewLifecycleOwner, musicFolderObserver)
// Create a View Manager
viewManager = LinearLayoutManager(this.context)
// Hook up the view with the manager and the adapter
listView = view.findViewById<RecyclerView>(recyclerViewId).apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
// Configure whether to show the folder header
viewAdapter.folderHeaderEnabled = showFolderHeader()
}
@Override
override fun onCreate(savedInstanceState: Bundle?) {
Util.applyTheme(this.context)
super.onCreate(savedInstanceState)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(mainLayout, container, false)
}
abstract fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean
abstract fun onItemClick(item: T)
}
abstract class EntryListFragment<T : GenericEntry, TA : GenericRowAdapter<T>> :
GenericListFragment<T, TA>() {
@Suppress("LongMethod")
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
val isArtist = (item is Artist)
when (menuItem.itemId) {
R.id.menu_play_now ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_next ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = true,
background = false,
playNext = true,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_last ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_pin ->
downloadHandler.downloadRecursively(
this,
item.id,
save = true,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_unpin ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = true,
isArtist = isArtist
)
R.id.menu_download ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false,
isArtist = isArtist
)
}
return true
}
override fun onItemClick(item: T) {
val bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, item.id)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, item.name)
bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, item.id)
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, (item is Artist))
findNavController().navigate(itemClickTarget, bundle)
}
}

View File

@ -1,149 +0,0 @@
/*
* GenericRowAdapter.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupMenu
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.view.SelectMusicFolderView
/*
* An abstract Adapter, which can be extended to display a List of <T> in a RecyclerView
*/
abstract class GenericRowAdapter<T : Identifiable>(
val onItemClick: (T) -> Unit,
val onContextMenuClick: (MenuItem, T) -> Boolean,
private val onMusicFolderUpdate: (String?) -> Unit
) : ListAdapter<T, RecyclerView.ViewHolder>(GenericDiffCallback()) {
protected abstract val layout: Int
protected abstract val contextMenuLayout: Int
var folderHeaderEnabled: Boolean = true
var selectFolderHeader: SelectMusicFolderView? = null
var musicFolders: List<MusicFolder> = listOf()
var selectedFolder: String? = null
/**
* Sets the content and state of the music folder selector row
*/
fun setFolderList(changedFolders: List<MusicFolder>, selectedId: String?) {
musicFolders = changedFolders
selectedFolder = selectedId
selectFolderHeader?.setData(
selectedFolder,
musicFolders
)
notifyDataSetChanged()
}
open fun newViewHolder(view: View): RecyclerView.ViewHolder {
return ViewHolder(view)
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
if (viewType == TYPE_ITEM) {
val row = LayoutInflater.from(parent.context)
.inflate(layout, parent, false)
return newViewHolder(row)
} else {
val row = LayoutInflater.from(parent.context)
.inflate(
R.layout.select_folder_header, parent, false
)
selectFolderHeader = SelectMusicFolderView(parent.context, row, onMusicFolderUpdate)
if (musicFolders.isNotEmpty()) {
selectFolderHeader?.setData(
selectedFolder,
musicFolders
)
}
return selectFolderHeader!!
}
}
abstract override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
override fun getItemCount(): Int {
if (selectFolderHeader != null)
return currentList.size + 1
else
return currentList.size
}
override fun getItemViewType(position: Int): Int {
return if (position == 0 && folderHeaderEnabled) TYPE_HEADER else TYPE_ITEM
}
internal fun createPopupMenu(view: View, position: Int): Boolean {
val popup = PopupMenu(view.context, view)
val inflater: MenuInflater = popup.menuInflater
inflater.inflate(contextMenuLayout, popup.menu)
val downloadMenuItem = popup.menu.findItem(R.id.menu_download)
downloadMenuItem?.isVisible = !ActiveServerProvider.isOffline()
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, currentList[position])
}
popup.show()
return true
}
/**
* Holds the view properties of an Item row
*/
class ViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var section: TextView = itemView.findViewById(R.id.row_section)
var textView: TextView = itemView.findViewById(R.id.row_artist_name)
var layout: RelativeLayout = itemView.findViewById(R.id.row_artist_layout)
var coverArt: ImageView = itemView.findViewById(R.id.artist_coverart)
var coverArtId: String? = null
}
companion object {
internal const val TYPE_HEADER = 0
internal const val TYPE_ITEM = 1
/**
* Calculates the differences between data sets
*/
class GenericDiffCallback<T : Identifiable> : DiffUtil.ItemCallback<T>() {
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.id == newItem.id
}
}
}
}

View File

@ -201,21 +201,21 @@ class MainFragment : Fragment(), KoinComponent {
private fun showStarredSongs() {
val bundle = Bundle()
bundle.putInt(Constants.INTENT_EXTRA_NAME_STARRED, 1)
bundle.putInt(Constants.INTENT_STARRED, 1)
Navigation.findNavController(requireView()).navigate(R.id.mainToTrackCollection, bundle)
}
private fun showRandomSongs() {
val bundle = Bundle()
bundle.putInt(Constants.INTENT_EXTRA_NAME_RANDOM, 1)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, Settings.maxSongs)
bundle.putInt(Constants.INTENT_RANDOM, 1)
bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, Settings.maxSongs)
Navigation.findNavController(requireView()).navigate(R.id.mainToTrackCollection, bundle)
}
private fun showArtists() {
val bundle = Bundle()
bundle.putString(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE,
Constants.INTENT_ALBUM_LIST_TITLE,
requireContext().resources.getString(R.string.main_artists_title)
)
Navigation.findNavController(requireView()).navigate(R.id.mainToArtistList, bundle)
@ -224,10 +224,10 @@ class MainFragment : Fragment(), KoinComponent {
private fun showAlbumList(type: String, titleIndex: Int) {
val bundle = Bundle()
val title = requireContext().resources.getString(titleIndex, "")
bundle.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type)
bundle.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, title)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, Settings.maxAlbums)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0)
bundle.putString(Constants.INTENT_ALBUM_LIST_TYPE, type)
bundle.putString(Constants.INTENT_ALBUM_LIST_TITLE, title)
bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, Settings.maxAlbums)
bundle.putInt(Constants.INTENT_ALBUM_LIST_OFFSET, 0)
Navigation.findNavController(requireView()).navigate(R.id.mainToAlbumList, bundle)
}
@ -237,7 +237,7 @@ class MainFragment : Fragment(), KoinComponent {
private fun showVideos() {
val bundle = Bundle()
bundle.putInt(Constants.INTENT_EXTRA_NAME_VIDEOS, 1)
bundle.putInt(Constants.INTENT_VIDEOS, 1)
Navigation.findNavController(requireView()).navigate(R.id.mainToTrackCollection, bundle)
}

View File

@ -0,0 +1,180 @@
/*
* MultiListFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.BaseAdapter
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.model.GenericListModel
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Util
/**
* An abstract Model, which can be extended to display a list of items of type T from the API
* @param T: The type of data which will be used (must extend GenericEntry)
*/
abstract class MultiListFragment<T : Identifiable> : Fragment() {
internal val activeServerProvider: ActiveServerProvider by inject()
internal val serverSettingsModel: ServerSettingsModel by viewModel()
internal val imageLoaderProvider: ImageLoaderProvider by inject()
protected val downloadHandler: DownloadHandler by inject()
protected var refreshListView: SwipeRefreshLayout? = null
internal var listView: RecyclerView? = null
internal lateinit var viewManager: LinearLayoutManager
internal lateinit var emptyView: ConstraintLayout
internal lateinit var emptyTextView: TextView
/**
* The Adapter for the RecyclerView
* Recommendation: Implement this as a lazy delegate
*/
internal val viewAdapter: BaseAdapter<Identifiable> by lazy {
BaseAdapter()
}
/**
* The ViewModel to use to get the data
*/
open val listModel: GenericListModel by viewModels()
/**
* The LiveData containing the list provided by the model
* Implement this as a getter
*/
internal lateinit var liveDataItems: LiveData<List<T>>
/**
* The central function to pass a query to the model and return a LiveData object
*/
open fun getLiveData(args: Bundle? = null, refresh: Boolean = false): LiveData<List<T>> {
return MutableLiveData()
}
/**
* The id of the main layout
*/
open val mainLayout: Int = R.layout.list_layout_generic
/**
* The ids of the swipe refresh view, the recycler view and the empty text view
*/
open val refreshListId = R.id.swipe_refresh_view
open val recyclerViewId = R.id.recycler_view
open val emptyViewId = R.id.empty_list_view
open val emptyTextId = R.id.empty_list_text
/**
* Whether to refresh the data onViewCreated
*/
open val refreshOnCreation: Boolean = true
open fun setTitle(title: String?) {
if (title == null) {
FragmentTitle.setTitle(
this,
if (listModel.isOffline())
R.string.music_library_label_offline
else R.string.music_library_label
)
} else {
FragmentTitle.setTitle(this, title)
}
}
/**
* What to do when the list has changed
*/
internal open val defaultObserver: ((List<T>) -> Unit) = {
emptyView.isVisible = it.isEmpty() && !(refreshListView?.isRefreshing?:false)
viewAdapter.submitList(it)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Set the title if available
setTitle(arguments?.getString(Constants.INTENT_ALBUM_LIST_TITLE))
// Setup refresh handler
refreshListView = view.findViewById(refreshListId)
refreshListView?.setOnRefreshListener {
listModel.refresh(refreshListView!!, arguments)
}
// Populate the LiveData. This starts an API request in most cases
liveDataItems = getLiveData(arguments, refreshOnCreation)
// Link view to display text if the list is empty
emptyView = view.findViewById(emptyViewId)
emptyTextView = view.findViewById(emptyTextId)
// Register an observer to update our UI when the data changes
liveDataItems.observe(viewLifecycleOwner, defaultObserver)
// Create a View Manager
viewManager = LinearLayoutManager(this.context)
// Hook up the view with the manager and the adapter
listView = view.findViewById<RecyclerView>(recyclerViewId).apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
}
@Override
override fun onCreate(savedInstanceState: Bundle?) {
Util.applyTheme(this.context)
super.onCreate(savedInstanceState)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(mainLayout, container, false)
}
abstract fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean
abstract fun onItemClick(item: T)
fun getArgumentsClone(): Bundle {
var bundle: Bundle
try {
bundle = arguments?.clone() as Bundle
} catch (ignored: Exception) {
bundle = Bundle()
}
return bundle
}
}

View File

@ -36,6 +36,7 @@ import timber.log.Timber
/**
* Contains the mini-now playing information box displayed at the bottom of the screen
*/
@Suppress("unused")
class NowPlayingFragment : Fragment() {
private var downX = 0f
@ -90,13 +91,13 @@ class NowPlayingFragment : Fragment() {
if (playerState === PlayerState.PAUSED) {
playButton!!.setImageDrawable(
getDrawableFromAttribute(
context, R.attr.media_play
requireContext(), R.attr.media_play
)
)
} else if (playerState === PlayerState.STARTED) {
playButton!!.setImageDrawable(
getDrawableFromAttribute(
context, R.attr.media_pause
requireContext(), R.attr.media_pause
)
)
}
@ -122,15 +123,15 @@ class NowPlayingFragment : Fragment() {
val bundle = Bundle()
if (Settings.shouldUseId3Tags) {
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, true)
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.albumId)
bundle.putBoolean(Constants.INTENT_IS_ALBUM, true)
bundle.putString(Constants.INTENT_ID, song.albumId)
} else {
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, false)
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, song.parent)
bundle.putBoolean(Constants.INTENT_IS_ALBUM, false)
bundle.putString(Constants.INTENT_ID, song.parent)
}
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.album)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.album)
bundle.putString(Constants.INTENT_NAME, song.album)
bundle.putString(Constants.INTENT_NAME, song.album)
Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
.navigate(R.id.trackCollectionFragment, bundle)

View File

@ -36,8 +36,11 @@ import android.widget.ViewFlipper
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import com.mobeta.android.dslv.DragSortListView
import com.mobeta.android.dslv.DragSortListView.DragSortListener
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.disposables.Disposable
import java.text.DateFormat
import java.text.SimpleDateFormat
@ -58,9 +61,12 @@ import org.koin.android.ext.android.inject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.BaseAdapter
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.audiofx.VisualizerController
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.domain.RepeatMode
@ -81,7 +87,6 @@ import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.view.AutoRepeatButton
import org.moire.ultrasonic.view.SongListAdapter
import org.moire.ultrasonic.view.VisualizerView
import timber.log.Timber
@ -94,6 +99,8 @@ class PlayerFragment :
GestureDetector.OnGestureListener,
KoinComponent,
CoroutineScope by CoroutineScope(Dispatchers.Main) {
// Settings
private var swipeDistance = 0
private var swipeVelocity = 0
private var jukeboxAvailable = false
@ -104,6 +111,7 @@ class PlayerFragment :
// Detectors & Callbacks
private lateinit var gestureScanner: GestureDetector
private lateinit var cancellationToken: CancellationToken
private lateinit var dragTouchHelper: ItemTouchHelper
// Data & Services
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
@ -114,6 +122,7 @@ class PlayerFragment :
private lateinit var executorService: ScheduledExecutorService
private var currentPlaying: DownloadFile? = null
private var currentSong: MusicDirectory.Entry? = null
private lateinit var viewManager: LinearLayoutManager
private var rxBusSubscription: Disposable? = null
private var ioScope = CoroutineScope(Dispatchers.IO)
@ -133,7 +142,7 @@ class PlayerFragment :
private lateinit var albumTextView: TextView
private lateinit var artistTextView: TextView
private lateinit var albumArtImageView: ImageView
private lateinit var playlistView: DragSortListView
private lateinit var playlistView: RecyclerView
private lateinit var positionTextView: TextView
private lateinit var downloadTrackTextView: TextView
private lateinit var downloadTotalDurationTextView: TextView
@ -146,6 +155,10 @@ class PlayerFragment :
private lateinit var fullStar: Drawable
private lateinit var progressBar: SeekBar
internal val viewAdapter: BaseAdapter<Identifiable> by lazy {
BaseAdapter()
}
override fun onCreate(savedInstanceState: Bundle?) {
Util.applyTheme(this.context)
super.onCreate(savedInstanceState)
@ -217,7 +230,7 @@ class PlayerFragment :
val ratingLinearLayout = view.findViewById<LinearLayout>(R.id.song_rating)
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
hollowStar = Util.getDrawableFromAttribute(view.context, R.attr.star_hollow)
fullStar = Util.getDrawableFromAttribute(context, R.attr.star_full)
fullStar = Util.getDrawableFromAttribute(view.context, R.attr.star_full)
fiveStar1ImageView.setOnClickListener { setSongRating(1) }
fiveStar2ImageView.setOnClickListener { setSongRating(2) }
@ -322,19 +335,12 @@ class PlayerFragment :
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {}
})
playlistView.setOnItemClickListener { _, _, position, _ ->
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.play(position)
onCurrentChanged()
onSliderProgressChanged()
}
}
initPlaylistDisplay()
registerForContextMenu(playlistView)
if (arguments != null && requireArguments().getBoolean(
Constants.INTENT_EXTRA_NAME_SHUFFLE,
Constants.INTENT_SHUFFLE,
false
)
) {
@ -434,15 +440,12 @@ class PlayerFragment :
// Scroll to current playing.
private fun scrollToCurrent() {
val adapter = playlistView.adapter
if (adapter != null) {
val count = adapter.count
for (i in 0 until count) {
if (currentPlaying == playlistView.getItemAtPosition(i)) {
playlistView.smoothScrollToPositionFromTop(i, 40)
return
}
}
val index = mediaPlayerController.playList.indexOf(currentPlaying)
if (index != -1) {
val smoothScroller = LinearSmoothScroller(context)
smoothScroller.targetPosition = index
viewManager.startSmoothScroll(smoothScroller)
}
}
@ -537,7 +540,7 @@ class PlayerFragment :
super.onCreateContextMenu(menu, view, menuInfo)
if (view === playlistView) {
val info = menuInfo as AdapterContextMenuInfo?
val downloadFile = playlistView.getItemAtPosition(info!!.position) as DownloadFile
val downloadFile = viewAdapter.getCurrentList()[info!!.position] as DownloadFile
val menuInflater = requireActivity().menuInflater
menuInflater.inflate(R.menu.nowplaying_context, menu)
val song: MusicDirectory.Entry?
@ -561,14 +564,6 @@ class PlayerFragment :
}
}
override fun onContextItemSelected(menuItem: MenuItem): Boolean {
val info = menuItem.menuInfo as AdapterContextMenuInfo
val downloadFile = playlistView.getItemAtPosition(info.position) as DownloadFile
return menuItemSelected(menuItem.itemId, downloadFile) || super.onContextItemSelected(
menuItem
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return menuItemSelected(item.itemId, null) || super.onOptionsItemSelected(item)
}
@ -587,10 +582,10 @@ class PlayerFragment :
if (Settings.shouldUseId3Tags) {
bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, entry.artistId)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.artist)
bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, entry.artistId)
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true)
bundle.putString(Constants.INTENT_ID, entry.artistId)
bundle.putString(Constants.INTENT_NAME, entry.artist)
bundle.putString(Constants.INTENT_PARENT_ID, entry.artistId)
bundle.putBoolean(Constants.INTENT_ARTIST, true)
Navigation.findNavController(requireView())
.navigate(R.id.playerToSelectAlbum, bundle)
}
@ -601,10 +596,10 @@ class PlayerFragment :
val albumId = if (Settings.shouldUseId3Tags) entry.albumId else entry.parent
bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, albumId)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.album)
bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, entry.parent)
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, true)
bundle.putString(Constants.INTENT_ID, albumId)
bundle.putString(Constants.INTENT_NAME, entry.album)
bundle.putString(Constants.INTENT_PARENT_ID, entry.parent)
bundle.putBoolean(Constants.INTENT_IS_ALBUM, true)
Navigation.findNavController(requireView())
.navigate(R.id.playerToSelectAlbum, bundle)
return true
@ -613,8 +608,8 @@ class PlayerFragment :
if (entry == null) return false
bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_ARTIST, entry.artist)
bundle.putString(Constants.INTENT_EXTRA_NAME_TITLE, entry.title)
bundle.putString(Constants.INTENT_ARTIST, entry.artist)
bundle.putString(Constants.INTENT_TITLE, entry.title)
Navigation.findNavController(requireView()).navigate(R.id.playerToLyrics, bundle)
return true
}
@ -844,60 +839,129 @@ class PlayerFragment :
}
}
private fun initPlaylistDisplay() {
// Create a View Manager
viewManager = LinearLayoutManager(this.context)
// Hook up the view with the manager and the adapter
playlistView.apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
// Create listener
val listener: ((DownloadFile) -> Unit) = { file ->
val list = mediaPlayerController.playList
val index = list.indexOf(file)
mediaPlayerController.play(index)
onCurrentChanged()
onSliderProgressChanged()
}
viewAdapter.register(
TrackViewBinder(
onItemClick = listener,
checkable = false,
draggable = true,
context = requireContext(),
lifecycleOwner = viewLifecycleOwner,
).apply {
this.startDrag = { holder ->
dragTouchHelper.startDrag(holder)
}
}
)
dragTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
// Move it in the data set
mediaPlayerController.moveItemInPlaylist(from, to)
viewAdapter.submitList(mediaPlayerController.playList)
return true
}
// Swipe to delete from playlist
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val pos = viewHolder.bindingAdapterPosition
val file = mediaPlayerController.playList[pos]
mediaPlayerController.removeFromPlaylist(file)
val songRemoved = String.format(
resources.getString(R.string.download_song_removed),
file.song.title
)
Util.toast(context, songRemoved)
viewAdapter.submitList(mediaPlayerController.playList)
viewAdapter.notifyDataSetChanged()
}
override fun onSelectedChanged(
viewHolder: RecyclerView.ViewHolder?,
actionState: Int
) {
super.onSelectedChanged(viewHolder, actionState)
if (actionState == ACTION_STATE_DRAG) {
viewHolder?.itemView?.alpha = 0.6f
}
}
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.alpha = 1.0f
}
override fun isLongPressDragEnabled(): Boolean {
return false
}
}
)
dragTouchHelper.attachToRecyclerView(playlistView)
}
private fun onPlaylistChanged() {
val mediaPlayerController = mediaPlayerController
val list = mediaPlayerController.playList
emptyTextView.setText(R.string.download_empty)
val adapter = SongListAdapter(context, list)
playlistView.adapter = adapter
playlistView.setDragSortListener(object : DragSortListener {
override fun drop(from: Int, to: Int) {
if (from != to) {
val item = adapter.getItem(from)
adapter.remove(item)
adapter.notifyDataSetChanged()
adapter.insert(item, to)
adapter.notifyDataSetChanged()
}
}
emptyTextView.setText(R.string.playlist_empty)
override fun drag(from: Int, to: Int) {}
override fun remove(which: Int) {
val item = adapter.getItem(which) ?: return
val currentPlaying = mediaPlayerController.currentPlaying
if (currentPlaying == item) {
mediaPlayerController.next()
}
adapter.remove(item)
adapter.notifyDataSetChanged()
val songRemoved = String.format(
resources.getString(R.string.download_song_removed),
item.song.title
)
Util.toast(context, songRemoved)
onPlaylistChanged()
onCurrentChanged()
}
})
viewAdapter.submitList(list)
emptyTextView.isVisible = list.isEmpty()
when (mediaPlayerController.repeatMode) {
RepeatMode.OFF -> repeatButton.setImageDrawable(
Util.getDrawableFromAttribute(
context, R.attr.media_repeat_off
requireContext(), R.attr.media_repeat_off
)
)
RepeatMode.ALL -> repeatButton.setImageDrawable(
Util.getDrawableFromAttribute(
context, R.attr.media_repeat_all
requireContext(), R.attr.media_repeat_all
)
)
RepeatMode.SINGLE -> repeatButton.setImageDrawable(
Util.getDrawableFromAttribute(
context, R.attr.media_repeat_single
requireContext(), R.attr.media_repeat_single
)
)
else -> {

View File

@ -0,0 +1,446 @@
package org.moire.ultrasonic.fragment
import android.app.SearchManager
import android.content.Context
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.viewModelScope
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.AlbumRowBinder
import org.moire.ultrasonic.adapters.ArtistRowBinder
import org.moire.ultrasonic.adapters.DividerBinder
import org.moire.ultrasonic.adapters.MoreButtonBinder
import org.moire.ultrasonic.adapters.MoreButtonBinder.MoreButton
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.model.SearchListModel
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util.toast
import timber.log.Timber
/**
* Initiates a search on the media library and displays the results
*/
class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
private var searchResult: SearchResult? = null
private var searchRefresh: SwipeRefreshLayout? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val shareHandler: ShareHandler by inject()
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private var cancellationToken: CancellationToken? = null
override val listModel: SearchListModel by viewModels()
override val mainLayout: Int = R.layout.search
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
cancellationToken = CancellationToken()
setTitle(this, R.string.search_title)
setHasOptionsMenu(true)
listModel.searchResult.observe(
viewLifecycleOwner,
{
if (it != null) {
// Shorten the display initially
searchResult = it
populateList(listModel.trimResultLength(it))
}
}
)
searchRefresh = view.findViewById(R.id.swipe_refresh_view)
searchRefresh!!.isEnabled = false
registerForContextMenu(listView!!)
// Register our data binders
// IMPORTANT:
// They need to be added in the order of most specific -> least specific.
viewAdapter.register(
ArtistRowBinder(
onItemClick = ::onItemClick,
onContextMenuClick = ::onContextMenuItemSelected,
imageLoader = imageLoaderProvider.getImageLoader(),
enableSections = false
)
)
viewAdapter.register(
AlbumRowBinder(
onItemClick = ::onItemClick,
onContextMenuClick = ::onContextMenuItemSelected,
imageLoader = imageLoaderProvider.getImageLoader(),
context = requireContext()
)
)
viewAdapter.register(
TrackViewBinder(
onItemClick = ::onItemClick,
onContextMenuClick = ::onContextMenuItemSelected,
checkable = false,
draggable = false,
context = requireContext(),
lifecycleOwner = viewLifecycleOwner
)
)
viewAdapter.register(
DividerBinder()
)
viewAdapter.register(
MoreButtonBinder()
)
// Fragment was started with a query (e.g. from voice search), try to execute search right away
val arguments = arguments
if (arguments != null) {
val query = arguments.getString(Constants.INTENT_QUERY)
val autoPlay = arguments.getBoolean(Constants.INTENT_AUTOPLAY, false)
if (query != null) {
return search(query, autoPlay)
}
}
}
/**
* This method create the search bar above the recycler view
*/
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
val activity = activity ?: return
val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager
inflater.inflate(R.menu.search, menu)
val searchItem = menu.findItem(R.id.search_item)
val searchView = searchItem.actionView as SearchView
val searchableInfo = searchManager.getSearchableInfo(requireActivity().componentName)
searchView.setSearchableInfo(searchableInfo)
val arguments = arguments
val autoPlay = arguments != null &&
arguments.getBoolean(Constants.INTENT_AUTOPLAY, false)
val query = arguments?.getString(Constants.INTENT_QUERY)
// If started with a query, enter it to the searchView
if (query != null) {
searchView.setQuery(query, false)
searchView.clearFocus()
}
searchView.setOnSuggestionListener(object : SearchView.OnSuggestionListener {
override fun onSuggestionSelect(position: Int): Boolean {
return true
}
override fun onSuggestionClick(position: Int): Boolean {
Timber.d("onSuggestionClick: %d", position)
val cursor = searchView.suggestionsAdapter.cursor
cursor.moveToPosition(position)
// 2 is the index of col containing suggestion name.
val suggestion = cursor.getString(2)
searchView.setQuery(suggestion, true)
return true
}
})
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
Timber.d("onQueryTextSubmit: %s", query)
searchView.clearFocus()
search(query, autoPlay)
return true
}
override fun onQueryTextChange(newText: String): Boolean {
return true
}
})
searchView.setIconifiedByDefault(false)
searchItem.expandActionView()
}
override fun onDestroyView() {
cancellationToken?.cancel()
super.onDestroyView()
}
private fun downloadBackground(save: Boolean, songs: List<MusicDirectory.Entry?>) {
val onValid = Runnable {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.downloadBackground(songs, save)
}
onValid.run()
}
private fun search(query: String, autoplay: Boolean) {
listModel.viewModelScope.launch(CommunicationError.getHandler(context)) {
refreshListView?.isRefreshing = true
listModel.search(query)
refreshListView?.isRefreshing = false
}.invokeOnCompletion {
if (it == null && autoplay) {
autoplay()
}
}
}
private fun populateList(result: SearchResult) {
val list = mutableListOf<Identifiable>()
val artists = result.artists
if (artists.isNotEmpty()) {
list.add(DividerBinder.Divider(R.string.search_artists))
list.addAll(artists)
if (searchResult!!.artists.size > artists.size) {
list.add(MoreButton(0, ::expandArtists))
}
}
val albums = result.albums
if (albums.isNotEmpty()) {
list.add(DividerBinder.Divider(R.string.search_albums))
list.addAll(albums)
if (searchResult!!.albums.size > albums.size) {
list.add(MoreButton(1, ::expandAlbums))
}
}
val songs = result.songs
if (songs.isNotEmpty()) {
list.add(DividerBinder.Divider(R.string.search_songs))
list.addAll(songs)
if (searchResult!!.songs.size > songs.size) {
list.add(MoreButton(2, ::expandSongs))
}
}
// Show/hide the empty text view
emptyView.isVisible = list.isEmpty()
viewAdapter.submitList(list)
}
private fun expandArtists() {
populateList(listModel.trimResultLength(searchResult!!, maxArtists = Int.MAX_VALUE))
}
private fun expandAlbums() {
populateList(listModel.trimResultLength(searchResult!!, maxAlbums = Int.MAX_VALUE))
}
private fun expandSongs() {
populateList(listModel.trimResultLength(searchResult!!, maxSongs = Int.MAX_VALUE))
}
private fun onArtistSelected(item: ArtistOrIndex) {
val bundle = Bundle()
// Common arguments
bundle.putString(Constants.INTENT_ID, item.id)
bundle.putString(Constants.INTENT_NAME, item.name)
bundle.putString(Constants.INTENT_PARENT_ID, item.id)
bundle.putBoolean(Constants.INTENT_ARTIST, (item is Artist))
// Check type
if (item is Index) {
findNavController().navigate(R.id.searchToTrackCollection, bundle)
} else {
bundle.putString(Constants.INTENT_ALBUM_LIST_TYPE, Constants.ALBUMS_OF_ARTIST)
bundle.putString(Constants.INTENT_ALBUM_LIST_TITLE, item.name)
bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, 1000)
bundle.putInt(Constants.INTENT_ALBUM_LIST_OFFSET, 0)
findNavController().navigate(R.id.searchToAlbumsList, bundle)
}
}
private fun onAlbumSelected(album: MusicDirectory.Album, autoplay: Boolean) {
val bundle = Bundle()
bundle.putString(Constants.INTENT_ID, album.id)
bundle.putString(Constants.INTENT_NAME, album.title)
bundle.putBoolean(Constants.INTENT_IS_ALBUM, album.isDirectory)
bundle.putBoolean(Constants.INTENT_AUTOPLAY, autoplay)
Navigation.findNavController(requireView()).navigate(R.id.searchToTrackCollection, bundle)
}
private fun onSongSelected(song: MusicDirectory.Entry, append: Boolean) {
if (!append) {
mediaPlayerController.clear()
}
mediaPlayerController.addToPlaylist(
listOf(song),
save = false,
autoPlay = false,
playNext = false,
shuffle = false,
newPlaylist = false
)
mediaPlayerController.play(mediaPlayerController.playlistSize - 1)
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
}
private fun onVideoSelected(entry: MusicDirectory.Entry) {
playVideo(requireContext(), entry)
}
private fun autoplay() {
if (searchResult!!.songs.isNotEmpty()) {
onSongSelected(searchResult!!.songs[0], false)
} else if (searchResult!!.albums.isNotEmpty()) {
onAlbumSelected(searchResult!!.albums[0], true)
}
}
override fun onItemClick(item: Identifiable) {
when (item) {
is ArtistOrIndex -> {
onArtistSelected(item)
}
is MusicDirectory.Entry -> {
if (item.isVideo) {
onVideoSelected(item)
} else {
onSongSelected(item, true)
}
}
is MusicDirectory.Album -> {
onAlbumSelected(item, false)
}
}
}
@Suppress("LongMethod")
override fun onContextMenuItemSelected(menuItem: MenuItem, item: Identifiable): Boolean {
val isArtist = (item is Artist)
val found = EntryListFragment.handleContextMenu(
menuItem,
item,
isArtist,
downloadHandler,
this
)
if (found || item !is DownloadFile) return true
val songs = mutableListOf<MusicDirectory.Entry>()
when (menuItem.itemId) {
R.id.song_menu_play_now -> {
songs.add(item.song)
downloadHandler.download(
fragment = this,
append = false,
save = false,
autoPlay = true,
playNext = false,
shuffle = false,
songs = songs
)
}
R.id.song_menu_play_next -> {
songs.add(item.song)
downloadHandler.download(
fragment = this,
append = true,
save = false,
autoPlay = false,
playNext = true,
shuffle = false,
songs = songs
)
}
R.id.song_menu_play_last -> {
songs.add(item.song)
downloadHandler.download(
fragment = this,
append = true,
save = false,
autoPlay = false,
playNext = false,
shuffle = false,
songs = songs
)
}
R.id.song_menu_pin -> {
songs.add(item.song)
toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_pinned,
songs.size,
songs.size
)
)
downloadBackground(true, songs)
}
R.id.song_menu_download -> {
songs.add(item.song)
toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_downloaded,
songs.size,
songs.size
)
)
downloadBackground(false, songs)
}
R.id.song_menu_unpin -> {
songs.add(item.song)
toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_unpinned,
songs.size,
songs.size
)
)
mediaPlayerController.unpin(songs)
}
R.id.song_menu_share -> {
songs.add(item.song)
shareHandler.createShare(this, songs, searchRefresh, cancellationToken!!)
}
}
return true
}
companion object {
var DEFAULT_ARTISTS = Settings.defaultArtists
var DEFAULT_ALBUMS = Settings.defaultAlbums
var DEFAULT_SONGS = Settings.defaultSongs
}
}

View File

@ -18,6 +18,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.fragment.EditServerFragment.Companion.EDIT_SERVER_INTENT_INDEX
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.Util
import timber.log.Timber

View File

@ -85,7 +85,6 @@ class SettingsFragment :
private var sendBluetoothNotifications: CheckBoxPreference? = null
private var sendBluetoothAlbumArt: CheckBoxPreference? = null
private var showArtistPicture: CheckBoxPreference? = null
private var viewRefresh: ListPreference? = null
private var sharingDefaultDescription: EditTextPreference? = null
private var sharingDefaultGreeting: EditTextPreference? = null
private var sharingDefaultExpiration: TimeSpanPreference? = null
@ -130,7 +129,6 @@ class SettingsFragment :
sendBluetoothAlbumArt = findPreference(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART)
sendBluetoothNotifications =
findPreference(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS)
viewRefresh = findPreference(Constants.PREFERENCES_KEY_VIEW_REFRESH)
sharingDefaultDescription =
findPreference(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION)
sharingDefaultGreeting = findPreference(Constants.PREFERENCES_KEY_DEFAULT_SHARE_GREETING)
@ -402,7 +400,6 @@ class SettingsFragment :
defaultSongs!!.summary = defaultSongs!!.entry
chatRefreshInterval!!.summary = chatRefreshInterval!!.entry
directoryCacheTime!!.summary = directoryCacheTime!!.entry
viewRefresh!!.summary = viewRefresh!!.entry
sharingDefaultExpiration!!.summary = sharingDefaultExpiration!!.text
sharingDefaultDescription!!.summary = sharingDefaultDescription!!.text
sharingDefaultGreeting!!.summary = sharingDefaultGreeting!!.text

View File

@ -1,294 +0,0 @@
/*
* TrackCollectionModel.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import java.util.LinkedList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
/*
* Model for retrieving different collections of tracks from the API
* TODO: Refactor this model to extend the GenericListModel
*/
class TrackCollectionModel(application: Application) : GenericListModel(application) {
private val allSongsId = "-1"
val currentDirectory: MutableLiveData<MusicDirectory> = MutableLiveData()
val songsForGenre: MutableLiveData<MusicDirectory> = MutableLiveData()
suspend fun getMusicFolders(refresh: Boolean) {
withContext(Dispatchers.IO) {
if (!isOffline()) {
val musicService = MusicServiceFactory.getMusicService()
musicFolders.postValue(musicService.getMusicFolders(refresh))
}
}
}
suspend fun getMusicDirectory(
refresh: Boolean,
id: String,
name: String?,
parentId: String?
) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
var root = MusicDirectory()
if (allSongsId == id && parentId != null) {
val musicDirectory = service.getMusicDirectory(
parentId, name, refresh
)
val songs: MutableList<MusicDirectory.Entry> = LinkedList()
getSongsRecursively(musicDirectory, songs)
for (song in songs) {
if (!song.isDirectory) {
root.addChild(song)
}
}
} else {
val musicDirectory = service.getMusicDirectory(id, name, refresh)
if (Settings.shouldShowAllSongsByArtist &&
musicDirectory.findChild(allSongsId) == null &&
hasOnlyFolders(musicDirectory)
) {
val allSongs = MusicDirectory.Entry(allSongsId)
allSongs.isDirectory = true
allSongs.artist = name
allSongs.parent = id
allSongs.title = String.format(
context.resources.getString(R.string.select_album_all_songs), name
)
root.addChild(allSongs)
root.addAll(musicDirectory.getChildren())
} else {
root = musicDirectory
}
}
currentDirectory.postValue(root)
}
}
// Given a Music directory "songs" it recursively adds all children to "songs"
private fun getSongsRecursively(
parent: MusicDirectory,
songs: MutableList<MusicDirectory.Entry>
) {
val service = MusicServiceFactory.getMusicService()
for (song in parent.getChildren(includeDirs = false, includeFiles = true)) {
if (!song.isVideo && !song.isDirectory) {
songs.add(song)
}
}
for ((id1, _, _, title) in parent.getChildren(true, includeFiles = false)) {
var root: MusicDirectory
if (allSongsId != id1) {
root = service.getMusicDirectory(id1, title, false)
getSongsRecursively(root, songs)
}
}
}
/*
* TODO: This method should be moved to AlbumListModel,
* since it displays a list of albums by a specified artist.
*/
suspend fun getArtist(refresh: Boolean, id: String, name: String?) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
var root = MusicDirectory()
val musicDirectory = service.getArtist(id, name, refresh)
if (Settings.shouldShowAllSongsByArtist &&
musicDirectory.findChild(allSongsId) == null &&
hasOnlyFolders(musicDirectory)
) {
val allSongs = MusicDirectory.Entry(allSongsId)
allSongs.isDirectory = true
allSongs.artist = name
allSongs.parent = id
allSongs.title = String.format(
context.resources.getString(R.string.select_album_all_songs), name
)
root.addFirst(allSongs)
root.addAll(musicDirectory.getChildren())
} else {
root = musicDirectory
}
currentDirectory.postValue(root)
}
}
suspend fun getAlbum(refresh: Boolean, id: String, name: String?, parentId: String?) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory: MusicDirectory
if (allSongsId == id && parentId != null) {
val root = MusicDirectory()
val songs: MutableCollection<MusicDirectory.Entry> = LinkedList()
val artist = service.getArtist(parentId, "", false)
for ((id1) in artist.getChildren()) {
if (allSongsId != id1) {
val albumDirectory = service.getAlbum(
id1, "", false
)
for (song in albumDirectory.getChildren()) {
if (!song.isVideo) {
songs.add(song)
}
}
}
}
for (song in songs) {
if (!song.isDirectory) {
root.addChild(song)
}
}
musicDirectory = root
} else {
musicDirectory = service.getAlbum(id, name, refresh)
}
currentDirectory.postValue(musicDirectory)
}
}
suspend fun getSongsForGenre(genre: String, count: Int, offset: Int) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getSongsByGenre(genre, count, offset)
songsForGenre.postValue(musicDirectory)
}
}
suspend fun getStarred() {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory: MusicDirectory
if (Settings.shouldUseId3Tags) {
musicDirectory = Util.getSongsFromSearchResult(service.getStarred2())
} else {
musicDirectory = Util.getSongsFromSearchResult(service.getStarred())
}
currentDirectory.postValue(musicDirectory)
}
}
suspend fun getVideos(refresh: Boolean) {
showHeader = false
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
currentDirectory.postValue(service.getVideos(refresh))
}
}
suspend fun getRandom(size: Int) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getRandomSongs(size)
currentListIsSortable = false
currentDirectory.postValue(musicDirectory)
}
}
suspend fun getPlaylist(playlistId: String, playlistName: String) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getPlaylist(playlistId, playlistName)
currentDirectory.postValue(musicDirectory)
}
}
suspend fun getPodcastEpisodes(podcastChannelId: String) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getPodcastEpisodes(podcastChannelId)
currentDirectory.postValue(musicDirectory)
}
}
suspend fun getShare(shareId: String) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = MusicDirectory()
val shares = service.getShares(true)
for (share in shares) {
if (share.id == shareId) {
for (entry in share.getEntries()) {
musicDirectory.addChild(entry)
}
break
}
}
currentDirectory.postValue(musicDirectory)
}
}
// Returns true if the directory contains only folders
private fun hasOnlyFolders(musicDirectory: MusicDirectory) =
musicDirectory.getChildren(includeDirs = true, includeFiles = false).size ==
musicDirectory.getChildren(includeDirs = true, includeFiles = true).size
override fun load(
isOffline: Boolean,
useId3Tags: Boolean,
musicService: MusicService,
refresh: Boolean,
args: Bundle
) {
// See To_Do at the top
}
}

View File

@ -89,7 +89,7 @@ class ImageLoader(
@JvmOverloads
fun loadImage(
view: View?,
entry: MusicDirectory.Entry?,
entry: MusicDirectory.Child?,
large: Boolean,
size: Int,
defaultResourceId: Int = R.drawable.unknown_album

View File

@ -1,4 +1,4 @@
package org.moire.ultrasonic.fragment
package org.moire.ultrasonic.model
import android.app.Application
import android.os.Bundle
@ -13,7 +13,7 @@ import org.moire.ultrasonic.util.Settings
class AlbumListModel(application: Application) : GenericListModel(application) {
val albumList: MutableLiveData<List<MusicDirectory.Entry>> = MutableLiveData(listOf())
val list: MutableLiveData<List<MusicDirectory.Album>> = MutableLiveData()
var lastType: String? = null
private var loadedUntil: Int = 0
@ -21,16 +21,25 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
refresh: Boolean,
swipe: SwipeRefreshLayout,
args: Bundle
): LiveData<List<MusicDirectory.Entry>> {
): LiveData<List<MusicDirectory.Album>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
val albumListType = args.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE)!!
val albumListType = args.getString(Constants.INTENT_ALBUM_LIST_TYPE)!!
if (refresh || albumList.value!!.isEmpty() || albumListType != lastType) {
if (refresh || list.value?.isEmpty() != false || albumListType != lastType) {
lastType = albumListType
backgroundLoadFromServer(refresh, swipe, args)
}
return albumList
return list
}
private fun getAlbumsOfArtist(
musicService: MusicService,
refresh: Boolean,
id: String,
name: String?
) {
list.postValue(musicService.getArtist(id, name, refresh))
}
override fun load(
@ -42,27 +51,43 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
) {
super.load(isOffline, useId3Tags, musicService, refresh, args)
val albumListType = args.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE)!!
val size = args.getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0)
var offset = args.getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0)
val append = args.getBoolean(Constants.INTENT_EXTRA_NAME_APPEND, false)
val albumListType = args.getString(Constants.INTENT_ALBUM_LIST_TYPE)!!
val size = args.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0)
var offset = args.getInt(Constants.INTENT_ALBUM_LIST_OFFSET, 0)
val append = args.getBoolean(Constants.INTENT_APPEND, false)
val musicDirectory: MusicDirectory
val musicDirectory: List<MusicDirectory.Album>
val musicFolderId = if (showSelectFolderHeader(args)) {
activeServerProvider.getActiveServer().musicFolderId
} else {
null
}
// If we are refreshing the random list, we want to avoid items moving across the screen,
// by clearing the list first
if (refresh && albumListType == "random") {
list.postValue(listOf())
}
// Handle the logic for endless scrolling:
// If appending the existing list, set the offset from where to load
if (append) offset += (size + loadedUntil)
if (useId3Tags) {
musicDirectory = musicService.getAlbumList2(
albumListType, size,
offset, musicFolderId
if (albumListType == Constants.ALBUMS_OF_ARTIST) {
return getAlbumsOfArtist(
musicService,
refresh,
args.getString(Constants.INTENT_ID, ""),
args.getString(Constants.INTENT_NAME, "")
)
}
if (useId3Tags) {
musicDirectory =
musicService.getAlbumList2(
albumListType, size,
offset, musicFolderId
)
} else {
musicDirectory = musicService.getAlbumList(
albumListType, size,
@ -72,13 +97,13 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
currentListIsSortable = isCollectionSortable(albumListType)
if (append && albumList.value != null) {
val list = ArrayList<MusicDirectory.Entry>()
list.addAll(albumList.value!!)
list.addAll(musicDirectory.getAllChild())
albumList.postValue(list)
if (append && list.value != null) {
val newList = ArrayList<MusicDirectory.Album>()
newList.addAll(list.value!!)
newList.addAll(musicDirectory)
list.postValue(newList)
} else {
albumList.postValue(musicDirectory.getAllChild())
list.postValue(musicDirectory)
}
loadedUntil = offset
@ -87,7 +112,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
override fun showSelectFolderHeader(args: Bundle?): Boolean {
if (args == null) return false
val albumListType = args.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE)!!
val albumListType = args.getString(Constants.INTENT_ALBUM_LIST_TYPE)!!
val isAlphabetical = (albumListType == AlbumListType.SORTED_BY_NAME.toString()) ||
(albumListType == AlbumListType.SORTED_BY_ARTIST.toString())

View File

@ -16,7 +16,7 @@
Copyright 2020 (C) Jozsef Varga
*/
package org.moire.ultrasonic.fragment
package org.moire.ultrasonic.model
import android.app.Application
import android.os.Bundle
@ -31,7 +31,7 @@ import org.moire.ultrasonic.service.MusicService
* Provides ViewModel which contains the list of available Artists
*/
class ArtistListModel(application: Application) : GenericListModel(application) {
private val artists: MutableLiveData<List<ArtistOrIndex>> = MutableLiveData(listOf())
private val artists: MutableLiveData<List<ArtistOrIndex>> = MutableLiveData()
/**
* Retrieves all available Artists in a LiveData
@ -39,7 +39,7 @@ class ArtistListModel(application: Application) : GenericListModel(application)
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<ArtistOrIndex>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
if (artists.value!!.isEmpty() || refresh) {
if (artists.value?.isEmpty() != false || refresh) {
backgroundLoadFromServer(refresh, swipe)
}
return artists
@ -67,6 +67,10 @@ class ArtistListModel(application: Application) : GenericListModel(application)
artists.postValue(result.toMutableList().sortedWith(comparator))
}
override fun showSelectFolderHeader(args: Bundle?): Boolean {
return true
}
companion object {
val comparator: Comparator<ArtistOrIndex> =
compareBy(Collator.getInstance()) { t -> t.name }

View File

@ -1,4 +1,4 @@
package org.moire.ultrasonic.fragment
package org.moire.ultrasonic.model
import android.app.Application
import android.content.Context
@ -6,7 +6,6 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@ -24,8 +23,8 @@ import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Settings
/**
* An abstract Model, which can be extended to retrieve a list of items from the API
*/
* An abstract Model, which can be extended to retrieve a list of items from the API
*/
open class GenericListModel(application: Application) :
AndroidViewModel(application), KoinComponent {
@ -40,12 +39,11 @@ open class GenericListModel(application: Application) :
var currentListIsSortable = true
var showHeader = true
@Suppress("UNUSED_PARAMETER")
open fun showSelectFolderHeader(args: Bundle?): Boolean {
return true
}
val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData(listOf())
internal val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData(listOf())
open fun showSelectFolderHeader(args: Bundle?): Boolean {
return false
}
/**
* Helper function to check online status
@ -109,17 +107,11 @@ open class GenericListModel(application: Application) :
args: Bundle
) {
// Update the list of available folders if enabled
if (showSelectFolderHeader(args) && !isOffline && !useId3Tags) {
@Suppress("ComplexCondition")
if (showSelectFolderHeader(args) && !isOffline && !useId3Tags && refresh) {
musicFolders.postValue(
musicService.getMusicFolders(refresh)
)
}
}
/**
* Retrieves the available Music Folders in a LiveData
*/
fun getMusicFolders(): LiveData<List<MusicFolder>> {
return musicFolders
}
}

View File

@ -0,0 +1,55 @@
package org.moire.ultrasonic.model
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.fragment.SearchFragment
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Settings
class SearchListModel(application: Application) : GenericListModel(application) {
var searchResult: MutableLiveData<SearchResult?> = MutableLiveData()
override fun load(
isOffline: Boolean,
useId3Tags: Boolean,
musicService: MusicService,
refresh: Boolean,
args: Bundle
) {
super.load(isOffline, useId3Tags, musicService, refresh, args)
}
suspend fun search(query: String) {
val maxArtists = Settings.maxArtists
val maxAlbums = Settings.maxAlbums
val maxSongs = Settings.maxSongs
withContext(Dispatchers.IO) {
val criteria = SearchCriteria(query, maxArtists, maxAlbums, maxSongs)
val service = MusicServiceFactory.getMusicService()
val result = service.search(criteria)
if (result != null) searchResult.postValue(result)
}
}
fun trimResultLength(
result: SearchResult,
maxArtists: Int = SearchFragment.DEFAULT_ARTISTS,
maxAlbums: Int = SearchFragment.DEFAULT_ALBUMS,
maxSongs: Int = SearchFragment.DEFAULT_SONGS
): SearchResult {
return SearchResult(
artists = result.artists.take(maxArtists),
albums = result.albums.take(maxAlbums),
songs = result.songs.take(maxSongs)
)
}
}

View File

@ -1,4 +1,4 @@
package org.moire.ultrasonic.fragment
package org.moire.ultrasonic.model
import android.app.Application
import android.content.SharedPreferences

View File

@ -0,0 +1,159 @@
/*
* TrackCollectionModel.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
/*
* Model for retrieving different collections of tracks from the API
*/
class TrackCollectionModel(application: Application) : GenericListModel(application) {
val currentList: MutableLiveData<List<MusicDirectory.Child>> = MutableLiveData()
/*
* Especially when dealing with indexes, this method can return Albums, Entries or a mix of both!
*/
suspend fun getMusicDirectory(
refresh: Boolean,
id: String,
name: String?
) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getMusicDirectory(id, name, refresh)
updateList(musicDirectory)
}
}
suspend fun getAlbum(refresh: Boolean, id: String, name: String?) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory: MusicDirectory = service.getAlbum(id, name, refresh)
updateList(musicDirectory)
}
}
suspend fun getSongsForGenre(genre: String, count: Int, offset: Int) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getSongsByGenre(genre, count, offset)
updateList(musicDirectory)
}
}
suspend fun getStarred() {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory: MusicDirectory
if (Settings.shouldUseId3Tags) {
musicDirectory = Util.getSongsFromSearchResult(service.getStarred2())
} else {
musicDirectory = Util.getSongsFromSearchResult(service.getStarred())
}
updateList(musicDirectory)
}
}
suspend fun getVideos(refresh: Boolean) {
showHeader = false
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val videos = service.getVideos(refresh)
if (videos != null) {
updateList(videos)
}
}
}
suspend fun getRandom(size: Int) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getRandomSongs(size)
currentListIsSortable = false
updateList(musicDirectory)
}
}
suspend fun getPlaylist(playlistId: String, playlistName: String) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getPlaylist(playlistId, playlistName)
updateList(musicDirectory)
}
}
suspend fun getPodcastEpisodes(podcastChannelId: String) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getPodcastEpisodes(podcastChannelId)
if (musicDirectory != null) {
updateList(musicDirectory)
}
}
}
suspend fun getShare(shareId: String) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = MusicDirectory()
val shares = service.getShares(true)
for (share in shares) {
if (share.id == shareId) {
for (entry in share.getEntries()) {
musicDirectory.add(entry)
}
break
}
}
updateList(musicDirectory)
}
}
suspend fun getBookmarks() {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = Util.getSongsFromBookmarks(service.getBookmarks())
updateList(musicDirectory)
}
}
private fun updateList(root: MusicDirectory) {
currentList.postValue(root.getChildren())
}
}

View File

@ -484,10 +484,12 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val albums = if (!isOffline && useId3Tags) {
callWithErrorHandling { musicService.getArtist(id, name, false) }
} else {
callWithErrorHandling { musicService.getMusicDirectory(id, name, false) }
callWithErrorHandling {
musicService.getMusicDirectory(id, name, false).getAlbums()
}
}
albums?.getAllChild()?.map { album ->
albums?.map { album ->
mediaItems.add(
album.title ?: "",
listOf(MEDIA_ALBUM_ITEM, album.id, album.name)
@ -517,7 +519,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|"))
// TODO: Paging is not implemented for songs, is it necessary at all?
val items = songs.getChildren().take(DISPLAY_LIMIT)
val items = songs.getTracks().take(DISPLAY_LIMIT)
items.map { item ->
if (item.isDirectory)
mediaItems.add(
@ -573,7 +575,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
}
}
albums?.getAllChild()?.map { album ->
albums?.map { album ->
mediaItems.add(
album.title ?: "",
listOf(MEDIA_ALBUM_ITEM, album.id, album.name)
@ -582,7 +584,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
)
}
if (albums?.getAllChild()?.count() ?: 0 >= DISPLAY_LIMIT)
if (albums?.size ?: 0 >= DISPLAY_LIMIT)
mediaItems.add(
R.string.search_more,
listOf(MEDIA_ALBUM_PAGE_ID, type.typeName, (page ?: 0) + 1).joinToString("|"),
@ -624,13 +626,13 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
if (content != null) {
if (content.getAllChild().count() > 1)
if (content.size > 1)
mediaItems.addPlayAllItem(
listOf(MEDIA_PLAYLIST_ITEM, id, name).joinToString("|")
)
// Playlist should be cached as it may contain random elements
playlistCache = content.getAllChild()
playlistCache = content.getTracks()
playlistCache!!.take(DISPLAY_LIMIT).map { item ->
mediaItems.add(
MediaBrowserCompat.MediaItem(
@ -657,7 +659,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
if (playlistCache == null) {
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
playlistCache = content?.getAllChild()
playlistCache = content?.getTracks()
}
if (playlistCache != null) playSongs(playlistCache)
}
@ -668,7 +670,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
if (playlistCache == null) {
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
playlistCache = content?.getAllChild()
playlistCache = content?.getTracks()
}
val song = playlistCache?.firstOrNull { x -> x.id == songId }
if (song != null) playSong(song)
@ -678,14 +680,14 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
private fun playAlbum(id: String, name: String) {
serviceScope.launch {
val songs = listSongsInMusicService(id, name)
if (songs != null) playSongs(songs.getAllChild())
if (songs != null) playSongs(songs.getTracks())
}
}
private fun playAlbumSong(id: String, name: String, songId: String) {
serviceScope.launch {
val songs = listSongsInMusicService(id, name)
val song = songs?.getAllChild()?.firstOrNull { x -> x.id == songId }
val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId }
if (song != null) playSong(song)
}
}
@ -717,10 +719,10 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) }
if (episodes != null) {
if (episodes.getAllChild().count() > 1)
if (episodes.getTracks().count() > 1)
mediaItems.addPlayAllItem(listOf(MEDIA_PODCAST_ITEM, id).joinToString("|"))
episodes.getAllChild().map { episode ->
episodes.getTracks().map { episode ->
mediaItems.add(
MediaBrowserCompat.MediaItem(
Util.getMediaDescriptionForEntry(
@ -741,7 +743,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
serviceScope.launch {
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) }
if (episodes != null) {
playSongs(episodes.getAllChild())
playSongs(episodes.getTracks())
}
}
}
@ -751,7 +753,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) }
if (episodes != null) {
val selectedEpisode = episodes
.getAllChild()
.getTracks()
.firstOrNull { episode -> episode.id == episodeId }
if (selectedEpisode != null) playSong(selectedEpisode)
}
@ -766,7 +768,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
if (bookmarks != null) {
val songs = Util.getSongsFromBookmarks(bookmarks)
songs.getAllChild().map { song ->
songs.getTracks().map { song ->
mediaItems.add(
MediaBrowserCompat.MediaItem(
Util.getMediaDescriptionForEntry(
@ -787,7 +789,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val bookmarks = callWithErrorHandling { musicService.getBookmarks() }
if (bookmarks != null) {
val songs = Util.getSongsFromBookmarks(bookmarks)
val song = songs.getAllChild().firstOrNull { song -> song.id == id }
val song = songs.getTracks().firstOrNull { song -> song.id == id }
if (song != null) playSong(song)
}
}
@ -926,11 +928,11 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
val songs = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
if (songs != null) {
if (songs.getAllChild().count() > 1)
if (songs.size > 1)
mediaItems.addPlayAllItem(listOf(MEDIA_SONG_RANDOM_ID).joinToString("|"))
// TODO: Paging is not implemented for songs, is it necessary at all?
val items = songs.getAllChild()
val items = songs.getTracks()
randomSongsCache = items
items.map { song ->
mediaItems.add(
@ -954,7 +956,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
// In this case we request a new set of random songs
val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
randomSongsCache = content?.getAllChild()
randomSongsCache = content?.getTracks()
}
if (randomSongsCache != null) playSongs(randomSongsCache)
}

View File

@ -41,7 +41,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
// Old style TimeLimitedCache
private val cachedMusicDirectories: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedArtist: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedArtist: LRUCache<String, TimeLimitedCache<List<MusicDirectory.Album>>>
private val cachedAlbum: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedUserInfo: LRUCache<String, TimeLimitedCache<UserInfo?>>
private val cachedLicenseValid = TimeLimitedCache<Boolean>(120, TimeUnit.SECONDS)
@ -148,20 +148,21 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
}
@Throws(Exception::class)
override fun getArtist(id: String, name: String?, refresh: Boolean): MusicDirectory {
checkSettingsChanged()
var cache = if (refresh) null else cachedArtist[id]
var dir = cache?.get()
if (dir == null) {
dir = musicService.getArtist(id, name, refresh)
cache = TimeLimitedCache(
Settings.directoryCacheTime.toLong(), TimeUnit.SECONDS
)
cache.set(dir)
cachedArtist.put(id, cache)
override fun getArtist(id: String, name: String?, refresh: Boolean):
List<MusicDirectory.Album> {
checkSettingsChanged()
var cache = if (refresh) null else cachedArtist[id]
var dir = cache?.get()
if (dir == null) {
dir = musicService.getArtist(id, name, refresh)
cache = TimeLimitedCache(
Settings.directoryCacheTime.toLong(), TimeUnit.SECONDS
)
cache.set(dir)
cachedArtist.put(id, cache)
}
return dir
}
return dir
}
@Throws(Exception::class)
override fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory {
@ -248,7 +249,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
size: Int,
offset: Int,
musicFolderId: String?
): MusicDirectory {
): List<MusicDirectory.Album> {
return musicService.getAlbumList(type, size, offset, musicFolderId)
}
@ -258,7 +259,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
size: Int,
offset: Int,
musicFolderId: String?
): MusicDirectory {
): List<MusicDirectory.Album> {
return musicService.getAlbumList2(type, size, offset, musicFolderId)
}
@ -399,7 +400,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
}
@Throws(Exception::class)
override fun getBookmarks(): List<Bookmark?>? = musicService.getBookmarks()
override fun getBookmarks(): List<Bookmark> = musicService.getBookmarks()
@Throws(Exception::class)
override fun deleteBookmark(id: String) {
@ -415,7 +416,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
override fun getVideos(refresh: Boolean): MusicDirectory? {
checkSettingsChanged()
var cache =
if (refresh) null else cachedMusicDirectories[Constants.INTENT_EXTRA_NAME_VIDEOS]
if (refresh) null else cachedMusicDirectories[Constants.INTENT_VIDEOS]
var dir = cache?.get()
if (dir == null) {
dir = musicService.getVideos(refresh)
@ -423,7 +424,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
Settings.directoryCacheTime.toLong(), TimeUnit.SECONDS
)
cache.set(dir)
cachedMusicDirectories.put(Constants.INTENT_EXTRA_NAME_VIDEOS, cache)
cachedMusicDirectories.put(Constants.INTENT_VIDEOS, cache)
}
return dir
}

View File

@ -31,14 +31,20 @@ import timber.log.Timber
/**
* This class represents a single Song or Video that can be downloaded.
*
* Terminology:
* PinnedFile: A "pinned" song. Will stay in cache permanently
* CompleteFile: A "downloaded" song. Will be quicker to be deleted if the cache is full
*
*/
class DownloadFile(
val song: MusicDirectory.Entry,
private val save: Boolean
save: Boolean
) : KoinComponent, Identifiable {
val partialFile: String
val completeFile: String
private val saveFile: String = FileUtil.getSongFile(song)
var shouldSave = save
private var downloadTask: CancellableTask? = null
var isFailed = false
private var retryCount = MAX_RETRIES
@ -62,11 +68,27 @@ class DownloadFile(
private val activeServerProvider: ActiveServerProvider by inject()
val progress: MutableLiveData<Int> = MutableLiveData(0)
val status: MutableLiveData<DownloadStatus> = MutableLiveData(DownloadStatus.IDLE)
val status: MutableLiveData<DownloadStatus>
init {
val state: DownloadStatus
partialFile = FileUtil.getParentPath(saveFile) + "/" + FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile))
completeFile = FileUtil.getParentPath(saveFile) + "/" + FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile))
when {
StorageFile.isPathExists(saveFile) -> {
state = DownloadStatus.PINNED
}
StorageFile.isPathExists(completeFile) -> {
state = DownloadStatus.DONE
}
else -> {
state = DownloadStatus.IDLE
}
}
status = MutableLiveData(state)
}
/**
@ -119,7 +141,7 @@ class DownloadFile(
@get:Synchronized
val isWorkDone: Boolean
get() = StorageFile.isPathExists(completeFile) && !save ||
get() = StorageFile.isPathExists(completeFile) && !shouldSave ||
StorageFile.isPathExists(saveFile) || saveWhenDone || completeWhenDone
@get:Synchronized
@ -130,10 +152,6 @@ class DownloadFile(
val isDownloadCancelled: Boolean
get() = downloadTask != null && downloadTask!!.isCancelled
fun shouldSave(): Boolean {
return save
}
fun shouldRetry(): Boolean {
return (retryCount > 0)
}
@ -144,12 +162,15 @@ class DownloadFile(
FileUtil.delete(completeFile)
FileUtil.delete(saveFile)
status.postValue(DownloadStatus.IDLE)
Util.scanMedia(saveFile)
}
fun unpin() {
val file = StorageFile.getFromPath(saveFile) ?: return
StorageFile.rename(file, completeFile)
status.postValue(DownloadStatus.DONE)
}
fun cleanup(): Boolean {
@ -177,7 +198,7 @@ class DownloadFile(
FileUtil.renameFile(completeFile, saveFile)
saveWhenDone = false
} else if (completeWhenDone) {
if (save) {
if (shouldSave) {
FileUtil.renameFile(partialFile, saveFile)
Util.scanMedia(saveFile)
} else {
@ -205,21 +226,23 @@ class DownloadFile(
try {
if (StorageFile.isPathExists(saveFile)) {
Timber.i("%s already exists. Skipping.", saveFile)
status.postValue(DownloadStatus.DONE)
status.postValue(DownloadStatus.PINNED)
return
}
if (StorageFile.isPathExists(completeFile)) {
if (save) {
var newStatus: DownloadStatus = DownloadStatus.DONE
if (shouldSave) {
if (isPlaying) {
saveWhenDone = true
} else {
FileUtil.renameFile(completeFile, saveFile)
newStatus = DownloadStatus.PINNED
}
} else {
Timber.i("%s already exists. Skipping.", completeFile)
}
status.postValue(DownloadStatus.DONE)
status.postValue(newStatus)
return
}
@ -238,7 +261,7 @@ class DownloadFile(
if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, isPartial) = musicService.getDownloadInputStream(
song, fileLength, desiredBitRate, save
song, fileLength, desiredBitRate, shouldSave
)
inputStream = inStream
@ -269,18 +292,18 @@ class DownloadFile(
}
downloadAndSaveCoverArt()
status.postValue(DownloadStatus.DONE)
}
if (isPlaying) {
completeWhenDone = true
} else {
if (save) {
if (shouldSave) {
FileUtil.renameFile(partialFile, saveFile)
status.postValue(DownloadStatus.PINNED)
Util.scanMedia(saveFile)
} else {
FileUtil.renameFile(partialFile, completeFile)
status.postValue(DownloadStatus.DONE)
}
}
} catch (all: Exception) {
@ -380,5 +403,5 @@ class DownloadFile(
}
enum class DownloadStatus {
IDLE, DOWNLOADING, RETRYING, FAILED, ABORTED, DONE
IDLE, DOWNLOADING, RETRYING, FAILED, ABORTED, DONE, PINNED, UNKNOWN
}

View File

@ -55,6 +55,8 @@ class Downloader(
RxBus.playlistPublisher.onNext(playlist)
}
var backgroundPriorityCounter = 100
val downloadChecker = Runnable {
try {
Timber.w("Checking Downloads")
@ -118,7 +120,7 @@ class Downloader(
}
@Synchronized
@Suppress("ComplexMethod")
@Suppress("ComplexMethod", "ComplexCondition")
fun checkDownloadsInternal() {
if (
!Util.isExternalStoragePresent() ||
@ -156,7 +158,8 @@ class Downloader(
// Add file to queue if not in one of the queues already.
if (!download.isWorkDone &&
!activelyDownloading.contains(download) &&
!downloadQueue.contains(download)
!downloadQueue.contains(download) &&
download.shouldRetry()
) {
listChanged = true
downloadQueue.add(download)
@ -282,14 +285,18 @@ class Downloader(
fun clearPlaylist() {
playlist.clear()
val toRemove = mutableListOf<DownloadFile>()
// Cancel all active downloads with a high priority
for (download in activelyDownloading) {
if (download.priority < 100) {
download.cancelDownload()
activelyDownloading.remove(download)
toRemove.add(download)
}
}
activelyDownloading.removeAll(toRemove)
playlistUpdateRevision++
updateLiveData()
}
@ -306,6 +313,8 @@ class Downloader(
activelyDownloading.remove(download)
}
}
backgroundPriorityCounter = 100
}
@Synchronized
@ -330,7 +339,7 @@ class Downloader(
@Synchronized
fun addToPlaylist(
songs: List<MusicDirectory.Entry?>,
songs: List<MusicDirectory.Entry>,
save: Boolean,
autoPlay: Boolean,
playNext: Boolean,
@ -349,13 +358,13 @@ class Downloader(
offset = 0
}
for (song in songs) {
val downloadFile = DownloadFile(song!!, save)
val downloadFile = song.getDownloadFile(save)
playlist.add(currentPlayingIndex + offset, downloadFile)
offset++
}
} else {
for (song in songs) {
val downloadFile = DownloadFile(song!!, save)
val downloadFile = song.getDownloadFile(save)
playlist.add(downloadFile)
}
}
@ -363,6 +372,20 @@ class Downloader(
checkDownloads()
}
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
val item = playlist[oldPos]
playlist.remove(item)
if (newPos < oldPos) {
playlist.add(newPos + 1, item)
} else {
playlist.add(newPos - 1, item)
}
playlistUpdateRevision++
checkDownloads()
}
@Synchronized
fun clearIncomplete() {
val iterator = playlist.iterator()
@ -380,10 +403,12 @@ class Downloader(
@Synchronized
fun downloadBackground(songs: List<MusicDirectory.Entry>, save: Boolean) {
// Because of the priority handling we add the songs in the reverse order they
// were requested, then it is correct in the end.
for (song in songs.asReversed()) {
downloadQueue.add(DownloadFile(song, save))
// By using the counter we ensure that the songs are added in the correct order
for (song in songs) {
val file = song.getDownloadFile()
file.shouldSave = save
file.priority = backgroundPriorityCounter++
downloadQueue.add(file)
}
checkDownloads()
@ -439,7 +464,7 @@ class Downloader(
val size = playlist.size
if (size < listSize) {
for (song in shufflePlayBuffer[listSize - size]) {
val downloadFile = DownloadFile(song, false)
val downloadFile = song.getDownloadFile(false)
playlist.add(downloadFile)
playlistUpdateRevision++
}
@ -451,7 +476,7 @@ class Downloader(
if (currIndex > SHUFFLE_BUFFER_LIMIT) {
val songsToShift = currIndex - 2
for (song in shufflePlayBuffer[songsToShift]) {
playlist.add(DownloadFile(song, false))
playlist.add(song.getDownloadFile(false))
playlist[0].cancelDownload()
playlist.removeAt(0)
playlistUpdateRevision++
@ -477,4 +502,14 @@ class Downloader(
const val CHECK_INTERVAL = 5L
const val SHUFFLE_BUFFER_LIMIT = 4
}
/**
* Extension function
* Gathers the download file for a given song, and modifies shouldSave if provided.
*/
fun MusicDirectory.Entry.getDownloadFile(save: Boolean? = null): DownloadFile {
return getDownloadFileForSong(this).apply {
if (save != null) this.shouldSave = save
}
}
}

View File

@ -250,6 +250,11 @@ class MediaPlayerController(
mediaPlayerService?.setNextPlaying()
}
@Synchronized
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
downloader.moveItemInPlaylist(oldPos, newPos)
}
@set:Synchronized
var repeatMode: RepeatMode
get() = Settings.repeatMode
@ -294,6 +299,7 @@ class MediaPlayerController(
}
@Synchronized
// TODO: If a playlist contains an item twice, this call will wrongly remove all
fun removeFromPlaylist(downloadFile: DownloadFile) {
if (downloadFile == localMediaPlayer.currentPlaying) {
reset()

View File

@ -704,7 +704,7 @@ class MediaPlayerService : Service() {
val intent = Intent(this, NavigationActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val flags = PendingIntent.FLAG_UPDATE_CURRENT
intent.putExtra(Constants.INTENT_EXTRA_NAME_SHOW_PLAYER, true)
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
return PendingIntent.getActivity(this, 0, intent, flags)
}

View File

@ -24,6 +24,7 @@ import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.domain.UserInfo
@Suppress("TooManyFunctions")
interface MusicService {
@Throws(Exception::class)
fun ping()
@ -56,7 +57,7 @@ interface MusicService {
fun getMusicDirectory(id: String, name: String?, refresh: Boolean): MusicDirectory
@Throws(Exception::class)
fun getArtist(id: String, name: String?, refresh: Boolean): MusicDirectory
fun getArtist(id: String, name: String?, refresh: Boolean): List<MusicDirectory.Album>
@Throws(Exception::class)
fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory
@ -89,7 +90,12 @@ interface MusicService {
fun scrobble(id: String, submission: Boolean)
@Throws(Exception::class)
fun getAlbumList(type: String, size: Int, offset: Int, musicFolderId: String?): MusicDirectory
fun getAlbumList(
type: String,
size: Int,
offset: Int,
musicFolderId: String?
): List<MusicDirectory.Album>
@Throws(Exception::class)
fun getAlbumList2(
@ -97,7 +103,7 @@ interface MusicService {
size: Int,
offset: Int,
musicFolderId: String?
): MusicDirectory
): List<MusicDirectory.Album>
@Throws(Exception::class)
fun getRandomSongs(size: Int): MusicDirectory
@ -154,7 +160,7 @@ interface MusicService {
fun addChatMessage(message: String)
@Throws(Exception::class)
fun getBookmarks(): List<Bookmark?>?
fun getBookmarks(): List<Bookmark>
@Throws(Exception::class)
fun deleteBookmark(id: String)

View File

@ -12,7 +12,6 @@ import java.io.BufferedWriter
import org.moire.ultrasonic.util.StorageFile
import java.io.InputStream
import java.io.Reader
import java.lang.Math.min
import java.util.ArrayList
import java.util.HashSet
import java.util.LinkedList
@ -23,6 +22,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre
@ -44,8 +44,6 @@ import timber.log.Timber
import java.io.FileReader
import java.io.FileWriter
// TODO: There are quite a number of deeply nested and complicated functions in this class..
// Simplify them :)
@Suppress("TooManyFunctions")
class OfflineMusicService : MusicService, KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
@ -95,6 +93,9 @@ class OfflineMusicService : MusicService, KoinComponent {
return indexes
}
/*
* Especially when dealing with indexes, this method can return Albums, Entries or a mix of both!
*/
override fun getMusicDirectory(
id: String,
name: String?,
@ -110,7 +111,11 @@ class OfflineMusicService : MusicService, KoinComponent {
val filename = getName(file.name, file.isDirectory)
if (filename != null && !seen.contains(filename)) {
seen.add(filename)
result.addChild(createEntry(file, filename))
if (file.isFile) {
result.add(createEntry(file, filename))
} else {
result.add(createAlbum(file, filename))
}
}
}
@ -118,8 +123,8 @@ class OfflineMusicService : MusicService, KoinComponent {
}
override fun search(criteria: SearchCriteria): SearchResult {
val artists: MutableList<Artist> = ArrayList()
val albums: MutableList<MusicDirectory.Entry> = ArrayList()
val artists: MutableList<ArtistOrIndex> = ArrayList()
val albums: MutableList<MusicDirectory.Album> = ArrayList()
val songs: MutableList<MusicDirectory.Entry> = ArrayList()
val root = FileUtil.musicDirectory
var closeness: Int
@ -127,7 +132,7 @@ class OfflineMusicService : MusicService, KoinComponent {
val artistName = artistFile.name
if (artistFile.isDirectory) {
if (matchCriteria(criteria, artistName).also { closeness = it } > 0) {
val artist = Artist(artistFile.path)
val artist = Index(artistFile.path)
artist.index = artistFile.name.substring(0, 1)
artist.name = artistName
artist.closeness = closeness
@ -208,7 +213,7 @@ class OfflineMusicService : MusicService, KoinComponent {
val entryFile = StorageFile.getFromPath(line) ?: continue
val entryName = getName(entryFile.name, entryFile.isDirectory)
if (entryName != null) {
playlist.addChild(createEntry(entryFile, entryName))
playlist.add(createEntry(entryFile, entryName))
}
}
playlist
@ -258,10 +263,10 @@ class OfflineMusicService : MusicService, KoinComponent {
return result
}
children.shuffle()
val finalSize: Int = min(children.size, size)
val finalSize: Int = children.size.coerceAtMost(size)
for (i in 0 until finalSize) {
val file = children[i % children.size]
result.addChild(createEntry(file, getName(file.name, file.isDirectory)))
result.add(createEntry(file, getName(file.name, file.isDirectory)))
}
return result
}
@ -292,10 +297,20 @@ class OfflineMusicService : MusicService, KoinComponent {
size: Int,
offset: Int,
musicFolderId: String?
): MusicDirectory {
): List<MusicDirectory.Album> {
throw OfflineException("Album lists not available in offline mode")
}
@Throws(OfflineException::class)
override fun getAlbumList2(
type: String,
size: Int,
offset: Int,
musicFolderId: String?
): List<MusicDirectory.Album> {
throw OfflineException("getAlbumList2 isn't available in offline mode")
}
@Throws(Exception::class)
override fun updateJukeboxPlaylist(ids: List<String>?): JukeboxStatus {
throw OfflineException("Jukebox not available in offline mode")
@ -385,16 +400,6 @@ class OfflineMusicService : MusicService, KoinComponent {
throw OfflineException("Music folders not available in offline mode")
}
@Throws(OfflineException::class)
override fun getAlbumList2(
type: String,
size: Int,
offset: Int,
musicFolderId: String?
): MusicDirectory {
throw OfflineException("getAlbumList2 isn't available in offline mode")
}
@Throws(OfflineException::class)
override fun getVideoUrl(id: String): String? {
throw OfflineException("getVideoUrl isn't available in offline mode")
@ -411,7 +416,7 @@ class OfflineMusicService : MusicService, KoinComponent {
}
@Throws(OfflineException::class)
override fun getBookmarks(): List<Bookmark?>? {
override fun getBookmarks(): List<Bookmark> {
throw OfflineException("getBookmarks isn't available in offline mode")
}
@ -447,9 +452,10 @@ class OfflineMusicService : MusicService, KoinComponent {
}
@Throws(OfflineException::class)
override fun getArtist(id: String, name: String?, refresh: Boolean): MusicDirectory {
throw OfflineException("getArtist isn't available in offline mode")
}
override fun getArtist(id: String, name: String?, refresh: Boolean):
List<MusicDirectory.Album> {
throw OfflineException("getArtist isn't available in offline mode")
}
@Throws(OfflineException::class)
override fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory {
@ -481,194 +487,208 @@ class OfflineMusicService : MusicService, KoinComponent {
throw OfflineException("getPodcastsChannels isn't available in offline mode")
}
companion object {
private val COMPILE = Pattern.compile(" ")
private fun getName(fileName: String, isDirectory: Boolean): String? {
if (isDirectory) {
return fileName
}
if (fileName.endsWith(".partial") || fileName.contains(".partial.") ||
fileName == Constants.ALBUM_ART_FILE
) {
return null
}
val name = fileName.replace(".complete", "")
return FileUtil.getBaseName(name)
private fun getName(fileName: String, isDirectory: Boolean): String? {
if (isDirectory) {
return fileName
}
@Suppress("TooGenericExceptionCaught", "ComplexMethod", "LongMethod", "NestedBlockDepth")
private fun createEntry(file: StorageFile, name: String?): MusicDirectory.Entry {
val entry = MusicDirectory.Entry(file.path)
entry.isDirectory = file.isDirectory
entry.parent = file.parent!!.path
entry.size = if (file.isFile) file.length else 0
val root = FileUtil.musicDirectory.path
entry.path = file.path.replaceFirst(
String.format(Locale.ROOT, "^%s/", root).toRegex(), ""
)
entry.title = name
if (file.isFile) {
var artist: String? = null
var album: String? = null
var title: String? = null
var track: String? = null
var disc: String? = null
var year: String? = null
var genre: String? = null
var duration: String? = null
var hasVideo: String? = null
try {
val mmr = MediaMetadataRetriever()
if (file.isRawFile) mmr.setDataSource(file.rawFilePath)
else {
val descriptor = file.getDocumentFileDescriptor("r")!!
mmr.setDataSource(descriptor.fileDescriptor)
descriptor.close()
}
artist = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
album = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM)
title = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
track = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER)
disc = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER)
year = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_YEAR)
genre = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_GENRE)
duration = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
hasVideo = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO)
mmr.release()
} catch (ignored: Exception) {
}
entry.artist = artist ?: file.parent!!.parent!!.name
entry.album = album ?: file.parent!!.name
if (title != null) {
entry.title = title
}
entry.isVideo = hasVideo != null
Timber.i("Offline Stuff: %s", track)
if (track != null) {
var trackValue = 0
try {
val slashIndex = track.indexOf('/')
if (slashIndex > 0) {
track = track.substring(0, slashIndex)
}
trackValue = track.toInt()
} catch (ex: Exception) {
Timber.e(ex, "Offline Stuff")
}
Timber.i("Offline Stuff: Setting Track: %d", trackValue)
entry.track = trackValue
}
if (disc != null) {
var discValue = 0
try {
val slashIndex = disc.indexOf('/')
if (slashIndex > 0) {
disc = disc.substring(0, slashIndex)
}
discValue = disc.toInt()
} catch (ignored: Exception) {
}
entry.discNumber = discValue
}
if (year != null) {
var yearValue = 0
try {
yearValue = year.toInt()
} catch (ignored: Exception) {
}
entry.year = yearValue
}
if (genre != null) {
entry.genre = genre
}
if (duration != null) {
var durationValue: Long = 0
try {
durationValue = duration.toLong()
durationValue = TimeUnit.MILLISECONDS.toSeconds(durationValue)
} catch (ignored: Exception) {
}
entry.setDuration(durationValue)
}
}
entry.suffix = FileUtil.getExtension(file.name.replace(".complete", ""))
val albumArt = FileUtil.getAlbumArtFile(entry)
if (albumArt != null && StorageFile.isPathExists(albumArt)) {
entry.coverArt = albumArt
}
return entry
}
@Suppress("NestedBlockDepth")
private fun recursiveAlbumSearch(
artistName: String,
file: StorageFile,
criteria: SearchCriteria,
albums: MutableList<MusicDirectory.Entry>,
songs: MutableList<MusicDirectory.Entry>
if (fileName.endsWith(".partial") || fileName.contains(".partial.") ||
fileName == Constants.ALBUM_ART_FILE
) {
var closeness: Int
for (albumFile in FileUtil.listMediaFiles(file)) {
if (albumFile.isDirectory) {
val albumName = getName(albumFile.name, albumFile.isDirectory)
if (matchCriteria(criteria, albumName).also { closeness = it } > 0) {
val album = createEntry(albumFile, albumName)
album.artist = artistName
album.closeness = closeness
albums.add(album)
}
for (songFile in FileUtil.listMediaFiles(albumFile)) {
val songName = getName(songFile.name, songFile.isDirectory)
if (songFile.isDirectory) {
recursiveAlbumSearch(artistName, songFile, criteria, albums, songs)
} else if (matchCriteria(criteria, songName).also { closeness = it } > 0) {
val song = createEntry(albumFile, songName)
song.artist = artistName
song.album = albumName
song.closeness = closeness
songs.add(song)
}
}
} else {
val songName = getName(albumFile.name, albumFile.isDirectory)
if (matchCriteria(criteria, songName).also { closeness = it } > 0) {
return null
}
val name = fileName.replace(".complete", "")
return FileUtil.getBaseName(name)
}
private fun createEntry(file: StorageFile, name: String?): MusicDirectory.Entry {
val entry = MusicDirectory.Entry(file.path)
entry.populateWithDataFrom(file, name)
return entry
}
private fun createAlbum(file: StorageFile, name: String?): MusicDirectory.Album {
val album = MusicDirectory.Album(file.path)
album.populateWithDataFrom(file, name)
return album
}
/*
* Extracts some basic data from a File object and applies it to an Album or Entry
*/
private fun MusicDirectory.Child.populateWithDataFrom(file: StorageFile, name: String?) {
isDirectory = file.isDirectory
parent = file.parent!!.path
val root = FileUtil.musicDirectory.path
path = file.path.replaceFirst(
String.format(Locale.ROOT, "^%s/", root).toRegex(), ""
)
title = name
val albumArt = FileUtil.getAlbumArtFile(this)
if (albumArt != null && StorageFile.isPathExists(albumArt)) {
coverArt = albumArt
}
}
/*
* More extensive variant of Child.populateWithDataFrom(), which also parses the ID3 tags of
* a given track file.
*/
private fun MusicDirectory.Entry.populateWithDataFrom(file: StorageFile, name: String?) {
(this as MusicDirectory.Child).populateWithDataFrom(file, name)
val meta = RawMetadata(null)
try {
val mmr = MediaMetadataRetriever()
if (file.isRawFile) mmr.setDataSource(file.rawFilePath)
else {
val descriptor = file.getDocumentFileDescriptor("r")!!
mmr.setDataSource(descriptor.fileDescriptor)
descriptor.close()
}
meta.artist = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
meta.album = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM)
meta.title = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
meta.track = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER)
meta.disc = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER)
meta.year = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_YEAR)
meta.genre = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_GENRE)
meta.duration = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
meta.hasVideo = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO)
mmr.release()
} catch (ignored: Exception) {
}
artist = meta.artist ?: file.parent!!.parent!!.name
album = meta.album ?: file.parent!!.name
title = meta.title ?: title
isVideo = meta.hasVideo != null
track = parseSlashedNumber(meta.track)
discNumber = parseSlashedNumber(meta.disc)
year = meta.year?.toIntOrNull()
genre = meta.genre
duration = parseDuration(meta.duration)
size = if (file.isFile) file.length else 0
suffix = FileUtil.getExtension(file.name.replace(".complete", ""))
}
/*
* Parses a number from a string in the format of 05/21,
* where the first number is the track number
* and the second the number of total tracks
*/
private fun parseSlashedNumber(string: String?): Int? {
if (string == null) return null
val slashIndex = string.indexOf('/')
if (slashIndex > 0)
return string.substring(0, slashIndex).toIntOrNull()
else
return string.toIntOrNull()
}
/*
* Parses a duration from a String
*/
private fun parseDuration(string: String?): Int? {
if (string == null) return null
val duration: Long? = string.toLongOrNull()
if (duration != null)
return TimeUnit.MILLISECONDS.toSeconds(duration).toInt()
else
return null
}
// TODO: Simplify this deeply nested and complicated function
@Suppress("NestedBlockDepth")
private fun recursiveAlbumSearch(
artistName: String,
file: StorageFile,
criteria: SearchCriteria,
albums: MutableList<MusicDirectory.Album>,
songs: MutableList<MusicDirectory.Entry>
) {
var closeness: Int
for (albumFile in FileUtil.listMediaFiles(file)) {
if (albumFile.isDirectory) {
val albumName = getName(albumFile.name, albumFile.isDirectory)
if (matchCriteria(criteria, albumName).also { closeness = it } > 0) {
val album = createAlbum(albumFile, albumName)
album.artist = artistName
album.closeness = closeness
albums.add(album)
}
for (songFile in FileUtil.listMediaFiles(albumFile)) {
val songName = getName(songFile.name, songFile.isDirectory)
if (songFile.isDirectory) {
recursiveAlbumSearch(artistName, songFile, criteria, albums, songs)
} else if (matchCriteria(criteria, songName).also { closeness = it } > 0) {
val song = createEntry(albumFile, songName)
song.artist = artistName
song.album = songName
song.album = albumName
song.closeness = closeness
songs.add(song)
}
}
}
}
private fun matchCriteria(criteria: SearchCriteria, name: String?): Int {
val query = criteria.query.lowercase(Locale.ROOT)
val queryParts = COMPILE.split(query)
val nameParts = COMPILE.split(
name!!.lowercase(Locale.ROOT)
)
var closeness = 0
for (queryPart in queryParts) {
for (namePart in nameParts) {
if (namePart == queryPart) {
closeness++
}
}
}
return closeness
}
private fun listFilesRecursively(parent: StorageFile, children: MutableList<StorageFile>) {
for (file in FileUtil.listMediaFiles(parent)) {
if (file.isFile) {
children.add(file)
} else {
listFilesRecursively(file, children)
} else {
val songName = getName(albumFile.name, albumFile.isDirectory)
if (matchCriteria(criteria, songName).also { closeness = it } > 0) {
val song = createEntry(albumFile, songName)
song.artist = artistName
song.album = songName
song.closeness = closeness
songs.add(song)
}
}
}
}
private fun matchCriteria(criteria: SearchCriteria, name: String?): Int {
val query = criteria.query.lowercase(Locale.ROOT)
val queryParts = COMPILE.split(query)
val nameParts = COMPILE.split(
name!!.lowercase(Locale.ROOT)
)
var closeness = 0
for (queryPart in queryParts) {
for (namePart in nameParts) {
if (namePart == queryPart) {
closeness++
}
}
}
return closeness
}
private fun listFilesRecursively(parent: StorageFile, children: MutableList<StorageFile>) {
for (file in FileUtil.listMediaFiles(parent)) {
if (file.isFile) {
children.add(file)
} else {
listFilesRecursively(file, children)
}
}
}
data class RawMetadata(val id: String?) {
var artist: String? = null
var album: String? = null
var title: String? = null
var track: String? = null
var disc: String? = null
var year: String? = null
var genre: String? = null
var duration: String? = null
var hasVideo: String? = null
}
companion object {
private val COMPILE = Pattern.compile(" ")
}
}

View File

@ -143,10 +143,10 @@ open class RESTMusicService(
id: String,
name: String?,
refresh: Boolean
): MusicDirectory {
): List<MusicDirectory.Album> {
val response = API.getArtist(id).execute().throwOnFailure()
return response.body()!!.artist.toMusicDirectoryDomainEntity()
return response.body()!!.artist.toDomainEntityList()
}
@Throws(Exception::class)
@ -319,7 +319,7 @@ open class RESTMusicService(
) {
val entry = podcastEntry.toDomainEntity()
entry.track = null
musicDirectory.addChild(entry)
musicDirectory.add(entry)
}
}
@ -350,7 +350,7 @@ open class RESTMusicService(
size: Int,
offset: Int,
musicFolderId: String?
): MusicDirectory {
): List<MusicDirectory.Album> {
val response = API.getAlbumList(
fromName(type),
size,
@ -361,11 +361,7 @@ open class RESTMusicService(
musicFolderId
).execute().throwOnFailure()
val childList = response.body()!!.albumList.toDomainEntityList()
val result = MusicDirectory()
result.addAll(childList)
return result
return response.body()!!.albumList.toDomainEntityList()
}
@Throws(Exception::class)
@ -374,7 +370,7 @@ open class RESTMusicService(
size: Int,
offset: Int,
musicFolderId: String?
): MusicDirectory {
): List<MusicDirectory.Album> {
val response = API.getAlbumList2(
fromName(type),
size,
@ -385,10 +381,7 @@ open class RESTMusicService(
musicFolderId
).execute().throwOnFailure()
val result = MusicDirectory()
result.addAll(response.body()!!.albumList.toDomainEntityList())
return result
return response.body()!!.albumList.toDomainEntityList()
}
@Throws(Exception::class)

View File

@ -30,6 +30,11 @@ class RxBus {
val themeChangedEventObservable: Observable<Unit> =
themeChangedEventPublisher.observeOn(AndroidSchedulers.mainThread())
val musicFolderChangedEventPublisher: PublishSubject<String> =
PublishSubject.create()
val musicFolderChangedEventObservable: Observable<String> =
musicFolderChangedEventPublisher.observeOn(AndroidSchedulers.mainThread())
val playerStatePublisher: PublishSubject<StateWithTrack> =
PublishSubject.create()
val playerStateObservable: Observable<StateWithTrack> =

View File

@ -49,7 +49,7 @@ class DownloadHandler(
false
)
val playlistName: String? = fragment.arguments?.getString(
Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME
Constants.INTENT_PLAYLIST_NAME
)
if (playlistName != null) {
mediaPlayerController.suggestedPlaylistName = playlistName
@ -219,7 +219,7 @@ class DownloadHandler(
for (share in shares) {
if (share.id == id) {
for (entry in share.getEntries()) {
root.addChild(entry)
root.add(entry)
}
break
}
@ -240,18 +240,13 @@ class DownloadHandler(
if (songs.size > maxSongs) {
return
}
for (song in parent.getChildren(includeDirs = false, includeFiles = true)) {
for (song in parent.getTracks()) {
if (!song.isVideo) {
songs.add(song)
}
}
val musicService = getMusicService()
for (
(id1, _, _, title) in parent.getChildren(
includeDirs = true,
includeFiles = false
)
) {
for ((id1, _, _, title) in parent.getAlbums()) {
val root: MusicDirectory = if (
!isOffline() &&
Settings.shouldUseId3Tags
@ -271,13 +266,13 @@ class DownloadHandler(
}
val musicService = getMusicService()
val artist = musicService.getArtist(id, "", false)
for ((id1) in artist.getChildren()) {
for ((id1) in artist) {
val albumDirectory = musicService.getAlbum(
id1,
"",
false
)
for (song in albumDirectory.getChildren()) {
for (song in albumDirectory.getTracks()) {
if (!song.isVideo) {
songs.add(song)
}

View File

@ -80,7 +80,7 @@ class ShareHandler(val context: Context) {
if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1) return null
if (shareDetails.Entries.isEmpty()) {
fragment.arguments?.getString(Constants.INTENT_EXTRA_NAME_ID).ifNotNull {
fragment.arguments?.getString(Constants.INTENT_ID).ifNotNull {
ids.add(it)
}
} else {

View File

@ -0,0 +1,57 @@
package org.moire.ultrasonic.util
import java.util.Comparator
import java.util.SortedSet
import java.util.TreeSet
/**
* A TreeSet that ensures it never grows beyond a max size.
* `last()` is removed if the `size()`
* get's bigger then `getMaxSize()`
*/
class BoundedTreeSet<E> : TreeSet<E> {
private var maxSize = Int.MAX_VALUE
constructor(maxSize: Int) : super() {
setMaxSize(maxSize)
}
constructor(maxSize: Int, c: Collection<E>?) : super(c) {
setMaxSize(maxSize)
}
constructor(maxSize: Int, c: Comparator<in E>?) : super(c) {
setMaxSize(maxSize)
}
constructor(maxSize: Int, s: SortedSet<E>?) : super(s) {
setMaxSize(maxSize)
}
fun getMaxSize(): Int {
return maxSize
}
fun setMaxSize(max: Int) {
maxSize = max
adjust()
}
private fun adjust() {
while (maxSize < size) {
remove(last())
}
}
override fun add(element: E): Boolean {
val out = super.add(element)
adjust()
return out
}
override fun addAll(elements: Collection<E>): Boolean {
val out = super.addAll(elements)
adjust()
return out
}
}

View File

@ -16,31 +16,31 @@ object Constants {
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"
const val INTENT_ID = "subsonic.id"
const val INTENT_NAME = "subsonic.name"
const val INTENT_ARTIST = "subsonic.artist"
const val INTENT_TITLE = "subsonic.title"
const val INTENT_AUTOPLAY = "subsonic.playall"
const val INTENT_QUERY = "subsonic.query"
const val INTENT_PLAYLIST_ID = "subsonic.playlist.id"
const val INTENT_PODCAST_CHANNEL_ID = "subsonic.podcastChannel.id"
const val INTENT_PARENT_ID = "subsonic.parent.id"
const val INTENT_PLAYLIST_NAME = "subsonic.playlist.name"
const val INTENT_SHARE_ID = "subsonic.share.id"
const val INTENT_SHARE_NAME = "subsonic.share.name"
const val INTENT_ALBUM_LIST_TYPE = "subsonic.albumlisttype"
const val INTENT_ALBUM_LIST_TITLE = "subsonic.albumlisttitle"
const val INTENT_ALBUM_LIST_SIZE = "subsonic.albumlistsize"
const val INTENT_ALBUM_LIST_OFFSET = "subsonic.albumlistoffset"
const val INTENT_SHUFFLE = "subsonic.shuffle"
const val INTENT_REFRESH = "subsonic.refresh"
const val INTENT_STARRED = "subsonic.starred"
const val INTENT_RANDOM = "subsonic.random"
const val INTENT_GENRE_NAME = "subsonic.genre"
const val INTENT_IS_ALBUM = "subsonic.isalbum"
const val INTENT_VIDEOS = "subsonic.videos"
const val INTENT_SHOW_PLAYER = "subsonic.showplayer"
const val INTENT_APPEND = "subsonic.append"
// Names for Intent Actions
const val CMD_PROCESS_KEYCODE = "org.moire.ultrasonic.CMD_PROCESS_KEYCODE"
@ -122,5 +122,6 @@ object Constants {
const val ALBUM_ART_FILE = "folder.jpeg"
const val STARRED = "starred"
const val ALPHABETICAL_BY_NAME = "alphabeticalByName"
const val ALBUMS_OF_ARTIST = "albumsOfArtist"
const val RESULT_CLOSE_ALL = 1337
}

View File

@ -0,0 +1,50 @@
package org.moire.ultrasonic.util
import java.util.Comparator
import org.moire.ultrasonic.domain.MusicDirectory
class EntryByDiscAndTrackComparator : Comparator<MusicDirectory.Child> {
override fun compare(x: MusicDirectory.Child, y: MusicDirectory.Child): Int {
val discX = x.discNumber
val discY = y.discNumber
val trackX = if (x is MusicDirectory.Entry) x.track else null
val trackY = if (y is MusicDirectory.Entry) y.track else null
val albumX = x.album
val albumY = y.album
val pathX = x.path
val pathY = y.path
val albumComparison = compare(albumX, albumY)
if (albumComparison != 0) {
return albumComparison
}
val discComparison = compare(discX ?: 0, discY ?: 0)
if (discComparison != 0) {
return discComparison
}
val trackComparison = compare(trackX ?: 0, trackY ?: 0)
return if (trackComparison != 0) {
trackComparison
} else compare(
pathX ?: "",
pathY ?: ""
)
}
companion object {
private fun compare(a: Int, b: Int): Int {
return a.compareTo(b)
}
private fun compare(a: String?, b: String?): Int {
if (a == null && b == null) {
return 0
}
if (a == null) {
return -1
}
return if (b == null) {
1
} else a.compareTo(b)
}
}
}

View File

@ -91,11 +91,10 @@ object FileUtil {
@JvmStatic
fun getPlaylistDirectory(server: String? = null): File {
val playlistDir: File
if (server != null) {
playlistDir = File(playlistDirectory, server)
val playlistDir: File = if (server != null) {
File(playlistDirectory, server)
} else {
playlistDir = playlistDirectory
playlistDirectory
}
ensureDirectoryExistsAndIsReadWritable(playlistDir)
return playlistDir
@ -106,7 +105,7 @@ object FileUtil {
* @param entry The album entry
* @return File object. Not guaranteed that it exists
*/
fun getAlbumArtFile(entry: MusicDirectory.Entry): String? {
fun getAlbumArtFile(entry: MusicDirectory.Child): String? {
val albumDir = getAlbumDirectory(entry)
return getAlbumArtFileForAlbumDir(albumDir)
}
@ -117,7 +116,7 @@ object FileUtil {
* @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? {
fun getAlbumArtKey(entry: MusicDirectory.Child?, large: Boolean): String? {
if (entry == null) return null
val albumDir = getAlbumDirectory(entry)
return getAlbumArtKey(albumDir, large)
@ -137,7 +136,7 @@ object FileUtil {
/**
* Get the cache key for a given album entry
* @param albumDir The album directory
* @param albumDirPath The album directory
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
@ -187,7 +186,7 @@ object FileUtil {
return albumArtDir
}
fun getAlbumDirectory(entry: MusicDirectory.Entry): String {
fun getAlbumDirectory(entry: MusicDirectory.Child): String {
val dir: String
if (!TextUtils.isEmpty(entry.path) && getParentPath(entry.path!!) != null) {
val f = fileSystemSafeDir(entry.path)
@ -461,7 +460,7 @@ object FileUtil {
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
for (e in playlist.getTracks()) {
var filePath = getSongFile(e)
if (!StorageFile.isPathExists(filePath)) {

View File

@ -403,9 +403,9 @@ object Util {
}
@JvmStatic
fun getDrawableFromAttribute(context: Context?, attr: Int): Drawable {
fun getDrawableFromAttribute(context: Context, attr: Int): Drawable {
val attrs = intArrayOf(attr)
val ta = context!!.obtainStyledAttributes(attrs)
val ta = context.obtainStyledAttributes(attrs)
val drawableFromTheme: Drawable? = ta.getDrawable(0)
ta.recycle()
return drawableFromTheme!!
@ -461,20 +461,19 @@ object Util {
fun getSongsFromSearchResult(searchResult: SearchResult): MusicDirectory {
val musicDirectory = MusicDirectory()
for (entry in searchResult.songs) {
musicDirectory.addChild(entry)
musicDirectory.add(entry)
}
return musicDirectory
}
@JvmStatic
fun getSongsFromBookmarks(bookmarks: Iterable<Bookmark?>): MusicDirectory {
fun getSongsFromBookmarks(bookmarks: Iterable<Bookmark>): MusicDirectory {
val musicDirectory = MusicDirectory()
var song: MusicDirectory.Entry
for (bookmark in bookmarks) {
if (bookmark == null) continue
song = bookmark.entry
song.bookmarkPosition = bookmark.position
musicDirectory.addChild(song)
musicDirectory.add(song)
}
return musicDirectory
}
@ -689,7 +688,8 @@ object Util {
}
@JvmOverloads
fun formatTotalDuration(totalDuration: Long, inMilliseconds: Boolean = false): String {
fun formatTotalDuration(totalDuration: Long?, inMilliseconds: Boolean = false): String {
if (totalDuration == null) return ""
var millis = totalDuration
if (!inMilliseconds) {
millis = totalDuration * 1000
@ -795,7 +795,15 @@ object Util {
)
}
@Suppress("ComplexMethod", "LongMethod")
data class ReadableEntryDescription(
var artist: String,
var title: String,
val trackNumber: String,
val duration: String,
var bitrate: String?,
var fileFormat: String?,
)
fun getMediaDescriptionForEntry(
song: MusicDirectory.Entry,
mediaId: String? = null,
@ -803,15 +811,39 @@ object Util {
): MediaDescriptionCompat {
val descriptionBuilder = MediaDescriptionCompat.Builder()
val desc = readableEntryDescription(song)
val title: String
if (groupNameId != null)
descriptionBuilder.setExtras(
Bundle().apply {
putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
appContext().getString(groupNameId)
)
}
)
if (desc.trackNumber.isNotEmpty()) {
title = "${desc.trackNumber} - ${desc.title}"
} else {
title = desc.title
}
descriptionBuilder.setTitle(title)
descriptionBuilder.setSubtitle(desc.artist)
descriptionBuilder.setMediaId(mediaId)
return descriptionBuilder.build()
}
@Suppress("ComplexMethod", "LongMethod")
fun readableEntryDescription(song: MusicDirectory.Entry): ReadableEntryDescription {
val artist = StringBuilder(LINE_LENGTH)
var bitRate: String? = null
var trackText = ""
val duration = song.duration
if (duration != null) {
artist.append(
String.format(Locale.ROOT, "%s ", formatTotalDuration(duration.toLong()))
)
}
if (song.bitRate != null && song.bitRate!! > 0)
bitRate = String.format(
@ -849,8 +881,9 @@ object Util {
val trackNumber = song.track ?: 0
val title = StringBuilder(LINE_LENGTH)
if (Settings.shouldShowTrackNumber && trackNumber > 0)
title.append(String.format(Locale.ROOT, "%02d - ", trackNumber))
if (Settings.shouldShowTrackNumber && trackNumber > 0) {
trackText = String.format(Locale.ROOT, "%02d.", trackNumber)
}
title.append(song.title)
@ -865,21 +898,14 @@ object Util {
).append(')')
}
if (groupNameId != null)
descriptionBuilder.setExtras(
Bundle().apply {
putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
appContext().getString(groupNameId)
)
}
)
descriptionBuilder.setTitle(title)
descriptionBuilder.setSubtitle(artist)
descriptionBuilder.setMediaId(mediaId)
return descriptionBuilder.build()
return ReadableEntryDescription(
artist = artist.toString(),
title = title.toString(),
trackNumber = trackText,
duration = formatTotalDuration(duration?.toLong()),
bitrate = bitRate,
fileFormat = fileFormat,
)
}
fun getPendingIntentForMediaAction(

View File

@ -1,87 +0,0 @@
package org.moire.ultrasonic.view
import android.content.Context
import android.view.MenuItem
import android.view.View
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.MusicFolder
/**
* This little view shows the currently selected Folder (or catalog) on the music server.
* When clicked it will drop down a list of all available Folders and allow you to
* select one. The intended usage is to supply a filter to lists of artists, albums, etc
*/
class SelectMusicFolderView(
private val context: Context,
view: View,
private val onUpdate: (String?) -> Unit
) : RecyclerView.ViewHolder(view) {
private var musicFolders: List<MusicFolder> = mutableListOf()
private var selectedFolderId: String? = null
private val folderName: TextView = itemView.findViewById(R.id.select_folder_name)
private val layout: LinearLayout = itemView.findViewById(R.id.select_folder_header)
init {
folderName.text = context.getString(R.string.select_artist_all_folders)
layout.setOnClickListener { onFolderClick() }
}
fun setData(selectedId: String?, folders: List<MusicFolder>) {
selectedFolderId = selectedId
musicFolders = folders
if (selectedFolderId != null) {
for ((id, name) in musicFolders) {
if (id == selectedFolderId) {
folderName.text = name
break
}
}
} else {
folderName.text = context.getString(R.string.select_artist_all_folders)
}
}
private fun onFolderClick() {
val popup = PopupMenu(context, layout)
var menuItem = popup.menu.add(
MENU_GROUP_MUSIC_FOLDER, -1, 0, R.string.select_artist_all_folders
)
if (selectedFolderId == null || selectedFolderId!!.isEmpty()) {
menuItem.isChecked = true
}
musicFolders.forEachIndexed { i, musicFolder ->
val (id, name) = musicFolder
menuItem = popup.menu.add(MENU_GROUP_MUSIC_FOLDER, i, i + 1, name)
if (id == selectedFolderId) {
menuItem.isChecked = true
}
}
popup.menu.setGroupCheckable(MENU_GROUP_MUSIC_FOLDER, true, true)
popup.setOnMenuItemClickListener { item -> onFolderMenuItemSelected(item) }
popup.show()
}
private fun onFolderMenuItemSelected(menuItem: MenuItem): Boolean {
val selectedFolder = if (menuItem.itemId == -1) null else musicFolders[menuItem.itemId]
val musicFolderName = selectedFolder?.name
?: context.getString(R.string.select_artist_all_folders)
selectedFolderId = selectedFolder?.id
menuItem.isChecked = true
folderName.text = musicFolderName
onUpdate(selectedFolderId)
return true
}
companion object {
const val MENU_GROUP_MUSIC_FOLDER = 10
}
}

View File

@ -1,393 +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 2020 (C) Jozsef Varga
*/
package org.moire.ultrasonic.view
import android.content.Context
import android.graphics.drawable.AnimationDrawable
import android.graphics.drawable.Drawable
import android.text.TextUtils
import android.view.LayoutInflater
import android.widget.Checkable
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.featureflags.Feature
import org.moire.ultrasonic.featureflags.FeatureStorage
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.view.EntryAdapter.SongViewHolder
import timber.log.Timber
/**
* Used to display songs and videos in a `ListView`.
*/
class SongView(context: Context) : UpdateView(context), Checkable, KoinComponent {
var entry: MusicDirectory.Entry? = null
private set
private var isMaximized = false
private var leftImage: Drawable? = null
private var previousLeftImageType: ImageType? = null
private var previousRightImageType: ImageType? = null
private var leftImageType: ImageType? = null
private var downloadFile: DownloadFile? = null
private var playing = false
private var viewHolder: SongViewHolder? = null
private val features: FeatureStorage = get()
private val useFiveStarRating: Boolean = features.isFeatureEnabled(Feature.FIVE_STAR_RATING)
private val mediaPlayerController: MediaPlayerController by inject()
fun setLayout(song: MusicDirectory.Entry) {
inflater?.inflate(
if (song.isVideo) R.layout.video_list_item
else R.layout.song_list_item,
this,
true
)
viewHolder = SongViewHolder()
viewHolder!!.check = findViewById(R.id.song_check)
viewHolder!!.rating = findViewById(R.id.song_rating)
viewHolder!!.fiveStar1 = findViewById(R.id.song_five_star_1)
viewHolder!!.fiveStar2 = findViewById(R.id.song_five_star_2)
viewHolder!!.fiveStar3 = findViewById(R.id.song_five_star_3)
viewHolder!!.fiveStar4 = findViewById(R.id.song_five_star_4)
viewHolder!!.fiveStar5 = findViewById(R.id.song_five_star_5)
viewHolder!!.star = findViewById(R.id.song_star)
viewHolder!!.drag = findViewById(R.id.song_drag)
viewHolder!!.track = findViewById(R.id.song_track)
viewHolder!!.title = findViewById(R.id.song_title)
viewHolder!!.artist = findViewById(R.id.song_artist)
viewHolder!!.duration = findViewById(R.id.song_duration)
viewHolder!!.status = findViewById(R.id.song_status)
tag = viewHolder
}
fun setViewHolder(viewHolder: SongViewHolder?) {
this.viewHolder = viewHolder
tag = this.viewHolder
}
fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean) {
updateBackground()
entry = song
downloadFile = mediaPlayerController.getDownloadFileForSong(song)
val artist = StringBuilder(60)
var bitRate: String? = null
if (song.bitRate != null)
bitRate = String.format(
this.context.getString(R.string.song_details_kbps), song.bitRate
)
val fileFormat: String?
val suffix = song.suffix
val transcodedSuffix = song.transcodedSuffix
fileFormat = if (
TextUtils.isEmpty(transcodedSuffix) || transcodedSuffix == suffix || song.isVideo
) suffix else String.format("%s > %s", suffix, transcodedSuffix)
val artistName = song.artist
if (artistName != null) {
if (Settings.shouldDisplayBitrateWithArtist) {
artist.append(artistName).append(" (").append(
String.format(
this.context.getString(R.string.song_details_all),
if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat
)
).append(')')
} else {
artist.append(artistName)
}
}
val trackNumber = song.track ?: 0
if (Settings.shouldShowTrackNumber && trackNumber != 0) {
viewHolder?.track?.text = String.format("%02d.", trackNumber)
} else {
viewHolder?.track?.visibility = GONE
}
val title = StringBuilder(60)
title.append(song.title)
if (song.isVideo && Settings.shouldDisplayBitrateWithArtist) {
title.append(" (").append(
String.format(
this.context.getString(R.string.song_details_all),
if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat
)
).append(')')
}
viewHolder?.title?.text = title
viewHolder?.artist?.text = artist
val duration = song.duration
if (duration != null) {
viewHolder?.duration?.text = Util.formatTotalDuration(duration.toLong())
}
viewHolder?.check?.visibility = if (checkable && !song.isVideo) VISIBLE else GONE
viewHolder?.drag?.visibility = if (draggable) VISIBLE else GONE
if (isOffline()) {
viewHolder?.star?.visibility = GONE
viewHolder?.rating?.visibility = GONE
} else {
if (useFiveStarRating) {
viewHolder?.star?.visibility = GONE
val rating = if (song.userRating == null) 0 else song.userRating!!
viewHolder?.fiveStar1?.setImageDrawable(
if (rating > 0) starDrawable else starHollowDrawable
)
viewHolder?.fiveStar2?.setImageDrawable(
if (rating > 1) starDrawable else starHollowDrawable
)
viewHolder?.fiveStar3?.setImageDrawable(
if (rating > 2) starDrawable else starHollowDrawable
)
viewHolder?.fiveStar4?.setImageDrawable(
if (rating > 3) starDrawable else starHollowDrawable
)
viewHolder?.fiveStar5?.setImageDrawable(
if (rating > 4) starDrawable else starHollowDrawable
)
} else {
viewHolder?.rating?.visibility = GONE
viewHolder?.star?.setImageDrawable(
if (song.starred) starDrawable else starHollowDrawable
)
viewHolder?.star?.setOnClickListener {
val isStarred = song.starred
val id = song.id
if (!isStarred) {
viewHolder?.star?.setImageDrawable(starDrawable)
song.starred = true
} else {
viewHolder?.star?.setImageDrawable(starHollowDrawable)
song.starred = false
}
Thread {
val musicService = getMusicService()
try {
if (!isStarred) {
musicService.star(id, null, null)
} else {
musicService.unstar(id, null, null)
}
} catch (e: Exception) {
Timber.e(e)
}
}.start()
}
}
}
update()
}
override fun updateBackground() {}
@Synchronized
public override fun update() {
updateBackground()
val song = entry ?: return
downloadFile = mediaPlayerController.getDownloadFileForSong(song)
updateDownloadStatus(downloadFile!!)
if (entry?.starred != true) {
if (viewHolder?.star?.drawable !== starHollowDrawable) {
viewHolder?.star?.setImageDrawable(starHollowDrawable)
}
} else {
if (viewHolder?.star?.drawable !== starDrawable) {
viewHolder?.star?.setImageDrawable(starDrawable)
}
}
val rating = entry?.userRating ?: 0
viewHolder?.fiveStar1?.setImageDrawable(
if (rating > 0) starDrawable else starHollowDrawable
)
viewHolder?.fiveStar2?.setImageDrawable(
if (rating > 1) starDrawable else starHollowDrawable
)
viewHolder?.fiveStar3?.setImageDrawable(
if (rating > 2) starDrawable else starHollowDrawable
)
viewHolder?.fiveStar4?.setImageDrawable(
if (rating > 3) starDrawable else starHollowDrawable
)
viewHolder?.fiveStar5?.setImageDrawable(
if (rating > 4) starDrawable else starHollowDrawable
)
val playing = mediaPlayerController.currentPlaying === downloadFile
if (playing) {
if (!this.playing) {
this.playing = true
viewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
playingImage, null, null, null
)
}
} else {
if (this.playing) {
this.playing = false
viewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
0, 0, 0, 0
)
}
}
}
private fun updateDownloadStatus(downloadFile: DownloadFile) {
if (downloadFile.isWorkDone) {
val newLeftImageType =
if (downloadFile.isSaved) ImageType.Pin else ImageType.Downloaded
if (leftImageType != newLeftImageType) {
leftImage = if (downloadFile.isSaved) pinImage else downloadedImage
leftImageType = newLeftImageType
}
} else {
leftImageType = ImageType.None
leftImage = null
}
val rightImageType: ImageType
val rightImage: Drawable?
if (downloadFile.isDownloading && !downloadFile.isDownloadCancelled) {
viewHolder?.status?.text = Util.formatPercentage(downloadFile.progress.value!!)
rightImageType = ImageType.Downloading
rightImage = downloadingImage
} else {
rightImageType = ImageType.None
rightImage = null
val statusText = viewHolder?.status?.text
if (!statusText.isNullOrEmpty()) viewHolder?.status?.text = null
}
if (previousLeftImageType != leftImageType || previousRightImageType != rightImageType) {
previousLeftImageType = leftImageType
previousRightImageType = rightImageType
if (viewHolder?.status != null) {
viewHolder?.status?.setCompoundDrawablesWithIntrinsicBounds(
leftImage, null, rightImage, null
)
if (rightImage === downloadingImage) {
val frameAnimation = rightImage as AnimationDrawable?
frameAnimation!!.setVisible(true, true)
frameAnimation.start()
}
}
}
}
override fun setChecked(b: Boolean) {
viewHolder?.check?.isChecked = b
}
override fun isChecked(): Boolean {
return viewHolder?.check?.isChecked ?: false
}
override fun toggle() {
viewHolder?.check?.toggle()
}
fun maximizeOrMinimize() {
isMaximized = !isMaximized
viewHolder?.title?.isSingleLine = !isMaximized
viewHolder?.artist?.isSingleLine = !isMaximized
}
enum class ImageType {
None, Pin, Downloaded, Downloading
}
companion object {
private var starHollowDrawable: Drawable? = null
private var starDrawable: Drawable? = null
var pinImage: Drawable? = null
var downloadedImage: Drawable? = null
var downloadingImage: Drawable? = null
private var playingImage: Drawable? = null
private var theme: String? = null
private var inflater: LayoutInflater? = null
}
init {
val theme = Settings.theme
val themesMatch = theme == Companion.theme
inflater = LayoutInflater.from(this.context)
if (!themesMatch) Companion.theme = theme
if (starHollowDrawable == null || !themesMatch) {
starHollowDrawable = Util.getDrawableFromAttribute(context, R.attr.star_hollow)
}
if (starDrawable == null || !themesMatch) {
starDrawable = Util.getDrawableFromAttribute(context, R.attr.star_full)
}
if (pinImage == null || !themesMatch) {
pinImage = Util.getDrawableFromAttribute(context, R.attr.pin)
}
if (downloadedImage == null || !themesMatch) {
downloadedImage = Util.getDrawableFromAttribute(context, R.attr.downloaded)
}
if (downloadingImage == null || !themesMatch) {
downloadingImage = Util.getDrawableFromAttribute(context, R.attr.downloading)
}
if (playingImage == null || !themesMatch) {
playingImage = Util.getDrawableFromAttribute(context, R.attr.media_play_small)
}
}
}

View File

@ -4,6 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFF"
android:pathData="M4,10h12v2L4,12zM4,6h12v2L4,8zM4,14h8v2L4,16zM14,14v6l5,-3z"/>
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"/>
</vector>

View File

@ -5,5 +5,5 @@
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M4,10h12v2L4,12zM4,6h12v2L4,8zM4,14h8v2L4,16zM14,14v6l5,-3z"/>
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M9,3H11V5H9V3M13,3H15V5H13V3M9,7H11V9H9V7M13,7H15V9H13V7M9,11H11V13H9V11M13,11H15V13H13V11M9,15H11V17H9V15M13,15H15V17H13V15M9,19H11V21H9V19M13,19H15V21H13V19Z" />
</vector>

View File

@ -1,8 +0,0 @@
<!-- drawable/drag_vertical.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFF" android:pathData="M9,3H11V5H9V3M13,3H15V5H13V3M9,7H11V9H9V7M13,7H15V9H13V7M9,11H11V13H9V11M13,11H15V13H13V11M9,15H11V17H9V15M13,15H15V17H13V15M9,19H11V21H9V19M13,19H15V21H13V19Z" />
</vector>

View File

@ -1,8 +0,0 @@
<!-- drawable/drag_vertical.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M9,3H11V5H9V3M13,3H15V5H13V3M9,7H11V9H9V7M13,7H15V9H13V7M9,11H11V13H9V11M13,11H15V13H13V11M9,15H11V17H9V15M13,15H15V17H13V15M9,19H11V21H9V19M13,19H15V21H13V19Z" />
</vector>

Some files were not shown because too many files have changed in this diff Show More