diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt index 1c8e6440..3da622c6 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt @@ -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) diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/ArtistOrIndex.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/ArtistOrIndex.kt index 586f1dae..602cae66 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/ArtistOrIndex.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/ArtistOrIndex.kt @@ -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) +} diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt index b361d0b7..71a57502 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt @@ -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 { val id: String + + val longId: Long + get() = id.hashCode().toLong() + + override fun compareTo(other: Identifiable): Int { + return longId.compareTo(other.longId) + } } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt index a9c80a5c..e316dc42 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt @@ -5,71 +5,90 @@ import androidx.room.PrimaryKey import java.io.Serializable import java.util.Date -class MusicDirectory { +class MusicDirectory : ArrayList() { var name: String? = null - private val children = mutableListOf() - - fun addAll(entries: Collection) { - 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 = children.toList() @JvmOverloads fun getChildren( includeDirs: Boolean = true, includeFiles: Boolean = true - ): List { + ): List { 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 { + return mapNotNull { + it as? Entry + } + } + + fun getAlbums(): List { + 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 + } } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt index 82479b70..ec576d8f 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt @@ -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, - val albums: List, - val songs: List + val artists: List = listOf(), + val albums: List = listOf(), + val songs: List = listOf() ) diff --git a/core/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiGetAlbumListRequestTest.kt b/core/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiGetAlbumListRequestTest.kt index 37ee2f90..b580ad6d 100644 --- a/core/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiGetAlbumListRequestTest.kt +++ b/core/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiGetAlbumListRequestTest.kt @@ -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, diff --git a/core/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiSearchTwoTest.kt b/core/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiSearchTwoTest.kt index 9ab6b79b..17cb3ef0 100644 --- a/core/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiSearchTwoTest.kt +++ b/core/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiSearchTwoTest.kt @@ -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") diff --git a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/Album.kt b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/Album.kt index 801634c3..f326caae 100644 --- a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/Album.kt +++ b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/Album.kt @@ -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 = emptyList(), @JsonProperty("starred") val starredDate: String = "" ) diff --git a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/SearchTwoResult.kt b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/SearchTwoResult.kt index 1b42f640..5e94c22a 100644 --- a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/SearchTwoResult.kt +++ b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/SearchTwoResult.kt @@ -4,6 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty data class SearchTwoResult( @JsonProperty("artist") val artistList: List = emptyList(), - @JsonProperty("album") val albumList: List = emptyList(), + @JsonProperty("album") val albumList: List = emptyList(), @JsonProperty("song") val songList: List = emptyList() ) diff --git a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/GetAlbumListResponse.kt b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/GetAlbumListResponse.kt index 8e3ca708..81c6be5b 100644 --- a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/GetAlbumListResponse.kt +++ b/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 + val albumList: List get() = albumWrapper.albumList } private class AlbumWrapper( - @JsonProperty("album") val albumList: List = emptyList() + @JsonProperty("album") val albumList: List = emptyList() ) diff --git a/dependencies.gradle b/dependencies.gradle index 5ac416a4..de3f553b 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -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 = [ diff --git a/detekt-config.yml b/detekt-config.yml index 8729b6e3..2bb90612 100644 --- a/detekt-config.yml +++ b/detekt-config.yml @@ -42,8 +42,8 @@ empty-blocks: complexity: active: true TooManyFunctions: - thresholdInFiles: 20 - thresholdInClasses: 20 + thresholdInFiles: 25 + thresholdInClasses: 25 thresholdInInterfaces: 20 thresholdInObjects: 30 LabeledExpression: diff --git a/gradle.properties b/gradle.properties index 19077ae1..2635181c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 5bd4da0f..888837bf 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -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 diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/BookmarksFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/BookmarksFragment.java deleted file mode 100644 index 36897f5f..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/BookmarksFragment.java +++ /dev/null @@ -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 = inject(MediaPlayerController.class); - private final Lazy imageLoader = inject(ImageLoaderProvider.class); - private final Lazy 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 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 getSelectedSongs(ListView albumListView) - { - List 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 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 songs = getSelectedSongs(albumListView); - - if (songs.isEmpty()) - { - selectAll(true, false); - songs = getSelectedSongs(albumListView); - } - - downloadBackground(save, songs); - } - - private void downloadBackground(final boolean save, final List 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 songs = getSelectedSongs(albumListView); - - if (songs.isEmpty()) - { - selectAll(true, false); - songs = getSelectedSongs(albumListView); - } - - mediaPlayerController.getValue().delete(songs); - } - - private void unpin() - { - List 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> - { - public LoadTask() - { - super(BookmarksFragment.this.getActivity(), true, refreshAlbumListView, cancellationToken); - } - - protected abstract MusicDirectory load(MusicService service) throws Exception; - - @Override - protected Pair 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 result) - { - MusicDirectory musicDirectory = result.first; - List 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)); - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/LyricsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/LyricsFragment.java index e461db64..f7f87b17 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/LyricsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/LyricsFragment.java @@ -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); } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PlaylistsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PlaylistsFragment.java index e78777b6..5d11c45e 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PlaylistsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PlaylistsFragment.java @@ -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); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PodcastFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PodcastFragment.java index eb068046..00210dd0 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PodcastFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PodcastFragment.java @@ -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); } }); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SearchFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SearchFragment.java deleted file mode 100644 index fd2797c1..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SearchFragment.java +++ /dev/null @@ -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 mediaPlayerControllerLazy = inject(MediaPlayerController.class); - private final Lazy imageLoaderProvider = inject(ImageLoaderProvider.class); - private final Lazy downloadHandler = inject(DownloadHandler.class); - private final Lazy shareHandler = inject(ShareHandler.class); - private final Lazy 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 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 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 task = new FragmentBackgroundTask(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 artists = searchResult.getArtists(); - if (!artists.isEmpty()) - { - mergeAdapter.addView(artistsHeading); - List 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 albums = searchResult.getAlbums(); - if (!albums.isEmpty()) - { - mergeAdapter.addView(albumsHeading); - List 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 songs = searchResult.getSongs(); - if (!songs.isEmpty()) - { - mergeAdapter.addView(songsHeading); - List 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); - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SelectGenreFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SelectGenreFragment.java index 47290c02..dc6f3382 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SelectGenreFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SelectGenreFragment.java @@ -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); } } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SharesFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SharesFragment.java index 4da42549..958774bf 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SharesFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SharesFragment.java @@ -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); } }); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java b/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java index 619484e2..cd88a66e 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java @@ -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"); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/AlbumHeader.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/AlbumHeader.java deleted file mode 100644 index 947b86f1..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/AlbumHeader.java +++ /dev/null @@ -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 artists; - private Set grandParents; - private Set genres; - private Set years; - - public boolean getIsAllVideo() - { - return isAllVideo; - } - - public long getTotalDuration() - { - return totalDuration; - } - - public Set getArtists() - { - return artists; - } - - public Set getGrandParents() - { - return this.grandParents; - } - - public Set getGenres() - { - return this.genres; - } - - public Set getYears() - { - return this.years; - } - - public AlbumHeader() - { - this.artists = new HashSet(); - this.grandParents = new HashSet(); - this.genres = new HashSet(); - this.years = new HashSet(); - - this.isAllVideo = true; - this.totalDuration = 0; - } - - public static AlbumHeader processEntries(Context context, Iterable 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); - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/EntryByDiscAndTrackComparator.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/EntryByDiscAndTrackComparator.java deleted file mode 100644 index cbf91c91..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/EntryByDiscAndTrackComparator.java +++ /dev/null @@ -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, 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); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java index f42771c0..16936c09 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java @@ -19,6 +19,7 @@ public abstract class LoadingTask extends BackgroundTask this.cancel = cancel; } + @Override public void execute() { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java index d918a4e0..156ea868 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java @@ -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) diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java deleted file mode 100644 index 1d77defc..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java +++ /dev/null @@ -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 . - - 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(); - } - }); - } - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ArtistAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/ArtistAdapter.java deleted file mode 100644 index 04471395..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ArtistAdapter.java +++ /dev/null @@ -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 . - - 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 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 artists) - { - super(context, R.layout.generic_text_list_item, artists); - - layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - - Collection sectionSet = new LinkedHashSet(30); - List positionList = new ArrayList(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; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/EntryAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/EntryAdapter.java deleted file mode 100644 index 65488e8f..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/EntryAdapter.java +++ /dev/null @@ -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 . - - 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 -{ - private final Context context; - private final ImageLoader imageLoader; - private final boolean checkable; - - public EntryAdapter(Context context, ImageLoader imageLoader, List 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; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java index 5b8f422e..475bb602 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java @@ -48,7 +48,7 @@ public class GenreAdapter extends ArrayAdapter implements SectionIndexer public GenreAdapter(Context context, List 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 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()); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/PlaylistView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/PlaylistView.java index f8919709..16c396ad 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/PlaylistView.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/PlaylistView.java @@ -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(); } } \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/PodcatsChannelItemView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/PodcastChannelItemView.java similarity index 85% rename from ultrasonic/src/main/java/org/moire/ultrasonic/view/PodcatsChannelItemView.java rename to ultrasonic/src/main/java/org/moire/ultrasonic/view/PodcastChannelItemView.java index 89163d86..367d01f4 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/PodcatsChannelItemView.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/PodcastChannelItemView.java @@ -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(); } } \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareView.java index ffe5fdce..0bed3b2c 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareView.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareView.java @@ -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(); } } \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/SongListAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/SongListAdapter.java deleted file mode 100644 index 32cae84d..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/SongListAdapter.java +++ /dev/null @@ -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 -{ - Context context; - - public SongListAdapter(Context context, final List 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; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/UpdateView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/UpdateView.java deleted file mode 100644 index cf94ab80..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/UpdateView.java +++ /dev/null @@ -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 INSTANCES = new WeakHashMap(); - - 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 views = new ArrayList(); - - 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 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() - { - - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index 55d52083..7cc2f8bd 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -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) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumHeader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumHeader.kt new file mode 100644 index 00000000..da300de0 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumHeader.kt @@ -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, + var name: String? +) : Identifiable { + var isAllVideo: Boolean + private set + + var totalDuration: Long + private set + + var childCount = 0 + + private val _artists: MutableSet + private val _grandParents: MutableSet + private val _genres: MutableSet + private val _years: MutableSet + + val artists: Set + get() = _artists + + val grandParents: Set + get() = _grandParents + + val genres: Set + get() = _genres + + val years: Set + 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) { + 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 +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowBinder.kt similarity index 55% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowBinder.kt index e203a35e..2368f8fe 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowBinder.kt @@ -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, - 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( - onItemClick, - onContextMenuClick, - onMusicFolderUpdate -) { - - init { - super.submitList(itemList) - } + context: Context +) : ItemViewBinder(), 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)) + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt new file mode 100644 index 00000000..d83385ab --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt @@ -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(), + 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)) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/BaseAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/BaseAdapter.kt new file mode 100644 index 00000000..55f17f0c --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/BaseAdapter.kt @@ -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 : 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 = BoundedTreeSet(selectionType.size) + internal var selectionRevision: MutableLiveData = MutableLiveData(0) + + private val diffCallback = GenericDiffCallback() + + 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 + get() = getCurrentList() + set(value) { + throw IllegalAccessException("You must use submitList() to add data to the Adapter") + } + + var mDiffer: AsyncListDiffer = AsyncListDiffer( + AdapterListUpdateCallback(this), + AsyncDifferConfig.Builder(diffCallback).build() + ) + + private val mListener = + ListListener { 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?) { + 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?, 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 { + 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, currentList: List) { + // 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 : DiffUtil.ItemCallback() { + @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(type).delegate + + if (binder is Utils.SectionedBinder) { + return binder.getSectionName(items[position] as Identifiable) + } + + return "" + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/DividerBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/DividerBinder.kt new file mode 100644 index 00000000..eae411dd --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/DividerBinder.kt @@ -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() { + + // 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() + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/FolderSelectorBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/FolderSelectorBinder.kt new file mode 100644 index 00000000..0f7d7f1f --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/FolderSelectorBinder.kt @@ -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(), + KoinComponent { + + private val weakContext: WeakReference = 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 + ) : RecyclerView.ViewHolder(view) { + + private var data: FolderHeader? = null + + private val selectedFolderId: String? + get() = data?.selected + + private val musicFolders: List + 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, + var selected: String? + ) : Identifiable { + override val id: String + get() = "FOLDERSELECTOR" + + override val longId: Long + get() = -1L + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt new file mode 100644 index 00000000..7032864f --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/HeaderViewBinder.kt @@ -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(), KoinComponent { + + private val weakContext: WeakReference = 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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/MoreButtonBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/MoreButtonBinder.kt new file mode 100644 index 00000000..d83553a5 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/MoreButtonBinder.kt @@ -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() { + + // 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() + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt similarity index 99% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerRowAdapter.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt index 2b9e4be1..89c7a5ea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt new file mode 100644 index 00000000..2ed77e52 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt @@ -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(), 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) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt new file mode 100644 index 00000000..ee46f7dc --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/Utils.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/Utils.kt new file mode 100644 index 00000000..991ae445 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/Utils.kt @@ -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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt index 992141e1..79209e5b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt @@ -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" diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIAlbumConverter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIAlbumConverter.kt index 0dac2654..eb42d409 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIAlbumConverter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIAlbumConverter.kt @@ -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.toDomainEntityList(): List = this.map { it.toDomainEntity() } +fun List.toDomainEntityList(): List = this.map { it.toDomainEntity() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIArtistConverter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIArtistConverter.kt index 51c2c72f..4c2294ba 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIArtistConverter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/APIArtistConverter.kt @@ -23,3 +23,7 @@ fun APIArtist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory(). name = this@toMusicDirectoryDomainEntity.name addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity() }) } + +fun APIArtist.toDomainEntityList(): List { + return this.albumsList.map { it.toDomainEntity() } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt index aafdea81..93c7d1c7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt @@ -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() { +class AlbumListFragment : EntryListFragment() { /** * The ViewModel to use to get the data @@ -24,53 +32,28 @@ class AlbumListFragment : EntryListFragment> { + override fun getLiveData( + args: Bundle?, + refresh: Boolean + ): LiveData> { 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 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) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt index 58ee16a8..2446e05a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt @@ -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() { +class ArtistListFragment : EntryListFragment() { /** * The ViewModel to use to get the data @@ -20,42 +27,57 @@ class ArtistListFragment : EntryListFragment() /** * 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> { - val refresh = args?.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH) ?: false - return listModel.getItems(refresh, refreshListView!!) + override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData> { + 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) + } + } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt deleted file mode 100644 index d4079a73..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt +++ /dev/null @@ -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, - onItemClick: (ArtistOrIndex) -> Unit, - onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean, - private val imageLoader: ImageLoader, - onMusicFolderUpdate: (String?) -> Unit -) : GenericRowAdapter( - 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) - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt new file mode 100644 index 00000000..c9c8b6b9 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt @@ -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> { + 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) { + if (songs.isNotEmpty()) { + + val position = songs[0].bookmarkPosition + + mediaPlayerController.restore( + songs = songs, + currentPlayingIndex = 0, + currentPlayingPosition = position, + autoPlay = true, + newPlaylist = true + ) + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt index b9ca729a..86847435 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt @@ -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() { +/** + * 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() { /** * 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> { + override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData> { 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, - onItemClick: (DownloadFile) -> Unit, - onContextMenuClick: (MenuItem, DownloadFile) -> Boolean, - onMusicFolderUpdate: (String?) -> Unit, - context: Context, - val lifecycleOwner: LifecycleOwner -) : GenericRowAdapter( - 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 } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt index 1addcf40..48d75a62 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt new file mode 100644 index 00000000..395d2d6c --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt @@ -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 : MultiListFragment() { + + /** + * 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) -> Unit = { + emptyView.isVisible = it.isEmpty() && !(refreshListView?.isRefreshing?:false) + + if (showFolderHeader()) { + val list = mutableListOf(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 + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt deleted file mode 100644 index 1aa1f254..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt +++ /dev/null @@ -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> : 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> - - /** - * The central function to pass a query to the model and return a LiveData object - */ - abstract fun getLiveData(args: Bundle? = null): LiveData> - - /** - * 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 -> - 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(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> : - GenericListFragment() { - @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) - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt deleted file mode 100644 index 33f3783e..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt +++ /dev/null @@ -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 in a RecyclerView -*/ -abstract class GenericRowAdapter( - val onItemClick: (T) -> Unit, - val onContextMenuClick: (MenuItem, T) -> Boolean, - private val onMusicFolderUpdate: (String?) -> Unit -) : ListAdapter(GenericDiffCallback()) { - - protected abstract val layout: Int - protected abstract val contextMenuLayout: Int - - var folderHeaderEnabled: Boolean = true - var selectFolderHeader: SelectMusicFolderView? = null - var musicFolders: List = listOf() - var selectedFolder: String? = null - - /** - * Sets the content and state of the music folder selector row - */ - fun setFolderList(changedFolders: List, 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 : DiffUtil.ItemCallback() { - @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 - } - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt index 56d33651..940fb1b5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt @@ -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) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt new file mode 100644 index 00000000..fef74587 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt @@ -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 : 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 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> + + /** + * 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> { + 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) -> 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(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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index 93b62077..4d700410 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 52dcb576..04f9c2d3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -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 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(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 -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt new file mode 100644 index 00000000..8acf907d --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -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(), 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) { + 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() + + 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() + + 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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt index 05d7b568..9e1eeabe 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index c72430fe..3212d2ed 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 6394b08a..e9f398e2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -10,68 +10,61 @@ package org.moire.ultrasonic.fragment import android.os.Bundle import android.os.Handler import android.os.Looper -import android.view.ContextMenu -import android.view.ContextMenu.ContextMenuInfo -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.AdapterContextMenuInfo import android.widget.ImageView -import android.widget.ListView -import android.widget.TextView -import androidx.fragment.app.Fragment +import androidx.core.view.isVisible import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer +import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import androidx.navigation.Navigation -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import java.util.Collections -import java.util.Random import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.moire.ultrasonic.R +import org.moire.ultrasonic.adapters.AlbumHeader +import org.moire.ultrasonic.adapters.AlbumRowBinder +import org.moire.ultrasonic.adapters.HeaderViewBinder +import org.moire.ultrasonic.adapters.TrackViewBinder import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline +import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicDirectory -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.getTitle import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.model.TrackCollectionModel import org.moire.ultrasonic.service.MediaPlayerController -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.AlbumHeader import org.moire.ultrasonic.util.CancellationToken import org.moire.ultrasonic.util.CommunicationError import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util -import org.moire.ultrasonic.view.AlbumView -import org.moire.ultrasonic.view.EntryAdapter -import org.moire.ultrasonic.view.SongView -import timber.log.Timber /** * Displays a group of tracks, eg. the songs of an album, of a playlist etc. - * TODO: Refactor this fragment and model to extend the GenericListFragment + * + * In most cases the data should be just a list of Entries, but there are some cases + * where the list can contain Albums as well. This happens especially when having ID3 tags disabled, + * or using Offline mode, both in which Indexes instead of Artists are being used. + * + * TODO: Remove more button and introduce endless scrolling */ -class TrackCollectionFragment : Fragment() { +@Suppress("TooManyFunctions") +open class TrackCollectionFragment : MultiListFragment() { - private var refreshAlbumListView: SwipeRefreshLayout? = null - private var albumListView: ListView? = null - private var header: View? = null private var albumButtons: View? = null - private var emptyView: TextView? = null - private var selectButton: ImageView? = null - private var playNowButton: ImageView? = null + internal var selectButton: ImageView? = null + internal var playNowButton: ImageView? = null private var playNextButton: ImageView? = null private var playLastButton: ImageView? = null - private var pinButton: ImageView? = null + internal var pinButton: ImageView? = null private var unpinButton: ImageView? = null private var downloadButton: ImageView? = null private var deleteButton: ImageView? = null @@ -81,28 +74,17 @@ class TrackCollectionFragment : Fragment() { private var playAllButton: MenuItem? = null private var shareButton: MenuItem? = null - private val mediaPlayerController: MediaPlayerController by inject() - private val downloadHandler: DownloadHandler by inject() + internal val mediaPlayerController: MediaPlayerController by inject() private val networkAndStorageChecker: NetworkAndStorageChecker by inject() - private val imageLoaderProvider: ImageLoaderProvider by inject() private val shareHandler: ShareHandler by inject() - private var cancellationToken: CancellationToken? = null + internal var cancellationToken: CancellationToken? = null - private val model: TrackCollectionModel by viewModels() - private val random: Random = Random() + override val listModel: TrackCollectionModel by viewModels() - 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(R.layout.select_album, container, false) - } + /** + * The id of the main layout + */ + override val mainLayout: Int = R.layout.list_layout_track override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -110,54 +92,65 @@ class TrackCollectionFragment : Fragment() { albumButtons = view.findViewById(R.id.menu_album) - refreshAlbumListView = view.findViewById(R.id.select_album_entries_refresh) - albumListView = view.findViewById(R.id.select_album_entries_list) - - refreshAlbumListView!!.setOnRefreshListener { - updateDisplay(true) + // Setup refresh handler + refreshListView = view.findViewById(refreshListId) + refreshListView?.setOnRefreshListener { + getLiveData(arguments, true) } - header = LayoutInflater.from(context).inflate( - R.layout.select_album_header, albumListView, - false + setupButtons(view) + + registerForContextMenu(listView!!) + setHasOptionsMenu(true) + + // Create a View Manager + viewManager = LinearLayoutManager(this.context) + + // Hook up the view with the manager and the adapter + listView = view.findViewById(recyclerViewId).apply { + setHasFixedSize(true) + layoutManager = viewManager + adapter = viewAdapter + } + + viewAdapter.register( + HeaderViewBinder( + context = requireContext() + ) ) - model.currentDirectory.observe(viewLifecycleOwner, defaultObserver) - model.songsForGenre.observe(viewLifecycleOwner, songsForGenreObserver) + viewAdapter.register( + TrackViewBinder( + onItemClick = { onItemClick(it.song) }, + onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.song) }, + checkable = true, + draggable = false, + context = requireContext(), + lifecycleOwner = viewLifecycleOwner + ) + ) - albumListView!!.choiceMode = ListView.CHOICE_MODE_MULTIPLE - albumListView!!.setOnItemClickListener { parent, theView, position, _ -> - if (position >= 0) { - val entry = parent.getItemAtPosition(position) as MusicDirectory.Entry? - if (entry != null && entry.isDirectory) { - val bundle = Bundle() - bundle.putString(Constants.INTENT_EXTRA_NAME_ID, entry.id) - bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, entry.isDirectory) - bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.title) - bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, entry.parent) - Navigation.findNavController(theView).navigate( - R.id.trackCollectionFragment, - bundle - ) - } else if (entry != null && entry.isVideo) { - VideoPlayer.playVideo(requireContext(), entry) - } else { - enableButtons() - } - } - } + viewAdapter.register( + AlbumRowBinder( + { entry -> onItemClick(entry) }, + { menuItem, entry -> onContextMenuItemSelected(menuItem, entry) }, + imageLoaderProvider.getImageLoader(), + context = requireContext() + ) + ) - albumListView!!.setOnItemLongClickListener { _, theView, _, _ -> - if (theView is AlbumView) { - return@setOnItemLongClickListener false - } - if (theView is SongView) { - theView.maximizeOrMinimize() - return@setOnItemLongClickListener true - } - return@setOnItemLongClickListener false - } + enableButtons() + // Update the buttons when the selection has changed + viewAdapter.selectionRevision.observe( + viewLifecycleOwner, + { + enableButtons() + } + ) + } + + internal open fun setupButtons(view: View) { selectButton = view.findViewById(R.id.select_album_select) playNowButton = view.findViewById(R.id.select_album_play_now) playNextButton = view.findViewById(R.id.select_album_play_next) @@ -167,162 +160,466 @@ class TrackCollectionFragment : Fragment() { downloadButton = view.findViewById(R.id.select_album_download) deleteButton = view.findViewById(R.id.select_album_delete) moreButton = view.findViewById(R.id.select_album_more) - emptyView = TextView(requireContext()) - selectButton!!.setOnClickListener { + selectButton?.setOnClickListener { selectAllOrNone() } - playNowButton!!.setOnClickListener { + + playNowButton?.setOnClickListener { playNow(false) } - playNextButton!!.setOnClickListener { + + playNextButton?.setOnClickListener { downloadHandler.download( this@TrackCollectionFragment, append = true, save = false, autoPlay = false, playNext = true, shuffle = false, - songs = getSelectedSongs(albumListView) + songs = getSelectedSongs() ) - selectAll(selected = false, toast = false) } + playLastButton!!.setOnClickListener { playNow(true) } - pinButton!!.setOnClickListener { + + pinButton?.setOnClickListener { downloadBackground(true) - selectAll(selected = false, toast = false) - } - unpinButton!!.setOnClickListener { - unpin() - selectAll(selected = false, toast = false) - } - downloadButton!!.setOnClickListener { - downloadBackground(false) - selectAll(selected = false, toast = false) - } - deleteButton!!.setOnClickListener { - delete() - selectAll(selected = false, toast = false) } - registerForContextMenu(albumListView!!) - setHasOptionsMenu(true) - enableButtons() - updateDisplay(false) + unpinButton?.setOnClickListener { + unpin() + } + + downloadButton?.setOnClickListener { + downloadBackground(false) + } + + deleteButton?.setOnClickListener { + delete() + } } val handler = CoroutineExceptionHandler { _, exception -> Handler(Looper.getMainLooper()).post { CommunicationError.handleError(exception, context) } - refreshAlbumListView!!.isRefreshing = false + refreshListView?.isRefreshing = false } - private fun updateDisplay(refresh: Boolean) { - val args = requireArguments() - val id = args.getString(Constants.INTENT_EXTRA_NAME_ID) - val isAlbum = args.getBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, false) - val name = args.getString(Constants.INTENT_EXTRA_NAME_NAME) - val parentId = args.getString(Constants.INTENT_EXTRA_NAME_PARENT_ID) - val playlistId = args.getString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID) - val podcastChannelId = args.getString( - Constants.INTENT_EXTRA_NAME_PODCAST_CHANNEL_ID - ) - val playlistName = args.getString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME) - val shareId = args.getString(Constants.INTENT_EXTRA_NAME_SHARE_ID) - val shareName = args.getString(Constants.INTENT_EXTRA_NAME_SHARE_NAME) - val genreName = args.getString(Constants.INTENT_EXTRA_NAME_GENRE_NAME) + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + playAllButton = menu.findItem(R.id.select_album_play_all) - val getStarredTracks = args.getInt(Constants.INTENT_EXTRA_NAME_STARRED, 0) - val getVideos = args.getInt(Constants.INTENT_EXTRA_NAME_VIDEOS, 0) - val getRandomTracks = args.getInt(Constants.INTENT_EXTRA_NAME_RANDOM, 0) - val albumListSize = args.getInt( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0 - ) - val albumListOffset = args.getInt( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0 - ) - - fun setTitle(name: String?) { - setTitle(this@TrackCollectionFragment, name) - } - - fun setTitle(name: Int) { - setTitle(this@TrackCollectionFragment, name) - } - - model.viewModelScope.launch(handler) { - refreshAlbumListView!!.isRefreshing = true - - model.getMusicFolders(refresh) - - if (playlistId != null) { - setTitle(playlistName!!) - model.getPlaylist(playlistId, playlistName) - } else if (podcastChannelId != null) { - setTitle(getString(R.string.podcasts_label)) - model.getPodcastEpisodes(podcastChannelId) - } else if (shareId != null) { - setTitle(shareName) - model.getShare(shareId) - } else if (genreName != null) { - setTitle(genreName) - model.getSongsForGenre(genreName, albumListSize, albumListOffset) - } else if (getStarredTracks != 0) { - setTitle(getString(R.string.main_songs_starred)) - model.getStarred() - } else if (getVideos != 0) { - setTitle(R.string.main_videos) - model.getVideos(refresh) - } else if (getRandomTracks != 0) { - setTitle(R.string.main_songs_random) - model.getRandom(albumListSize) - } else { - setTitle(name) - if (!isOffline() && Settings.shouldUseId3Tags) { - if (isAlbum) { - model.getAlbum(refresh, id!!, name, parentId) - } else { - model.getArtist(refresh, id!!, name) - } - } else { - model.getMusicDirectory(refresh, id!!, name, parentId) - } - } - - refreshAlbumListView!!.isRefreshing = false - } - } - - override fun onCreateContextMenu(menu: ContextMenu, view: View, menuInfo: ContextMenuInfo?) { - super.onCreateContextMenu(menu, view, menuInfo) - val info = menuInfo as AdapterContextMenuInfo? - - val entry = albumListView!!.getItemAtPosition(info!!.position) as MusicDirectory.Entry? - - if (entry != null && entry.isDirectory) { - val inflater = requireActivity().menuInflater - inflater.inflate(R.menu.generic_context_menu, menu) + if (playAllButton != null) { + playAllButton!!.isVisible = playAllButtonVisible } shareButton = menu.findItem(R.id.menu_item_share) if (shareButton != null) { - shareButton!!.isVisible = !isOffline() - } - - val downloadMenuItem = menu.findItem(R.id.menu_download) - if (downloadMenuItem != null) { - downloadMenuItem.isVisible = !isOffline() + shareButton!!.isVisible = shareButtonVisible } } - override fun onContextItemSelected(menuItem: MenuItem): Boolean { - Timber.d("onContextItemSelected") - val info = menuItem.menuInfo as AdapterContextMenuInfo? ?: return true + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.select_album, menu) + super.onCreateOptionsMenu(menu, inflater) + } - val entry = albumListView!!.getItemAtPosition(info.position) as MusicDirectory.Entry? - ?: return true + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val itemId = item.itemId + if (itemId == R.id.select_album_play_all) { + playAll() + return true + } else if (itemId == R.id.menu_item_share) { + shareHandler.createShare( + this, getSelectedSongs(), + refreshListView, cancellationToken!! + ) + return true + } - val entryId = entry.id + return false + } + + override fun onDestroyView() { + cancellationToken!!.cancel() + super.onDestroyView() + } + + private fun playNow(append: Boolean) { + val selectedSongs = getSelectedSongs() + + if (selectedSongs.isNotEmpty()) { + downloadHandler.download( + this, append, false, !append, playNext = false, + shuffle = false, songs = selectedSongs + ) + } else { + playAll(false, append) + } + } + + /** + * Get the size of the underlying list + */ + private val childCount: Int + get() { + val count = viewAdapter.getCurrentList().count() + if (listModel.showHeader) { + return count - 1 + } else { + return count + } + } + + private fun playAll(shuffle: Boolean = false, append: Boolean = false) { + var hasSubFolders = false + + for (item in viewAdapter.getCurrentList()) { + if (item is MusicDirectory.Entry && item.isDirectory) { + hasSubFolders = true + break + } + } + + val isArtist = arguments?.getBoolean(Constants.INTENT_ARTIST, false) ?: false + val id = arguments?.getString(Constants.INTENT_ID) + + if (hasSubFolders && id != null) { + downloadHandler.downloadRecursively( + fragment = this, + id = id, + save = false, + append = append, + autoPlay = !append, + shuffle = shuffle, + background = false, + playNext = false, + unpin = false, + isArtist = isArtist + ) + } else { + downloadHandler.download( + fragment = this, + append = append, + save = false, + autoPlay = !append, + playNext = false, + shuffle = shuffle, + songs = getAllSongs() + ) + } + } + + @Suppress("UNCHECKED_CAST") + private fun getAllSongs(): List { + return viewAdapter.getCurrentList().filter { + it is MusicDirectory.Entry && !it.isDirectory + } as List + } + + internal fun selectAllOrNone() { + val someUnselected = viewAdapter.selectedSet.size < childCount + + selectAll(someUnselected, true) + } + + internal fun selectAll(selected: Boolean, toast: Boolean) { + var selectedCount = viewAdapter.selectedSet.size * -1 + + selectedCount += viewAdapter.setSelectionStatusOfAll(selected) + + // Display toast: N tracks selected + if (toast) { + val toastResId = R.string.select_album_n_selected + Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0))) + } + } + + internal open fun enableButtons(selection: List = getSelectedSongs()) { + val enabled = selection.isNotEmpty() + var unpinEnabled = false + var deleteEnabled = false + val multipleSelection = viewAdapter.hasMultipleSelection() + + var pinnedCount = 0 + + for (song in selection) { + val downloadFile = mediaPlayerController.getDownloadFileForSong(song) + if (downloadFile.isWorkDone) { + deleteEnabled = true + } + if (downloadFile.isSaved) { + pinnedCount++ + unpinEnabled = true + } + } + + playNowButton?.isVisible = enabled + playNextButton?.isVisible = enabled && multipleSelection + playLastButton?.isVisible = enabled && multipleSelection + pinButton?.isVisible = (enabled && !isOffline() && selection.size > pinnedCount) + unpinButton?.isVisible = (enabled && unpinEnabled) + downloadButton?.isVisible = (enabled && !deleteEnabled && !isOffline()) + deleteButton?.isVisible = (enabled && deleteEnabled) + } + + internal fun downloadBackground(save: Boolean) { + var songs = getSelectedSongs() + + if (songs.isEmpty()) { + songs = getAllSongs() + } + + downloadBackground(save, songs) + } + + private fun downloadBackground(save: Boolean, songs: List) { + val onValid = Runnable { + networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() + mediaPlayerController.downloadBackground(songs, save) + + if (save) { + Util.toast( + context, + resources.getQuantityString( + R.plurals.select_album_n_songs_pinned, songs.size, songs.size + ) + ) + } else { + Util.toast( + context, + resources.getQuantityString( + R.plurals.select_album_n_songs_downloaded, songs.size, songs.size + ) + ) + } + } + onValid.run() + } + + internal fun delete() { + val songs = getSelectedSongs() + + Util.toast( + context, + resources.getQuantityString( + R.plurals.select_album_n_songs_deleted, songs.size, songs.size + ) + ) + + mediaPlayerController.delete(songs) + } + + internal fun unpin() { + val songs = getSelectedSongs() + Util.toast( + context, + resources.getQuantityString( + R.plurals.select_album_n_songs_unpinned, songs.size, songs.size + ) + ) + mediaPlayerController.unpin(songs) + } + + override val defaultObserver: (List) -> Unit = { + + val entryList: MutableList = it.toMutableList() + + if (listModel.currentListIsSortable && Settings.shouldSortByDisc) { + Collections.sort(entryList, EntryByDiscAndTrackComparator()) + } + + var allVideos = true + var songCount = 0 + + for (entry in entryList) { + if (!entry.isVideo) { + allVideos = false + } + if (!entry.isDirectory) { + songCount++ + } + } + + val listSize = arguments?.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0) ?: 0 + + // Hide select button for video lists and singular selection lists + selectButton!!.isVisible = !allVideos && viewAdapter.hasMultipleSelection() && songCount > 0 + + if (songCount > 0) { + if (listSize == 0 || songCount < listSize) { + moreButton!!.visibility = View.GONE + } else { + moreButton!!.visibility = View.VISIBLE + if (arguments?.getInt(Constants.INTENT_RANDOM, 0) ?: 0 > 0) { + moreRandomTracks() + } else if (arguments?.getString(Constants.INTENT_GENRE_NAME, "") ?: "" != "") { + moreSongsForGenre() + } + } + } + + // Show a text if we have no entries + emptyView.isVisible = entryList.isEmpty() + + enableButtons() + + val isAlbumList = arguments?.containsKey( + Constants.INTENT_ALBUM_LIST_TYPE + ) ?: false + + playAllButtonVisible = !(isAlbumList || entryList.isEmpty()) && !allVideos + shareButtonVisible = !isOffline() && songCount > 0 + + playAllButton?.isVisible = playAllButtonVisible + shareButton?.isVisible = shareButtonVisible + + if (songCount > 0 && listModel.showHeader) { + val intentAlbumName = arguments?.getString(Constants.INTENT_NAME, "") + val albumHeader = AlbumHeader(it, intentAlbumName) + val mixedList: MutableList = mutableListOf(albumHeader) + mixedList.addAll(entryList) + viewAdapter.submitList(mixedList) + } else { + viewAdapter.submitList(entryList) + } + + val playAll = arguments?.getBoolean(Constants.INTENT_AUTOPLAY, false) ?: false + + if (playAll && songCount > 0) { + playAll( + arguments?.getBoolean(Constants.INTENT_SHUFFLE, false) ?: false, + false + ) + } + + listModel.currentListIsSortable = true + } + + private fun moreSongsForGenre(args: Bundle = requireArguments()) { + moreButton!!.setOnClickListener { + val theGenre = args.getString(Constants.INTENT_GENRE_NAME) + val size = args.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0) + val theOffset = args.getInt( + Constants.INTENT_ALBUM_LIST_OFFSET, 0 + ) + size + val bundle = Bundle() + bundle.putString(Constants.INTENT_GENRE_NAME, theGenre) + bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, size) + bundle.putInt(Constants.INTENT_ALBUM_LIST_OFFSET, theOffset) + + Navigation.findNavController(requireView()) + .navigate(R.id.trackCollectionFragment, bundle) + } + } + + private fun moreRandomTracks() { + val listSize = arguments?.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0) ?: 0 + + moreButton!!.setOnClickListener { + val offset = requireArguments().getInt( + Constants.INTENT_ALBUM_LIST_OFFSET, 0 + ) + listSize + val bundle = Bundle() + bundle.putInt(Constants.INTENT_RANDOM, 1) + bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, listSize) + bundle.putInt(Constants.INTENT_ALBUM_LIST_OFFSET, offset) + Navigation.findNavController(requireView()).navigate( + R.id.trackCollectionFragment, bundle + ) + } + } + + internal fun getSelectedSongs(): List { + // Walk through selected set and get the Entries based on the saved ids. + return viewAdapter.getCurrentList().mapNotNull { + if (it is MusicDirectory.Entry && viewAdapter.isSelected(it.longId)) + it + else + null + } + } + + override fun setTitle(title: String?) { + setTitle(this@TrackCollectionFragment, title) + } + + fun setTitle(id: Int) { + setTitle(this@TrackCollectionFragment, id) + } + + @Suppress("LongMethod") + override fun getLiveData( + args: Bundle?, + refresh: Boolean + ): LiveData> { + if (args == null) return listModel.currentList + val id = args.getString(Constants.INTENT_ID) + val isAlbum = args.getBoolean(Constants.INTENT_IS_ALBUM, false) + val name = args.getString(Constants.INTENT_NAME) + val playlistId = args.getString(Constants.INTENT_PLAYLIST_ID) + val podcastChannelId = args.getString(Constants.INTENT_PODCAST_CHANNEL_ID) + val playlistName = args.getString(Constants.INTENT_PLAYLIST_NAME) + val shareId = args.getString(Constants.INTENT_SHARE_ID) + val shareName = args.getString(Constants.INTENT_SHARE_NAME) + val genreName = args.getString(Constants.INTENT_GENRE_NAME) + + val getStarredTracks = args.getInt(Constants.INTENT_STARRED, 0) + val getVideos = args.getInt(Constants.INTENT_VIDEOS, 0) + val getRandomTracks = args.getInt(Constants.INTENT_RANDOM, 0) + val albumListSize = args.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0) + val albumListOffset = args.getInt(Constants.INTENT_ALBUM_LIST_OFFSET, 0) + val refresh2 = args.getBoolean(Constants.INTENT_REFRESH, true) || refresh + + listModel.viewModelScope.launch(handler) { + refreshListView?.isRefreshing = true + + if (playlistId != null) { + setTitle(playlistName!!) + listModel.getPlaylist(playlistId, playlistName) + } else if (podcastChannelId != null) { + setTitle(getString(R.string.podcasts_label)) + listModel.getPodcastEpisodes(podcastChannelId) + } else if (shareId != null) { + setTitle(shareName) + listModel.getShare(shareId) + } else if (genreName != null) { + setTitle(genreName) + listModel.getSongsForGenre(genreName, albumListSize, albumListOffset) + } else if (getStarredTracks != 0) { + setTitle(getString(R.string.main_songs_starred)) + listModel.getStarred() + } else if (getVideos != 0) { + setTitle(R.string.main_videos) + listModel.getVideos(refresh2) + } else if (getRandomTracks != 0) { + setTitle(R.string.main_songs_random) + listModel.getRandom(albumListSize) + } else { + setTitle(name) + if (!isOffline() && Settings.shouldUseId3Tags) { + if (isAlbum) { + listModel.getAlbum(refresh2, id!!, name) + } else { + throw IllegalAccessException("Use AlbumFragment instead!") + } + } else { + listModel.getMusicDirectory(refresh2, id!!, name) + } + } + + refreshListView?.isRefreshing = false + } + return listModel.currentList + } + + @Suppress("LongMethod") + override fun onContextMenuItemSelected( + menuItem: MenuItem, + item: MusicDirectory.Child + ): Boolean { + val entryId = item.id when (menuItem.itemId) { R.id.menu_play_now -> { @@ -372,13 +669,12 @@ class TrackCollectionFragment : Fragment() { playAll() } R.id.menu_item_share -> { - val entries: MutableList = ArrayList(1) - entries.add(entry) - shareHandler.createShare( - this, entries, refreshAlbumListView, - cancellationToken!! - ) - return true + if (item is MusicDirectory.Entry) { + shareHandler.createShare( + this, listOf(item), refreshListView, + cancellationToken!! + ) + } } else -> { return super.onContextItemSelected(menuItem) @@ -387,451 +683,25 @@ class TrackCollectionFragment : Fragment() { return true } - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - playAllButton = menu.findItem(R.id.select_album_play_all) - - if (playAllButton != null) { - playAllButton!!.isVisible = playAllButtonVisible - } - - shareButton = menu.findItem(R.id.menu_item_share) - - if (shareButton != null) { - shareButton!!.isVisible = shareButtonVisible - } - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.select_album, menu) - super.onCreateOptionsMenu(menu, inflater) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val itemId = item.itemId - if (itemId == R.id.select_album_play_all) { - playAll() - return true - } else if (itemId == R.id.menu_item_share) { - shareHandler.createShare( - this, getSelectedSongs(albumListView), - refreshAlbumListView, cancellationToken!! - ) - return true - } - - return false - } - - override fun onDestroyView() { - cancellationToken!!.cancel() - super.onDestroyView() - } - - private fun playNow(append: Boolean) { - val selectedSongs = getSelectedSongs(albumListView) - - if (selectedSongs.isNotEmpty()) { - downloadHandler.download( - this, append, false, !append, playNext = false, - shuffle = false, songs = selectedSongs - ) - selectAll(selected = false, toast = false) - } else { - playAll(false, append) - } - } - - private fun playAll(shuffle: Boolean = false, append: Boolean = false) { - var hasSubFolders = false - - for (i in 0 until albumListView!!.count) { - val entry = albumListView!!.getItemAtPosition(i) as MusicDirectory.Entry? - if (entry != null && entry.isDirectory) { - hasSubFolders = true - break - } - } - - val isArtist = requireArguments().getBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, false) - val id = requireArguments().getString(Constants.INTENT_EXTRA_NAME_ID) - - if (hasSubFolders && id != null) { - downloadHandler.downloadRecursively( - this, id, false, append, !append, - shuffle, background = false, playNext = false, unpin = false, isArtist = isArtist - ) - } else { - selectAll(selected = true, toast = false) - downloadHandler.download( - this, append, false, !append, false, - shuffle, getSelectedSongs(albumListView) - ) - selectAll(selected = false, toast = false) - } - } - - private fun selectAllOrNone() { - var someUnselected = false - val count = albumListView!!.count - - for (i in 0 until count) { - if (!albumListView!!.isItemChecked(i) && - albumListView!!.getItemAtPosition(i) is MusicDirectory.Entry - ) { - someUnselected = true - break - } - } - selectAll(someUnselected, true) - } - - private fun selectAll(selected: Boolean, toast: Boolean) { - val count = albumListView!!.count - var selectedCount = 0 - - for (i in 0 until count) { - val entry = albumListView!!.getItemAtPosition(i) as MusicDirectory.Entry? - if (entry != null && !entry.isDirectory && !entry.isVideo) { - albumListView!!.setItemChecked(i, selected) - selectedCount++ - } - } - - // Display toast: N tracks selected / N tracks unselected - if (toast) { - val toastResId = if (selected) - R.string.select_album_n_selected - else - R.string.select_album_n_unselected - Util.toast(activity, getString(toastResId, selectedCount)) - } - enableButtons() - } - - private fun enableButtons() { - val selection = getSelectedSongs(albumListView) - val enabled = selection.isNotEmpty() - var unpinEnabled = false - var deleteEnabled = false - - var pinnedCount = 0 - - for (song in selection) { - if (song == null) continue - val downloadFile = mediaPlayerController.getDownloadFileForSong(song) - if (downloadFile.isWorkDone) { - deleteEnabled = true - } - if (downloadFile.isSaved) { - pinnedCount++ - unpinEnabled = true - } - } - - playNowButton!!.visibility = if (enabled) View.VISIBLE else View.GONE - playNextButton!!.visibility = if (enabled) View.VISIBLE else View.GONE - playLastButton!!.visibility = if (enabled) View.VISIBLE else View.GONE - pinButton!!.visibility = if (enabled && !isOffline() && selection.size > pinnedCount) - View.VISIBLE - else - View.GONE - unpinButton!!.visibility = if (enabled && unpinEnabled) View.VISIBLE else View.GONE - downloadButton!!.visibility = if (enabled && !deleteEnabled && !isOffline()) - View.VISIBLE - else - View.GONE - deleteButton!!.visibility = if (enabled && deleteEnabled) View.VISIBLE else View.GONE - } - - private fun downloadBackground(save: Boolean) { - var songs = getSelectedSongs(albumListView) - - if (songs.isEmpty()) { - selectAll(selected = true, toast = false) - songs = getSelectedSongs(albumListView) - } - - downloadBackground(save, songs) - } - - private fun downloadBackground(save: Boolean, songs: List) { - val onValid = Runnable { - networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - mediaPlayerController.downloadBackground(songs, save) - - if (save) { - Util.toast( - context, - resources.getQuantityString( - R.plurals.select_album_n_songs_pinned, songs.size, songs.size - ) - ) - } else { - Util.toast( - context, - resources.getQuantityString( - R.plurals.select_album_n_songs_downloaded, songs.size, songs.size - ) + override fun onItemClick(item: MusicDirectory.Child) { + when { + item.isDirectory -> { + val bundle = 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) + Navigation.findNavController(requireView()).navigate( + R.id.trackCollectionFragment, + bundle ) } - } - onValid.run() - } - - private fun delete() { - var songs = getSelectedSongs(albumListView) - - if (songs.isEmpty()) { - selectAll(selected = true, toast = false) - songs = getSelectedSongs(albumListView) - } - - mediaPlayerController.delete(songs) - } - - private fun unpin() { - val songs = getSelectedSongs(albumListView) - Util.toast( - context, - resources.getQuantityString( - R.plurals.select_album_n_songs_unpinned, songs.size, songs.size - ) - ) - mediaPlayerController.unpin(songs) - } - - private val songsForGenreObserver = Observer { musicDirectory -> - - // Hide more button when results are less than album list size - if (musicDirectory.getChildren().size < requireArguments().getInt( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0 - ) - ) { - moreButton!!.visibility = View.GONE - } else { - moreButton!!.visibility = View.VISIBLE - } - - moreButton!!.setOnClickListener { - val theGenre = requireArguments().getString(Constants.INTENT_EXTRA_NAME_GENRE_NAME) - val size = requireArguments().getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0) - val theOffset = requireArguments().getInt( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0 - ) + size - val bundle = Bundle() - bundle.putString(Constants.INTENT_EXTRA_NAME_GENRE_NAME, theGenre) - bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, size) - bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, theOffset) - - Navigation.findNavController(requireView()) - .navigate(R.id.trackCollectionFragment, bundle) - } - - updateInterfaceWithEntries(musicDirectory) - } - - private val defaultObserver = Observer(this::updateInterfaceWithEntries) - - private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory) { - val entries = musicDirectory.getChildren() - - if (model.currentListIsSortable && Settings.shouldSortByDisc) { - Collections.sort(entries, EntryByDiscAndTrackComparator()) - } - - var allVideos = true - var songCount = 0 - - for (entry in entries) { - if (!entry.isVideo) { - allVideos = false + item is MusicDirectory.Entry && item.isVideo -> { + VideoPlayer.playVideo(requireContext(), item) } - if (!entry.isDirectory) { - songCount++ + else -> { + enableButtons() } } - - val listSize = requireArguments().getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0) - - if (songCount > 0) { - if (model.showHeader) { - val intentAlbumName = requireArguments().getString(Constants.INTENT_EXTRA_NAME_NAME) - val directoryName = musicDirectory.name - val header = createHeader( - entries, intentAlbumName ?: directoryName, - songCount - ) - if (header != null && albumListView!!.headerViewsCount == 0) { - albumListView!!.addHeaderView(header, null, false) - } - } - - pinButton!!.visibility = View.VISIBLE - unpinButton!!.visibility = View.VISIBLE - downloadButton!!.visibility = View.VISIBLE - deleteButton!!.visibility = View.VISIBLE - selectButton!!.visibility = if (allVideos) View.GONE else View.VISIBLE - playNowButton!!.visibility = View.VISIBLE - playNextButton!!.visibility = View.VISIBLE - playLastButton!!.visibility = View.VISIBLE - - if (listSize == 0 || songCount < listSize) { - moreButton!!.visibility = View.GONE - } else { - moreButton!!.visibility = View.VISIBLE - if (requireArguments().getInt(Constants.INTENT_EXTRA_NAME_RANDOM, 0) > 0) { - moreButton!!.setOnClickListener { - val offset = requireArguments().getInt( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0 - ) + listSize - val bundle = Bundle() - bundle.putInt(Constants.INTENT_EXTRA_NAME_RANDOM, 1) - bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, listSize) - bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, offset) - Navigation.findNavController(requireView()).navigate( - R.id.trackCollectionFragment, bundle - ) - } - } - } - } else { - - // TODO: This code path can be removed when getArtist has been moved to - // AlbumListFragment (getArtist returns the albums of an artist) - pinButton!!.visibility = View.GONE - unpinButton!!.visibility = View.GONE - downloadButton!!.visibility = View.GONE - deleteButton!!.visibility = View.GONE - selectButton!!.visibility = View.GONE - playNowButton!!.visibility = View.GONE - playNextButton!!.visibility = View.GONE - playLastButton!!.visibility = View.GONE - - if (listSize == 0 || musicDirectory.getChildren().size < listSize) { - albumButtons!!.visibility = View.GONE - } else { - moreButton!!.visibility = View.VISIBLE - } - } - - enableButtons() - - val isAlbumList = requireArguments().containsKey( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE - ) - - playAllButtonVisible = !(isAlbumList || entries.isEmpty()) && !allVideos - shareButtonVisible = !isOffline() && songCount > 0 - - albumListView!!.removeHeaderView(emptyView!!) - if (entries.isEmpty()) { - emptyView!!.text = getString(R.string.select_album_empty) - emptyView!!.setPadding(10, 10, 10, 10) - albumListView!!.addHeaderView(emptyView, null, false) - } - - if (playAllButton != null) { - playAllButton!!.isVisible = playAllButtonVisible - } - - if (shareButton != null) { - shareButton!!.isVisible = shareButtonVisible - } - - albumListView!!.adapter = EntryAdapter( - context, - imageLoaderProvider.getImageLoader(), entries, true - ) - - val playAll = requireArguments().getBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false) - if (playAll && songCount > 0) { - playAll( - requireArguments().getBoolean(Constants.INTENT_EXTRA_NAME_SHUFFLE, false), - false - ) - } - - model.currentListIsSortable = true - } - - private fun createHeader( - entries: List, - name: CharSequence?, - songCount: Int - ): View? { - val coverArtView = header!!.findViewById(R.id.select_album_art) as ImageView - val artworkSelection = random.nextInt(entries.size) - imageLoaderProvider.getImageLoader().loadImage( - coverArtView, entries[artworkSelection], false, - Util.getAlbumImageSize(context) - ) - - val albumHeader = AlbumHeader.processEntries(context, entries) - - val titleView = header!!.findViewById(R.id.select_album_title) as TextView - titleView.text = name ?: getTitle(this@TrackCollectionFragment) // getActionBarSubtitle()); - - // Don't show a header if all entries are videos - if (albumHeader.isAllVideo) { - return null - } - - val artistView = header!!.findViewById(R.id.select_album_artist) - - val artist: String = when { - albumHeader.artists.size == 1 -> albumHeader.artists.iterator().next() - albumHeader.grandParents.size == 1 -> albumHeader.grandParents.iterator().next() - else -> resources.getString(R.string.common_various_artists) - } - - artistView.text = artist - - val genreView = header!!.findViewById(R.id.select_album_genre) - - val genre: String = if (albumHeader.genres.size == 1) - albumHeader.genres.iterator().next() - else - resources.getString(R.string.common_multiple_genres) - - genreView.text = genre - - val yearView = header!!.findViewById(R.id.select_album_year) - - val year: String = if (albumHeader.years.size == 1) - albumHeader.years.iterator().next().toString() - else - resources.getString(R.string.common_multiple_years) - - yearView.text = year - - val songCountView = header!!.findViewById(R.id.select_album_song_count) - val songs = resources.getQuantityString( - R.plurals.select_album_n_songs, songCount, - songCount - ) - songCountView.text = songs - - val duration = Util.formatTotalDuration(albumHeader.totalDuration) - - val durationView = header!!.findViewById(R.id.select_album_duration) - durationView.text = duration - - return header - } - - private fun getSelectedSongs(albumListView: ListView?): List { - val songs: MutableList = ArrayList(10) - - if (albumListView != null) { - val count = albumListView.count - for (i in 0 until count) { - if (albumListView.isItemChecked(i)) { - songs.add(albumListView.getItemAtPosition(i) as MusicDirectory.Entry?) - } - } - } - - return songs } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt deleted file mode 100644 index ab5ce052..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt +++ /dev/null @@ -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 = MutableLiveData() - val songsForGenre: MutableLiveData = 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 = 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 - ) { - 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 = 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 - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt index 35690da6..41be0182 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt similarity index 56% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt index a9216173..5fd2e86d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt @@ -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> = MutableLiveData(listOf()) + val list: MutableLiveData> = 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> { + ): LiveData> { // 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 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() - list.addAll(albumList.value!!) - list.addAll(musicDirectory.getAllChild()) - albumList.postValue(list) + if (append && list.value != null) { + val newList = ArrayList() + 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()) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ArtistListModel.kt similarity index 92% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ArtistListModel.kt index e87477b2..6c2de732 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ArtistListModel.kt @@ -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> = MutableLiveData(listOf()) + private val artists: MutableLiveData> = 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> { // 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 = compareBy(Collator.getInstance()) { t -> t.name } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt similarity index 87% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt index 5ec1db0e..2d4b4aea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt @@ -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> = MutableLiveData(listOf()) - internal val musicFolders: MutableLiveData> = 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> { - return musicFolders - } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt new file mode 100644 index 00000000..252c48cb --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt @@ -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 = 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) + ) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSettingsModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt similarity index 99% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSettingsModel.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt index 65c2c6e6..2f520617 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSettingsModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt @@ -1,4 +1,4 @@ -package org.moire.ultrasonic.fragment +package org.moire.ultrasonic.model import android.app.Application import android.content.SharedPreferences diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt new file mode 100644 index 00000000..8fdd1202 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt @@ -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> = 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()) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index 173c5806..02f897fc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -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) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt index d7753662..a0e79d73 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt @@ -41,7 +41,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService, // Old style TimeLimitedCache private val cachedMusicDirectories: LRUCache> - private val cachedArtist: LRUCache> + private val cachedArtist: LRUCache>> private val cachedAlbum: LRUCache> private val cachedUserInfo: LRUCache> private val cachedLicenseValid = TimeLimitedCache(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 { + 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 { 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 { 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? = musicService.getBookmarks() + override fun getBookmarks(): List = 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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt index cec735a1..cb290c3b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt @@ -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 = MutableLiveData(0) - val status: MutableLiveData = MutableLiveData(DownloadStatus.IDLE) + val status: MutableLiveData 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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt index b5e221e2..52b304c8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -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() + // 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, + songs: List, 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, 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 + } + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index e3322213..55bffc4c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -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() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index d72d65e1..1f285916 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -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) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt index 1a086d73..410558b8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt @@ -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 @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 @Throws(Exception::class) fun getAlbumList2( @@ -97,7 +103,7 @@ interface MusicService { size: Int, offset: Int, musicFolderId: String? - ): MusicDirectory + ): List @Throws(Exception::class) fun getRandomSongs(size: Int): MusicDirectory @@ -154,7 +160,7 @@ interface MusicService { fun addChatMessage(message: String) @Throws(Exception::class) - fun getBookmarks(): List? + fun getBookmarks(): List @Throws(Exception::class) fun deleteBookmark(id: String) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt index 6ee13280..37ee1247 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -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 = ArrayList() - val albums: MutableList = ArrayList() + val artists: MutableList = ArrayList() + val albums: MutableList = ArrayList() val songs: MutableList = 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 { throw OfflineException("Album lists not available in offline mode") } + @Throws(OfflineException::class) + override fun getAlbumList2( + type: String, + size: Int, + offset: Int, + musicFolderId: String? + ): List { + throw OfflineException("getAlbumList2 isn't available in offline mode") + } + @Throws(Exception::class) override fun updateJukeboxPlaylist(ids: List?): 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? { + override fun getBookmarks(): List { 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 { + 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, - songs: MutableList + 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, + songs: MutableList + ) { + 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) { - 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) { + 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(" ") + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt index 3121fbe6..74c7c3ea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt @@ -143,10 +143,10 @@ open class RESTMusicService( id: String, name: String?, refresh: Boolean - ): MusicDirectory { + ): List { 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 { 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 { 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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt index eeca3ffc..f5686372 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -30,6 +30,11 @@ class RxBus { val themeChangedEventObservable: Observable = themeChangedEventPublisher.observeOn(AndroidSchedulers.mainThread()) + val musicFolderChangedEventPublisher: PublishSubject = + PublishSubject.create() + val musicFolderChangedEventObservable: Observable = + musicFolderChangedEventPublisher.observeOn(AndroidSchedulers.mainThread()) + val playerStatePublisher: PublishSubject = PublishSubject.create() val playerStateObservable: Observable = diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt index 1d14d2fb..807ae3b1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -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) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt index 13f9922f..227b2b94 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt @@ -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 { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/BoundedTreeSet.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/BoundedTreeSet.kt new file mode 100644 index 00000000..91bb0b22 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/BoundedTreeSet.kt @@ -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 : TreeSet { + private var maxSize = Int.MAX_VALUE + + constructor(maxSize: Int) : super() { + setMaxSize(maxSize) + } + + constructor(maxSize: Int, c: Collection?) : super(c) { + setMaxSize(maxSize) + } + + constructor(maxSize: Int, c: Comparator?) : super(c) { + setMaxSize(maxSize) + } + + constructor(maxSize: Int, s: SortedSet?) : 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): Boolean { + val out = super.addAll(elements) + adjust() + return out + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt index 31744967..5e9cb2db 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt @@ -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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/EntryByDiscAndTrackComparator.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/EntryByDiscAndTrackComparator.kt new file mode 100644 index 00000000..ec3ef5cd --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/EntryByDiscAndTrackComparator.kt @@ -0,0 +1,50 @@ +package org.moire.ultrasonic.util + +import java.util.Comparator +import org.moire.ultrasonic.domain.MusicDirectory + +class EntryByDiscAndTrackComparator : Comparator { + 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) + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt index bd9cf7cb..a3305077 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -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)) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index f42978b2..7a6592ac 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -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): MusicDirectory { + fun getSongsFromBookmarks(bookmarks: Iterable): 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( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SelectMusicFolderView.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SelectMusicFolderView.kt deleted file mode 100644 index 3dcec9a4..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SelectMusicFolderView.kt +++ /dev/null @@ -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 = 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) { - 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 - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongView.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongView.kt deleted file mode 100644 index 651926fe..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongView.kt +++ /dev/null @@ -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 . - - 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) - } - } -} diff --git a/ultrasonic/src/main/res/drawable/media_play_next_dark.xml b/ultrasonic/src/main/res/drawable/ic_baseline_error_dark.xml similarity index 52% rename from ultrasonic/src/main/res/drawable/media_play_next_dark.xml rename to ultrasonic/src/main/res/drawable/ic_baseline_error_dark.xml index b7d80429..4c9185e3 100644 --- a/ultrasonic/src/main/res/drawable/media_play_next_dark.xml +++ b/ultrasonic/src/main/res/drawable/ic_baseline_error_dark.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> + 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"/> diff --git a/ultrasonic/src/main/res/drawable/media_play_next_light.xml b/ultrasonic/src/main/res/drawable/ic_baseline_error_light.xml similarity index 63% rename from ultrasonic/src/main/res/drawable/media_play_next_light.xml rename to ultrasonic/src/main/res/drawable/ic_baseline_error_light.xml index 3e7cedf9..2f22d456 100644 --- a/ultrasonic/src/main/res/drawable/media_play_next_light.xml +++ b/ultrasonic/src/main/res/drawable/ic_baseline_error_light.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + 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"/> diff --git a/ultrasonic/src/main/res/drawable/ic_drag_vertical.xml b/ultrasonic/src/main/res/drawable/ic_drag_vertical.xml new file mode 100644 index 00000000..5aa2d20c --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_drag_vertical.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/drawable/ic_drag_vertical_dark.xml b/ultrasonic/src/main/res/drawable/ic_drag_vertical_dark.xml deleted file mode 100644 index 8dcc63e5..00000000 --- a/ultrasonic/src/main/res/drawable/ic_drag_vertical_dark.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/drawable/ic_drag_vertical_light.xml b/ultrasonic/src/main/res/drawable/ic_drag_vertical_light.xml deleted file mode 100644 index 7fdf1d55..00000000 --- a/ultrasonic/src/main/res/drawable/ic_drag_vertical_light.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/drawable/ic_empty.xml b/ultrasonic/src/main/res/drawable/ic_empty.xml new file mode 100644 index 00000000..74776517 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_empty.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ultrasonic/src/main/res/drawable/ic_menu_add_to_queue_dark.xml b/ultrasonic/src/main/res/drawable/ic_menu_add_to_queue_dark.xml deleted file mode 100644 index 157454a8..00000000 --- a/ultrasonic/src/main/res/drawable/ic_menu_add_to_queue_dark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ultrasonic/src/main/res/drawable/ic_menu_add_to_queue_light.xml b/ultrasonic/src/main/res/drawable/ic_menu_add_to_queue_light.xml deleted file mode 100644 index d330ca57..00000000 --- a/ultrasonic/src/main/res/drawable/ic_menu_add_to_queue_light.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ultrasonic/src/main/res/drawable/ic_play_last.xml b/ultrasonic/src/main/res/drawable/ic_play_last.xml new file mode 100644 index 00000000..156f3ce6 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_play_last.xml @@ -0,0 +1,10 @@ + + + diff --git a/ultrasonic/src/main/res/drawable/ic_play_next.xml b/ultrasonic/src/main/res/drawable/ic_play_next.xml new file mode 100644 index 00000000..cdd599eb --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_play_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/ultrasonic/src/main/res/layout/album_buttons.xml b/ultrasonic/src/main/res/layout/album_buttons.xml index 323ddd81..73acfcd0 100644 --- a/ultrasonic/src/main/res/layout/album_buttons.xml +++ b/ultrasonic/src/main/res/layout/album_buttons.xml @@ -13,7 +13,8 @@ android:scaleType="fitCenter" android:layout_weight="1" android:src="?attr/select_all" - android:visibility="gone" /> + android:visibility="gone" + android:contentDescription="@string/common.select_all" /> + android:visibility="gone" + android:contentDescription="@string/common.play_now" /> + android:src="@drawable/ic_play_next" + android:visibility="gone" + android:contentDescription="@string/common.play_next" /> + android:src="@drawable/ic_play_last" + android:visibility="gone" + android:contentDescription="@string/common.play_last" /> + android:visibility="gone" + android:contentDescription="@string/common.pin" /> + android:visibility="gone" + android:contentDescription="@string/common.unpin" /> + android:visibility="gone" + android:contentDescription="@string/common.download" /> + android:visibility="gone" + android:contentDescription="@string/common.delete" /> + android:visibility="gone" + android:contentDescription="@string/search.more" /> \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/album_list_item_legacy.xml b/ultrasonic/src/main/res/layout/album_list_item_legacy.xml deleted file mode 100644 index f8e244a2..00000000 --- a/ultrasonic/src/main/res/layout/album_list_item_legacy.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/ultrasonic/src/main/res/layout/current_playlist.xml b/ultrasonic/src/main/res/layout/current_playlist.xml index fcee1849..bad74a52 100644 --- a/ultrasonic/src/main/res/layout/current_playlist.xml +++ b/ultrasonic/src/main/res/layout/current_playlist.xml @@ -1,32 +1,34 @@ - - + a:layout_height="fill_parent" + a:orientation="vertical"> + a:padding="10dip" + a:text="@string/playlist.empty" /> - + a:clipToPadding="false" + a:paddingTop="8dp" + a:paddingBottom="8dp" + app:fastScrollAutoHide="true" + app:fastScrollAutoHideDelay="2000" + app:fastScrollPopupBackgroundSize="42dp" + app:fastScrollPopupBgColor="@color/cyan" + app:fastScrollPopupPosition="adjacent" + app:fastScrollPopupTextColor="@android:color/primary_text_dark" + app:fastScrollPopupTextSize="28sp" + app:fastScrollThumbColor="@color/cyan" + app:fastScrollTrackColor="@color/dividerColor" /> \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/select_album_header.xml b/ultrasonic/src/main/res/layout/list_header_album.xml similarity index 100% rename from ultrasonic/src/main/res/layout/select_album_header.xml rename to ultrasonic/src/main/res/layout/list_header_album.xml diff --git a/ultrasonic/src/main/res/layout/select_folder_header.xml b/ultrasonic/src/main/res/layout/list_header_folder.xml similarity index 100% rename from ultrasonic/src/main/res/layout/select_folder_header.xml rename to ultrasonic/src/main/res/layout/list_header_folder.xml diff --git a/ultrasonic/src/main/res/layout/album_list_item.xml b/ultrasonic/src/main/res/layout/list_item_album.xml similarity index 90% rename from ultrasonic/src/main/res/layout/album_list_item.xml rename to ultrasonic/src/main/res/layout/list_item_album.xml index e3e971d1..d3bee2ba 100644 --- a/ultrasonic/src/main/res/layout/album_list_item.xml +++ b/ultrasonic/src/main/res/layout/list_item_album.xml @@ -2,7 +2,7 @@ + tools:src="@drawable/ic_star_hollow_dark" + a:contentDescription="@string/download.menu_star" /> diff --git a/ultrasonic/src/main/res/layout/artist_list_item.xml b/ultrasonic/src/main/res/layout/list_item_artist.xml similarity index 86% rename from ultrasonic/src/main/res/layout/artist_list_item.xml rename to ultrasonic/src/main/res/layout/list_item_artist.xml index 527523c8..84782315 100644 --- a/ultrasonic/src/main/res/layout/artist_list_item.xml +++ b/ultrasonic/src/main/res/layout/list_item_artist.xml @@ -1,9 +1,10 @@ @@ -17,17 +18,17 @@ a:minHeight="56dip" a:paddingStart="8dip" a:paddingEnd="8dip" - a:text="A" a:textAppearance="?android:attr/textAppearanceLarge" - a:textColor="@color/cyan" /> + a:textColor="@color/cyan" + tools:text="A" /> + \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/list_item_divider.xml b/ultrasonic/src/main/res/layout/list_item_divider.xml new file mode 100644 index 00000000..6f931fa2 --- /dev/null +++ b/ultrasonic/src/main/res/layout/list_item_divider.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/ultrasonic/src/main/res/layout/generic_text_list_item.xml b/ultrasonic/src/main/res/layout/list_item_generic.xml similarity index 100% rename from ultrasonic/src/main/res/layout/generic_text_list_item.xml rename to ultrasonic/src/main/res/layout/list_item_generic.xml diff --git a/ultrasonic/src/main/res/layout/list_item_more_button.xml b/ultrasonic/src/main/res/layout/list_item_more_button.xml new file mode 100644 index 00000000..8d9b886c --- /dev/null +++ b/ultrasonic/src/main/res/layout/list_item_more_button.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/song_list_item.xml b/ultrasonic/src/main/res/layout/list_item_track.xml similarity index 86% rename from ultrasonic/src/main/res/layout/song_list_item.xml rename to ultrasonic/src/main/res/layout/list_item_track.xml index 820bc3bb..16322ac7 100644 --- a/ultrasonic/src/main/res/layout/song_list_item.xml +++ b/ultrasonic/src/main/res/layout/list_item_track.xml @@ -10,12 +10,13 @@ a:id="@+id/song_drag" a:layout_width="wrap_content" a:layout_height="fill_parent" - a:paddingStart="5dip" - a:paddingEnd="0dip" a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" - a:src="?attr/drag_vertical" /> + a:importantForAccessibility="no" + a:paddingStart="5dip" + a:paddingEnd="6dip" + a:src="@drawable/ic_drag_vertical" /> + a:paddingEnd="4dip" /> - + @@ -52,6 +54,7 @@ a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" a:scaleType="centerInside" a:src="?attr/star_hollow" /> @@ -62,6 +65,7 @@ a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" a:scaleType="centerInside" a:src="?attr/star_hollow" /> @@ -72,6 +76,7 @@ a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" a:scaleType="centerInside" a:src="?attr/star_hollow" /> @@ -83,6 +88,7 @@ a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" a:scaleType="centerInside" a:src="?attr/star_hollow" /> @@ -93,6 +99,7 @@ a:layout_width="38dp" a:layout_height="fill_parent" a:background="@android:color/transparent" + a:contentDescription="@string/download.menu_star" a:focusable="false" a:gravity="center_vertical" a:paddingEnd="8dip" diff --git a/ultrasonic/src/main/res/layout/list_item_track_details.xml b/ultrasonic/src/main/res/layout/list_item_track_details.xml new file mode 100644 index 00000000..569a64f7 --- /dev/null +++ b/ultrasonic/src/main/res/layout/list_item_track_details.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/list_layout_generic.xml b/ultrasonic/src/main/res/layout/list_layout_generic.xml new file mode 100644 index 00000000..e4793745 --- /dev/null +++ b/ultrasonic/src/main/res/layout/list_layout_generic.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ultrasonic/src/main/res/layout/list_layout_track.xml b/ultrasonic/src/main/res/layout/list_layout_track.xml new file mode 100644 index 00000000..117592a2 --- /dev/null +++ b/ultrasonic/src/main/res/layout/list_layout_track.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/ultrasonic/src/main/res/layout/list_parts_empty_view.xml b/ultrasonic/src/main/res/layout/list_parts_empty_view.xml new file mode 100644 index 00000000..b6e8bcce --- /dev/null +++ b/ultrasonic/src/main/res/layout/list_parts_empty_view.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/generic_list.xml b/ultrasonic/src/main/res/layout/list_parts_recycler.xml similarity index 75% rename from ultrasonic/src/main/res/layout/generic_list.xml rename to ultrasonic/src/main/res/layout/list_parts_recycler.xml index 1cb9529d..7808a71c 100644 --- a/ultrasonic/src/main/res/layout/generic_list.xml +++ b/ultrasonic/src/main/res/layout/list_parts_recycler.xml @@ -1,18 +1,14 @@ - + - - + \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/search.xml b/ultrasonic/src/main/res/layout/search.xml index 5ef70eaa..513054b2 100644 --- a/ultrasonic/src/main/res/layout/search.xml +++ b/ultrasonic/src/main/res/layout/search.xml @@ -1,20 +1,23 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + a:layout_width="match_parent" + a:layout_height="match_parent" + a:orientation="vertical"> + + - + a:layout_weight="1.0" /> diff --git a/ultrasonic/src/main/res/layout/search_buttons.xml b/ultrasonic/src/main/res/layout/search_buttons.xml deleted file mode 100644 index 66b82755..00000000 --- a/ultrasonic/src/main/res/layout/search_buttons.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/ultrasonic/src/main/res/layout/select_album.xml b/ultrasonic/src/main/res/layout/select_album.xml deleted file mode 100644 index 662245b4..00000000 --- a/ultrasonic/src/main/res/layout/select_album.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/ultrasonic/src/main/res/layout/song_details.xml b/ultrasonic/src/main/res/layout/song_details.xml deleted file mode 100644 index 4de05f9d..00000000 --- a/ultrasonic/src/main/res/layout/song_details.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/video_details.xml b/ultrasonic/src/main/res/layout/video_details.xml deleted file mode 100644 index 2ea9473c..00000000 --- a/ultrasonic/src/main/res/layout/video_details.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/video_list_item.xml b/ultrasonic/src/main/res/layout/video_list_item.xml deleted file mode 100644 index 5851bbca..00000000 --- a/ultrasonic/src/main/res/layout/video_list_item.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/menu/artist_context_menu.xml b/ultrasonic/src/main/res/menu/context_menu_artist.xml similarity index 100% rename from ultrasonic/src/main/res/menu/artist_context_menu.xml rename to ultrasonic/src/main/res/menu/context_menu_artist.xml diff --git a/ultrasonic/src/main/res/menu/context_menu_track.xml b/ultrasonic/src/main/res/menu/context_menu_track.xml new file mode 100644 index 00000000..16eaff74 --- /dev/null +++ b/ultrasonic/src/main/res/menu/context_menu_track.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/menu/generic_context_menu.xml b/ultrasonic/src/main/res/menu/generic_context_menu.xml deleted file mode 100644 index 553bf63e..00000000 --- a/ultrasonic/src/main/res/menu/generic_context_menu.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/menu/select_song_context.xml b/ultrasonic/src/main/res/menu/select_song_context.xml deleted file mode 100644 index 3a534b25..00000000 --- a/ultrasonic/src/main/res/menu/select_song_context.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/navigation/navigation_graph.xml b/ultrasonic/src/main/res/navigation/navigation_graph.xml index 4f4bd844..90f978a6 100644 --- a/ultrasonic/src/main/res/navigation/navigation_graph.xml +++ b/ultrasonic/src/main/res/navigation/navigation_graph.xml @@ -25,19 +25,32 @@ android:name="org.moire.ultrasonic.fragment.ArtistListFragment" android:label="@string/music_library.label" > + + + + + Opravdu smazat %1$s Záložka odstraněna. Záložka vytvořena na %s. - Playlist je prázdný + Playlist je prázdný Vzdálené ovládání není povoleno. Povolte jukebox mód v Uživatelském > Nastavení na Subsonic serveru. Vzdálené ovládání vypnuto. Hudba je přehrávána na telefonu. Vzdálené ovládání není dostupné v offline módu. @@ -130,7 +130,6 @@ Hledat Média nenalezena %d skladeb označeno. - %d skladeb odznačeno. Varování: Připojení nedostupné. Chyba: SD karta nedostupná. Přehrát vše @@ -304,17 +303,6 @@ Obrázek umělce v seznamu umělců Zobrazí obrázek umělce v náhledu umělců pokud je dostupný Video - Obnovení náhledu - .5 sekundy - 1 sekunda - 1.5 sekundy - 2 sekundy - 2.5 sekundy - 3 sekundy - 3.5 sekundy - 4 sekundy - 4.5 sekundy - 5 sekund Streamovat media pouze přes Wi-Fi připojení Streamovat pouze přes Wi-Fi %1$s%2$s diff --git a/ultrasonic/src/main/res/values-de/strings.xml b/ultrasonic/src/main/res/values-de/strings.xml index 0182cf3e..878f130d 100644 --- a/ultrasonic/src/main/res/values-de/strings.xml +++ b/ultrasonic/src/main/res/values-de/strings.xml @@ -42,7 +42,7 @@ Möchtest du %1$s löschen Lesezeichen entfernt Lesezeichen gesetzt als %s. - Wiedergabeliste ist leer + Wiedergabeliste ist leer Fernbedienung ist nicht erlaubt. Bitte Jukebox Modus auf dem Subsonic Server in Benutzer > Einstellungen aktivieren. Fernbedienung ausgeschaltet. Musik wird auf dem Telefon wiedergegeben. Fernbedienungs-Modus is Offline nicht verfügbar. @@ -128,8 +128,7 @@ Titel Suche Keine Medien gefunden - %d Titel ausgewählt. - %d Titel abgewählt. + %d Titel ausgewählt Warnung: kein Netz. Fehler: Keine SD Karte verfügbar. Alles wiedergeben @@ -299,17 +298,6 @@ Durchsuchen von ID3-Tags Nutze ID3 Tag Methode anstatt Dateisystem-Methode Film - Aktualisierungsinterval - .5 Sekunden - 1 Sekunde - 1.5 Sekunden - 2 Sekunden - 2.5 Sekunden - 3 Sekunden - 3.5 Sekunden - 4 Sekunden - 4.5 Sekunden - 5 Sekunden Nur bei WLAN verbindung streamen Nur über WLAN streamen %1$s%2$s diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index d51c4d79..d0d77262 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -56,7 +56,7 @@ Quieres eliminar %1$s Marcador eliminado. Marcador añadido a %s. - La lista de reproducción esta vacía + La lista de reproducción esta vacía El control remoto no esta habilitado. Por favor habilita el modo jukebox en Configuración > Usuarios en tu servidor de Subsonic. Control remoto apagado. La música se reproduce en tu dispositivo. Control remoto no disponible en modo fuera de línea. @@ -145,7 +145,6 @@ Buscar No se han encontrado medios %d pista(s) seleccionada(s). - %d pista(s) deseleccionada(s). Atención: No hay red disponible. Error: No hay tarjeta SD disponible. Reproducir todo @@ -323,17 +322,6 @@ Mostrar la imagen del artista en la lista de artistas Muestra la imagen del artista en la lista de artistas si está disponible Vídeo - Refresco de la vista - .5 segundos - 1 segundo - 1.5 segundos - 2 segundos - 2.5 segundos - 3 segundos - 3.5 segundos - 4 segundos - 4.5 segundos - 5 segundos Solo trasmitir medios si esta conectado a la Wi-Fi Trasmitir solo por Wi-Fi %1$s%2$s diff --git a/ultrasonic/src/main/res/values-fr/strings.xml b/ultrasonic/src/main/res/values-fr/strings.xml index 392cef16..fa8c6a49 100644 --- a/ultrasonic/src/main/res/values-fr/strings.xml +++ b/ultrasonic/src/main/res/values-fr/strings.xml @@ -53,7 +53,7 @@ Voulez-vous supprimer %1$s Signet supprimé. Signet ajouté à %s. - La playlist est vide + La playlist est vide La télécommande n\'est pas autorisée. Veuillez activer le mode jukebox dans Utilisateurs > Paramètres à partir de votre serveur Subsonic. Mode jukebox désactivé. La musique est jouée sur l\'appareil. Le mode jukebox n\'est pas disponible en mode déconnecté. @@ -142,7 +142,6 @@ Recherche Aucun titre trouvé %d pistes sélectionnées. - %d pistes non sélectionnés. Avertissement : Aucun réseau disponible. Erreur : Aucune carte SD disponible. Tout jouer @@ -318,17 +317,6 @@ Afficher l’image de l’artiste dans la liste Affiche l’image de l’artiste dans la liste des artistes si celle-ci est disponible Vidéo - Actualisation de la vue - 0,5 secondes - 1 seconde - 1,5 secondes - 2 secondes - 2,5 secondes - 3 secondes - 3,5 secondes - 4 secondes - 4,5 secondes - 5 secondes Lire en streaming seulement si connecté en Wi-Fi Streaming en Wi-Fi uniquement %1$s%2$s diff --git a/ultrasonic/src/main/res/values-hu/strings.xml b/ultrasonic/src/main/res/values-hu/strings.xml index a0791890..2bf172ba 100644 --- a/ultrasonic/src/main/res/values-hu/strings.xml +++ b/ultrasonic/src/main/res/values-hu/strings.xml @@ -53,7 +53,7 @@ Biztos, hogy törölni akarja? %1$s Könyvjelző eltávolítva. Könyvjelző beállítva %s. - A várólista üres! + A várólista üres! A távvezérlés nem áll rendelkezésre. Kérjük, engedélyezze a Jukebox módot a Felhasználók > Beállítások menüpontban, az Ön Subsonic kiszolgálóján! Távvezérlés kikapcsolása. A zenelejátszás a telefonon történik. A távvezérlés nem lehetséges kapcsolat nélküli módban! @@ -140,7 +140,6 @@ Keresés Nem található média! %d dal kijelölve. - %d dal visszavonva. Figyelem: Hálózat nem áll rendelkezésre! Hiba: SD kártya nem áll rendelkezésre! Összes lejátszása @@ -316,17 +315,6 @@ Előadó képének megjelenítése Az előadó listában megjeleníti a képeket, amennyiben elérhetőek Videó - Nézet frissítési gyakorisága - .5 másodperc - 1 másodperc - 1.5 másodperc - 2 másodperc - 2.5 másodperc - 3 másodperc - 3.5 másodperc - 4 másodperc - 4.5 másodperc - 5 másodperc Streaming csak Wi-Fi hálózaton keresztül. Streaming csak Wi-Fivel %1$s%2$s diff --git a/ultrasonic/src/main/res/values-it/strings.xml b/ultrasonic/src/main/res/values-it/strings.xml index ffc11685..6f41bea4 100644 --- a/ultrasonic/src/main/res/values-it/strings.xml +++ b/ultrasonic/src/main/res/values-it/strings.xml @@ -40,7 +40,7 @@ Vuoi eliminare %1$s Segnalibro rimosso. Segnalibro impostato su %s. - Playlist vuota + Playlist vuota Il controllo remoto non è consentito. Per favore abilita la modalità jukebox nelle Impostazioni > Utente nel server Airsonic. Controllo remoto disattivato. La musica verrà riprodotta sullo smartphone. Il controllo remoto non è disponibile nella modalità offline. @@ -126,7 +126,6 @@ Cerca Nessun media trovato %dtracce selezionate. - %d tracce non selezionate. Attenzione: nessuna rete disponibile. Errore: Nessuna memoria SD disponibile. Riproduci tutto @@ -291,16 +290,6 @@ Sfoglia Utilizzando Tag ID3 Usa metodi tag ID3 invece dei metodi basati sul filesystem Video - .5 secondo - 1 secondo - 1.5 secondi - 2 secondi - 2.5 secondi - 3 secondi - 3.5 secondi - 4 secondi - 4.5 secondi - 5 secondi %1$s%2$s %d kbps 0 B diff --git a/ultrasonic/src/main/res/values-nl/strings.xml b/ultrasonic/src/main/res/values-nl/strings.xml index 0ce8cce4..9b55a8f9 100644 --- a/ultrasonic/src/main/res/values-nl/strings.xml +++ b/ultrasonic/src/main/res/values-nl/strings.xml @@ -56,7 +56,7 @@ Wil je %1$s verwijderen? Bladwijzer verwijderd. Bladwijzer ingesteld op %s. - Lege afspeellijst + Lege afspeellijst Afstandsbediening wordt niet ondersteund. Schakel jukebox-modus in op je Subsonic-server via Gebruikers > Instellingen. Afstandsbediening uitgeschakeld; muziek wordt afgespeeld op de telefoon. Afstandsbediening is niet beschikbaar in offline-modus. @@ -145,7 +145,6 @@ Zoeken Geen media gevonden %d nummers geselecteerd. - %d nummers gedeselecteerd. Waarschuwing: geen internetverbinding. Fout: geen SD-kaart beschikbaar. Alles afspelen @@ -323,17 +322,6 @@ Artiestfoto tonen op artiestenlijst Toont de artiestfoto op de artiestenlijst (indien beschikbaar) Video - Verversen - 0,5 seconden - 1 seconde - 1,5 seconden - 2 seconden - 2,5 seconden - 3 seconden - 3,5 seconden - 4 seconden - 4,5 seconden - 5 seconden Alleen streamen via wifi-verbindingen Alleen streamen via wifi %1$s%2$s diff --git a/ultrasonic/src/main/res/values-pl/strings.xml b/ultrasonic/src/main/res/values-pl/strings.xml index e794e991..7985499f 100644 --- a/ultrasonic/src/main/res/values-pl/strings.xml +++ b/ultrasonic/src/main/res/values-pl/strings.xml @@ -42,7 +42,7 @@ Czy chcesz usunąć %1$s? Zakładka usunięta. Zakładka ustawiona na %s. - Playlista jest pusta + Playlista jest pusta Kontrola pilotem jest niedostępna. Proszę uruchomić tryb jukebox w Użytkownicy > Ustawienia na serwerze Subsonic. Tryb pilota jest wyłączony. Muzyka jest odtwarzana w telefonie. Pilot jest niedostępny w trybie offline. @@ -128,7 +128,6 @@ Wyszukiwanie Brak mediów Zaznaczono %d utworów. - Odznaczono %d utworów. Uwaga: sieć niedostępna. Błąd: Niedostępna karta SD. Odtwórz wszystkie @@ -299,17 +298,6 @@ ponieważ api Subsonic nie wspiera nowego sposobu autoryzacji dla użytkowników Przeglądaj używając tagów ID3 Używa metod z tagów ID3 zamiast metod opartych na systemie plików Wideo - Odświeżanie widoku - co pół sekundy - co 1 sekundę - co 1,5 sekundy - co 2 sekundy - co 2,5 sekundy - co 3 sekundy - co 3,5 sekundy - co 4 sekundy - co 4,5 sekundy - co 5 sekund Przesyłanie mediów tylko gdy Wi-fi jest włączone Przesyłanie tylko przez Wi-fi %1$s%2$s diff --git a/ultrasonic/src/main/res/values-pt-rBR/strings.xml b/ultrasonic/src/main/res/values-pt-rBR/strings.xml index 2767a907..97fdd7b8 100644 --- a/ultrasonic/src/main/res/values-pt-rBR/strings.xml +++ b/ultrasonic/src/main/res/values-pt-rBR/strings.xml @@ -53,7 +53,7 @@ Você quer excluir %1$s Favorito removido. Favorito marcado em %s. - Playlist está vazia + Playlist está vazia Controle remoto não está permitido. Habilite o modo jukebox em Usuário > Configurações no seu servidor Subsonic. Controle remoto desligado. Música tocada no celular. Controle remoto não está disponível no modo offline. @@ -142,7 +142,6 @@ Pesquisar Nenhuma mídia encontrada %d faixas selecionadas. - %d faixas desselecionadas. Aviso: Nenhuma rede disponível. Erro: Nenhum cartão SD disponível. Tocar Tudo @@ -320,17 +319,6 @@ Mostrar Foto do Artista na Lista Mostrar a imagem do artista na lista de artistas, se disponível Vídeo - Atualização da Tela - .5 segundos - 1 segundo - 1.5 segundos - 2 segundos - 2.5 segundos - 3 segundos - 3.5 segundos - 4 segundos - 4.5 segundos - 5 segundos Somente fazer stream de mídia se conectado por Wi-Fi Streaming Somente por Wi-Fi %1$s%2$s diff --git a/ultrasonic/src/main/res/values-pt/strings.xml b/ultrasonic/src/main/res/values-pt/strings.xml index 3706c9ee..13217978 100644 --- a/ultrasonic/src/main/res/values-pt/strings.xml +++ b/ultrasonic/src/main/res/values-pt/strings.xml @@ -42,7 +42,7 @@ Você quer apagar %1$s Favorito removido. Favorito marcado em %s. - Playlist está vazia + Playlist está vazia Controle remoto não está permitido. Habilite o modo jukebox em Usuário > Configurações no seu servidor Subsonic. Controle remoto desligado. Música tocada no celular. Controle remoto não está disponível no modo offline. @@ -128,7 +128,6 @@ Pesquisar Nenhuma mídia encontrada %d faixas selecionadas. - %d faixas desselecionadas. Aviso: Nenhuma rede disponível. Erro: Nenhum cartão SD disponível. Tocar Tudo @@ -299,17 +298,6 @@ Navegar Usando Etiquetas ID3 Usa as etiquetas ID3 ao invés do sistema de ficheiros Vídeo - Atualização do Ecrã - .5 segundos - 1 segundo - 1.5 segundos - 2 segundos - 2.5 segundos - 3 segundos - 3.5 segundos - 4 segundos - 4.5 segundos - 5 segundos Somente fazer stream de mídia se conectado por Wi-Fi Streaming Somente por Wi-Fi %1$s%2$s diff --git a/ultrasonic/src/main/res/values-ru/strings.xml b/ultrasonic/src/main/res/values-ru/strings.xml index d13d3181..9533ad00 100644 --- a/ultrasonic/src/main/res/values-ru/strings.xml +++ b/ultrasonic/src/main/res/values-ru/strings.xml @@ -53,7 +53,7 @@ Вы хотите удалить %1$s Закладка удалена Закладка установлена ​​на %s - Плейлист пустой + Плейлист пустой Пульт дистанционного управления не допускается. Пожалуйста, включите режим музыкального автомата в Пользователи > Настройки на вашем Subsonic сервере. Пульт управления выключен. Музыка играет на телефоне Пульт дистанционного управления недоступен в автономном режиме. @@ -142,7 +142,6 @@ Поиск Медиа не найдена %d треки выбраны. - %d треки не выбраны. Предупреждение: сеть недоступна. Ошибка: нет SD-карты Воспроизвести все @@ -318,17 +317,6 @@ Показать изображение исполнителя в списке исполнителей Отображает изображение исполнителя в списке исполнителей, если доступно Видео - Посмотреть Обновить - .5 секунд - 1 секунда - 1.5 секунды - 2 секунды - 2.5 секунды - 3 секунды - 3.5 секунды - 4 секунды - 4.5 секунды - 5 секунд Потоковое мультимедиа только при подключении к Wi-Fi Только потоковая передача по Wi-Fi %1$s%2$s diff --git a/ultrasonic/src/main/res/values-zh-rCN/strings.xml b/ultrasonic/src/main/res/values-zh-rCN/strings.xml index 6596db6a..024a18fd 100644 --- a/ultrasonic/src/main/res/values-zh-rCN/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rCN/strings.xml @@ -53,7 +53,7 @@ 确定要删除 %1$s吗 书签已删除。 书签设置为 %s。 - 空的播放列表 + 空的播放列表 不允许远程控制. 请在您的服务器上的 Users > Settings 打开点唱机模式。 关闭远程控制,音乐将在手机上播放 离线模式不支持远程控制 @@ -141,7 +141,6 @@ 搜索 找不到歌曲 已选择 %d 首曲目。 - 未选择 %d 首曲目。 警告:网络不可用 错误:没有SD卡 播放所有 @@ -316,17 +315,6 @@ 在艺术家列表中显示艺术家图片 如果可用,在艺术家列表中显示艺术家图片 视频 - 刷新视图 - .5 秒 - 1 秒 - 1.5 秒 - 2 秒 - 2.5 秒 - 3 秒 - 3.5 秒 - 4 秒 - 4.5 秒 - 5 秒 仅在连接到 WIFI 时使用流媒体 仅使用 WIFI %1$s%2$s diff --git a/ultrasonic/src/main/res/values/arrays.xml b/ultrasonic/src/main/res/values/arrays.xml index 22b3cdff..3ea71240 100644 --- a/ultrasonic/src/main/res/values/arrays.xml +++ b/ultrasonic/src/main/res/values/arrays.xml @@ -224,30 +224,6 @@ @string/settings.search_250 @string/settings.search_500 - - @string/settings.view_refresh_500 - @string/settings.view_refresh_1000 - @string/settings.view_refresh_1500 - @string/settings.view_refresh_2000 - @string/settings.view_refresh_2500 - @string/settings.view_refresh_3000 - @string/settings.view_refresh_3500 - @string/settings.view_refresh_4000 - @string/settings.view_refresh_4500 - @string/settings.view_refresh_5000 - - - 500 - 1000 - 1500 - 2000 - 2500 - 3000 - 3500 - 4000 - 4500 - 5000 - @string/settings.share_milliseconds @string/settings.share_seconds diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 139cd13e..15b21c89 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -50,13 +50,15 @@ Play Shuffled Public Save + Select all Title Unpin Various Artists Do you want to delete %1$s Bookmark removed. Bookmark set at %s. - Playlist is empty + Nothing is downloading + Playlist is empty Remote control is not allowed. Please enable jukebox mode in Users > Settings on your Subsonic server. Turned off remote control. Music is played on phone. Remote control is not available in offline mode. @@ -146,8 +148,7 @@ Songs Search No media found - %d tracks selected. - %d tracks unselected. + %d tracks selected Warning: No network available. Error: No SD card available. Play All @@ -329,17 +330,6 @@ Show artist picture in artist list Displays the artist picture in the artist list if available Video - View Refresh - .5 seconds - 1 second - 1.5 seconds - 2 seconds - 2.5 seconds - 3 seconds - 3.5 seconds - 4 seconds - 4.5 seconds - 5 seconds Only download media on unmetered connections Download on Wi-Fi only %1$s%2$s @@ -432,24 +422,28 @@ %d songs - %d song selected to be pinned. - %d songs selected to be pinned. + %d song selected to be pinned + %d songs selected to be pinned - %d song selected to be downloaded. - %d songs selected to be downloaded. + %d song selected to be downloaded + %d songs selected to be downloaded - %d song selected to be unpinned. - %d songs selected to be unpinned. + %d song unpinned + %d songs unpinned + + + %d song deleted + %d songs deleted - %d song added to the end of play queue. - %d songs added to the end of play queue. + %d song added to the end of play queue + %d songs added to the end of play queue - %d song inserted after current song. - %d songs inserted after current song. + %d song inserted after current song + %d songs inserted after current song %d day left of trial period diff --git a/ultrasonic/src/main/res/values/styles.xml b/ultrasonic/src/main/res/values/styles.xml index d1d05648..d9cfaa1d 100644 --- a/ultrasonic/src/main/res/values/styles.xml +++ b/ultrasonic/src/main/res/values/styles.xml @@ -39,7 +39,6 @@ - @@ -61,6 +60,7 @@ + @@ -72,7 +72,6 @@ - diff --git a/ultrasonic/src/main/res/values/themes.xml b/ultrasonic/src/main/res/values/themes.xml index 4e23d827..126c3df0 100644 --- a/ultrasonic/src/main/res/values/themes.xml +++ b/ultrasonic/src/main/res/values/themes.xml @@ -12,7 +12,6 @@ @drawable/ic_star_full_dark @drawable/ic_menu_about_dark @drawable/ic_menu_select_all_dark - @drawable/ic_menu_add_to_queue_dark @drawable/ic_menu_browse_dark @drawable/ic_menu_exit_dark @drawable/ic_menu_backward_dark @@ -34,6 +33,7 @@ @drawable/ic_menu_share_dark @drawable/ic_menu_download_dark @drawable/stat_sys_download_anim_0_dark + @drawable/ic_baseline_error_dark @drawable/stat_sys_download_dark @drawable/media_backward_normal_dark @drawable/media_forward_normal_dark @@ -45,7 +45,6 @@ @drawable/media_start_normal_dark @drawable/ic_menu_podcasts_dark @drawable/ic_menu_refresh_dark - @drawable/media_play_next_dark @drawable/ic_stat_play_dark @drawable/media_stop_normal_dark @drawable/media_toggle_list_normal_dark @@ -58,10 +57,10 @@ @drawable/ic_subdirectory_up_dark @drawable/ic_sd_storage_dark @drawable/ic_drag_queue_dark - @drawable/ic_drag_vertical_dark @drawable/ic_more_vert_dark @drawable/list_selector_holo_dark @drawable/list_selector_holo_dark_selected + @color/selected_menu_background_light \ No newline at end of file diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index 42a77114..73c611f6 100644 --- a/ultrasonic/src/main/res/xml/settings.xml +++ b/ultrasonic/src/main/res/xml/settings.xml @@ -48,13 +48,6 @@ a:summary="@string/settings.disc_sort_summary" a:title="@string/settings.disc_sort" app:iconSpaceReserved="false"/> -