mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-24 12:50:58 +03:00
Merge branch 'develop' into api30
This commit is contained in:
commit
90638e5fd7
core
domain/src/main/kotlin/org/moire/ultrasonic/domain
subsonic-api/src
integrationTest/kotlin/org/moire/ultrasonic/api/subsonic
main/kotlin/org/moire/ultrasonic/api/subsonic
ultrasonic
build.gradle
src/main
java/org/moire/ultrasonic
fragment
BookmarksFragment.javaLyricsFragment.javaPlaylistsFragment.javaPodcastFragment.javaSearchFragment.javaSelectGenreFragment.javaSharesFragment.java
provider
util
view
kotlin/org/moire/ultrasonic
activity
adapters
AlbumHeader.ktAlbumRowBinder.ktArtistRowBinder.ktBaseAdapter.ktDividerBinder.ktFolderSelectorBinder.ktHeaderViewBinder.ktMoreButtonBinder.ktServerRowAdapter.ktTrackViewBinder.ktTrackViewHolder.ktUtils.kt
di
domain
fragment
AlbumListFragment.ktArtistListFragment.ktArtistRowAdapter.ktBookmarksFragment.ktDownloadsFragment.ktEditServerFragment.ktEntryListFragment.ktGenericListFragment.ktGenericRowAdapter.ktMainFragment.ktMultiListFragment.ktNowPlayingFragment.ktPlayerFragment.ktSearchFragment.ktServerSelectorFragment.ktSettingsFragment.ktTrackCollectionFragment.ktTrackCollectionModel.kt
imageloader
model
AlbumListModel.ktArtistListModel.ktGenericListModel.ktSearchListModel.ktServerSettingsModel.ktTrackCollectionModel.kt
service
AutoMediaBrowserService.ktCachedMusicService.ktDownloadFile.ktDownloader.ktMediaPlayerController.ktMediaPlayerService.ktMusicService.ktOfflineMusicService.ktRESTMusicService.ktRxBus.kt
subsonic
util
view
res/drawable
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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")
|
||||
|
@ -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 = ""
|
||||
)
|
||||
|
@ -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()
|
||||
)
|
||||
|
6
core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/GetAlbumListResponse.kt
6
core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/GetAlbumListResponse.kt
@ -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()
|
||||
)
|
||||
|
@ -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 = [
|
||||
|
@ -42,8 +42,8 @@ empty-blocks:
|
||||
complexity:
|
||||
active: true
|
||||
TooManyFunctions:
|
||||
thresholdInFiles: 20
|
||||
thresholdInClasses: 20
|
||||
thresholdInFiles: 25
|
||||
thresholdInClasses: 25
|
||||
thresholdInInterfaces: 20
|
||||
thresholdInObjects: 30
|
||||
LabeledExpression:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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");
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ public abstract class LoadingTask<T> extends BackgroundTask<T>
|
||||
this.cancel = cancel;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void execute()
|
||||
{
|
||||
|
@ -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)
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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());
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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 ""
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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() }
|
||||
|
@ -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() }
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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 -> {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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())
|
@ -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 }
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package org.moire.ultrasonic.fragment
|
||||
package org.moire.ultrasonic.model
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
@ -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())
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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(" ")
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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> =
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
50
ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/EntryByDiscAndTrackComparator.kt
Normal file
50
ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/EntryByDiscAndTrackComparator.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)) {
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
10
ultrasonic/src/main/res/drawable/ic_drag_vertical.xml
Normal file
10
ultrasonic/src/main/res/drawable/ic_drag_vertical.xml
Normal 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>
|
@ -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>
|
@ -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
Loading…
x
Reference in New Issue
Block a user