Compare commits
67 Commits
4.0.0-beta
...
develop
Author | SHA1 | Date |
---|---|---|
Óscar García Amor | 90205fe0fb | |
Óscar García Amor | 2165ce75b3 | |
Óscar García Amor | 8b3ee0a8d6 | |
birdbird | 695b2df63f | |
tzugen | 798d795e81 | |
tzugen | ecfce59e0f | |
tzugen | de0cb7713b | |
tzugen | 78bfab3753 | |
tzugen | b955d77152 | |
tzugen | b11694d6a2 | |
tzugen | 31a1fdace1 | |
tzugen | 5b03b632fd | |
tzugen | 152b1d261a | |
tzugen | 53a1a5545a | |
tzugen | ad54db5bcb | |
tzugen | 177329abcf | |
tzugen | 241e51015f | |
tzugen | 60dbe70ca5 | |
tzugen | 8490f7115d | |
tzugen | ee67f4c744 | |
tzugen | 3a3bd10fdb | |
birdbird | 3445576dc9 | |
tzugen | 8c40f662a1 | |
birdbird | 6c6227ce41 | |
tzugen | 240a2fa8f6 | |
tzugen | 7de775dc26 | |
birdbird | d034fc9c71 | |
birdbird | 05ada9297d | |
Maxence G | aa6c037b20 | |
Maxence G | b8c924be27 | |
Maxence G | 0929a6a1bd | |
Maxence G | fefee74a66 | |
Maxence G | 37e3ce09c1 | |
Maxence G | 16b3fcad32 | |
Maxence G | d6aebd9989 | |
Maxence G | 3f408600cb | |
Maxence G | 9014b47b74 | |
tzugen | ac489ae8b9 | |
tzugen | e7f8fa21cb | |
tzugen | b1c3cabfef | |
tzugen | 77865a143d | |
Óscar García Amor | ff9c7b2435 | |
Óscar García Amor | 737563bf6b | |
tzugen | 9a73d72fa4 | |
tzugen | 98ce519014 | |
tzugen | 83fc54d332 | |
Maxence G | a2b9c6b9a3 | |
Maxence G | 5ae56d26c5 | |
Maxence G | 4efb6dcb58 | |
tzugen | 8a90e98989 | |
tzugen | 46a8f4640d | |
tzugen | ab41966943 | |
tzugen | 00d7ce326c | |
Maxence G | bc4b0aa832 | |
Maxence G | 23fd336ffd | |
Maxence G | b57a973510 | |
Maxence G | 8796006ced | |
Maxence G | 545b65921e | |
Maxence G | cf367ead92 | |
Maxence G | 9961213f09 | |
tzugen | 5deb7d4d58 | |
tzugen | 5f31eaaffe | |
tzugen | cad6477cd9 | |
tzugen | b440821ea8 | |
Holger Müller | 8663b9d50e | |
Óscar García Amor | 2bae243be0 | |
tzugen | 66443ba018 |
|
@ -9,7 +9,7 @@ parameters:
|
|||
jobs:
|
||||
build:
|
||||
docker:
|
||||
- image: cimg/android:2022.03.1
|
||||
- image: cimg/android:2022.06.1
|
||||
working_directory: ~/ultrasonic
|
||||
environment:
|
||||
JVM_OPTS: << pipeline.parameters.memory-config >>
|
||||
|
@ -41,7 +41,6 @@ jobs:
|
|||
name: unit-tests
|
||||
command: |
|
||||
./gradlew ciTest testDebugUnitTest
|
||||
./gradlew jacocoFullReport
|
||||
- run:
|
||||
name: lint
|
||||
command: ./gradlew :ultrasonic:lintRelease
|
||||
|
@ -61,8 +60,6 @@ jobs:
|
|||
- store_artifacts:
|
||||
path: subsonic-api/build/reports
|
||||
destination: reports
|
||||
- store_artifacts:
|
||||
path: build/reports/jacoco/jacocoFullReport/
|
||||
push_translations:
|
||||
docker:
|
||||
- image: cimg/python:3.6
|
||||
|
@ -85,7 +82,7 @@ jobs:
|
|||
tx push -s
|
||||
generate_signed_apk:
|
||||
docker:
|
||||
- image: cimg/android:2022.03.1
|
||||
- image: cimg/android:2022.06.1
|
||||
working_directory: ~/ultrasonic
|
||||
environment:
|
||||
JVM_OPTS: << pipeline.parameters.memory-config >>
|
||||
|
|
|
@ -19,17 +19,45 @@ By default Pull Request should be opened against **develop** branch, PR against
|
|||
|
||||
1. **License Acceptance:** All contributions must be licensed as [GNU GPLv3](LICENSE) to be accepted.
|
||||
Use `git commit --signoff` to acknowledge this.
|
||||
2. **App is migrating to [Kotlin](https://kotlinlang.org/) programming language:** new Pull Requests
|
||||
should be written in this programming language.
|
||||
3. **No Breakage:** New features or changes to existing ones must not degrade the user experience.
|
||||
4. **Coding standards:** best-practices should be followed, comment generously, and avoid "clever" algorithms.
|
||||
2. **No Breakage:** New features or changes to existing ones must not degrade the user experience.
|
||||
3. **Coding standards:** best-practices should be followed, comment generously, and avoid "clever" algorithms.
|
||||
Refactoring existing messes is great, but watch out for breakage.
|
||||
5. **No large PR:** Try to limit the scope of PR only to the related issue, so it will be easier to review
|
||||
4. **No large PR:** Try to limit the scope of PR only to the related issue, so it will be easier to review
|
||||
and test.
|
||||
|
||||
### Pull Request Process
|
||||
On each Pull Request Github runs a number of checks to make sure there are no problems.
|
||||
|
||||
#### Signed commits
|
||||
Commits must be signed. [See here how to set it up](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits)
|
||||
|
||||
#### KtLint
|
||||
This programm checks if the source code is formatted correctly.
|
||||
You can run it yourself locally with
|
||||
|
||||
`./gradlew -Pqc ktlintFormat`
|
||||
|
||||
Running this command will fix common problems and will notify you of problems it couldn't fix automatically.
|
||||
|
||||
#### Detekt
|
||||
|
||||
Detekt is a static analyser. It helps to find potential bugs in our code.
|
||||
|
||||
You can run it yourself locally with
|
||||
|
||||
`./gradlew -Pqc detekt`
|
||||
|
||||
There is a "baseline" file, in which errors which have been in the code base before are noted.
|
||||
Sometimes it is necessary to regenerate this file by running:
|
||||
|
||||
`./gradlew -Pqc detektBaseline`
|
||||
|
||||
#### Lint
|
||||
Lint looks for general problems in the code or unused resources etc.
|
||||
You can run it with
|
||||
|
||||
`./gradlew -Pqc lintRelease`
|
||||
|
||||
If there is a need to regenerate the baseline, remove `ultrasonic/lint-baseline.xml` and rerun the command.
|
||||
|
||||
|
||||
1. Ensure [all commits are signed-off](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/about-commit-signature-verification).
|
||||
2. Check tests for the new code are added.
|
||||
3. Check code style is passing.
|
||||
4. Check code static analysis is passing.
|
||||
|
|
62
README.md
62
README.md
|
@ -1,14 +1,25 @@
|
|||
# Ultrasonic
|
||||
[![Build Status](https://circleci.com/gh/ultrasonic/ultrasonic/tree/develop.svg?style=shield&circle-token=:circle-token)](https://circleci.com/gh/ultrasonic)
|
||||
[![Codecov branch](https://img.shields.io/codecov/c/github/ultrasonic/ultrasonic/develop.svg)]()
|
||||
[![ktlint](https://img.shields.io/badge/code%20style-%E2%9D%A4-FF4081.svg)](https://ktlint.github.io/)
|
||||
# WE HAVE MOVED
|
||||
|
||||
Ultrasonic is free and open-source music streaming Android client for [Subsonic](http://www.subsonic.org/) [API](http://www.subsonic.org/pages/api.jsp) (version 1.7.0 or higher) compatible servers.
|
||||
Ultrasonic code is now hosted in [GitLab][ultrasonic].
|
||||
|
||||
- New Web: https://ultrasonic.gitlab.io
|
||||
- New Git: https://gitlab.com/ultrasonic/ultrasonic
|
||||
- New bugtracker: https://gitlab.com/ultrasonic/ultrasonic/-/issues
|
||||
- New releases: https://gitlab.com/ultrasonic/ultrasonic/-/packages
|
||||
|
||||
[ultrasonic]: https://gitlab.com/ultrasonic/ultrasonic
|
||||
|
||||
# Ultrasonic
|
||||
|
||||
Ultrasonic is free and open-source music streaming Android client for
|
||||
[Subsonic][subsonic] [API][subapi] (version 1.7.0 or higher) compatible
|
||||
servers.
|
||||
|
||||
## Help wanted
|
||||
|
||||
We currently don't have that much time to spend developing Subsonic, so any
|
||||
contributions or active developers are always welcomed.
|
||||
Have a look at [CONTRIBUTING](CONTRIBUTING.md) to get started.
|
||||
|
||||
## Download
|
||||
|
||||
|
@ -16,24 +27,26 @@ App is available to download at following stores:
|
|||
|
||||
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="70">](https://play.google.com/store/apps/details?id=org.moire.ultrasonic)
|
||||
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="70">](https://f-droid.org/packages/org.moire.ultrasonic/)
|
||||
[<img src="https://ultrasonic.github.io/assets/img/get-it-on-github.png" alt="Get it on GitHub" height="70">](https://github.com/ultrasonic/ultrasonic/releases)
|
||||
[<img src="https://ultrasonic.gitlab.io/assets/img/get-it-on-gitlab.png" alt="Get it on GitLab" height="70">](https://gitlab.com/ultrasonic/ultrasonic/-/releases)
|
||||
|
||||
**Warning**: All three versions (Google Play, F-Droid and the APKs) are not
|
||||
compatible (not signed by the same key)! You must uninstall one to install
|
||||
the other, which will delete all your data.
|
||||
|
||||
If you want to use the version downloaded from F-Droid or from Github with **Android Auto**, you must enable Unknown Sources as it is described in [this wiki page](https://github.com/ultrasonic/ultrasonic/wiki/Using-Ultrasonic-with-Android-Auto).
|
||||
If you want to use the version downloaded from F-Droid or from GitLab with
|
||||
**Android Auto**, you must enable Unknown Sources as it is described in
|
||||
[this wiki page][wikiaa].
|
||||
|
||||
## Bugs and issues
|
||||
|
||||
First, see if your issue haven’t been yet reported [here](https://github.com/ultrasonic/ultrasonic/issues),
|
||||
otherwise open [a new issue](https://github.com/ultrasonic/ultrasonic/issues/new).
|
||||
First, see if your issue haven’t been yet reported [here][issues], otherwise
|
||||
open [a new issue][newissue].
|
||||
|
||||
### Known (not our) bugs
|
||||
|
||||
If you are using *Madsonic 5.1.X* several sections of Ultrasonic will not
|
||||
work. This is caused by bad implementation of Subsonic API by Madsonic. For
|
||||
more info about this you can read [this bug](https://github.com/ultrasonic/ultrasonic/issues/129).
|
||||
more info about this you can read [this bug][madbug].
|
||||
|
||||
## Contributing
|
||||
|
||||
|
@ -41,16 +54,29 @@ See [CONTRIBUTING](CONTRIBUTING.md).
|
|||
|
||||
## Supported (tested) Subsonic API implementations
|
||||
|
||||
- [Subsonic](http://www.subsonic.org/pages/index.jsp)
|
||||
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
|
||||
- [Supysonic](https://github.com/spl0k/supysonic)
|
||||
- [Ampache](https://ampache.org/)
|
||||
- [Subsonic][subsonic]
|
||||
- [Airsonic-Advanced][airsonic]
|
||||
- [Supysonic][supysonic]
|
||||
- [Ampache][ampache]
|
||||
|
||||
Other *Subsonic API* implementations should work as well as long as they follow API
|
||||
[documentation](http://www.subsonic.org/pages/api.jsp).
|
||||
Other *Subsonic API* implementations should work as well as long as they
|
||||
follow API [documentation][subapi].
|
||||
|
||||
## License
|
||||
|
||||
This software is licensed under the terms of the GNU General Public License version 3 (GPLv3).
|
||||
This software is licensed under the terms of the GNU General Public License
|
||||
version 3 (GPLv3).
|
||||
|
||||
Full text of the license is available in the [LICENSE](LICENSE) file and [online](https://opensource.org/licenses/gpl-3.0.html).
|
||||
Full text of the license is available in the [LICENSE](LICENSE) file and
|
||||
[online][gpl3].
|
||||
|
||||
[wikiaa]: https://gitlab.com/ultrasonic/ultrasonic/-/wikis/Using-Ultrasonic-with-Android-Auto
|
||||
[issues]: https://gitlab.com/ultrasonic/ultrasonic/-/issues
|
||||
[newissue]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/new
|
||||
[madbug]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/129
|
||||
[subsonic]: http://www.subsonic.org/
|
||||
[subapi]: http://www.subsonic.org/pages/api.jsp
|
||||
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced
|
||||
[supysonic]: https://github.com/spl0k/supysonic
|
||||
[ampache]: https://ampache.org/
|
||||
[gpl3]: https://opensource.org/licenses/gpl-3.0.html
|
||||
|
|
|
@ -17,7 +17,6 @@ buildscript {
|
|||
classpath libs.kotlin
|
||||
classpath libs.ktlintGradle
|
||||
classpath libs.detekt
|
||||
classpath libs.jacoco
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,8 +43,6 @@ allprojects {
|
|||
}
|
||||
}
|
||||
|
||||
apply from: 'gradle_scripts/jacoco.gradle'
|
||||
|
||||
wrapper {
|
||||
gradleVersion(libs.versions.gradle.get())
|
||||
distributionType("all")
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
apply from: bootstrap.androidModule
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
ext {
|
||||
jacocoExclude = [
|
||||
'**/domain/**'
|
||||
]
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation libs.roomRuntime
|
||||
implementation libs.roomKtx
|
||||
|
|
|
@ -1,10 +1,21 @@
|
|||
/*
|
||||
* Album.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import java.util.Date
|
||||
|
||||
@Entity(tableName = "albums", primaryKeys = ["id", "serverId"])
|
||||
data class Album(
|
||||
@PrimaryKey override var id: String,
|
||||
override var id: String,
|
||||
@ColumnInfo(defaultValue = "-1")
|
||||
override var serverId: Int = -1,
|
||||
override var parent: String? = null,
|
||||
override var album: String? = null,
|
||||
override var title: String? = null,
|
||||
|
|
|
@ -1,14 +1,23 @@
|
|||
/*
|
||||
* Artist.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "artists")
|
||||
@Entity(tableName = "artists", primaryKeys = ["id", "serverId"])
|
||||
data class Artist(
|
||||
@PrimaryKey override var id: String,
|
||||
override var id: String,
|
||||
@ColumnInfo(defaultValue = "-1")
|
||||
override var serverId: Int = -1,
|
||||
override var name: String? = null,
|
||||
override var index: String? = null,
|
||||
override var coverArt: String? = null,
|
||||
override var albumCount: Long? = null,
|
||||
override var closeness: Int = 0
|
||||
) : ArtistOrIndex(id)
|
||||
) : ArtistOrIndex(id, serverId)
|
||||
|
|
|
@ -1,11 +1,21 @@
|
|||
/*
|
||||
* ArtistOrIndex.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import androidx.room.Ignore
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
abstract class ArtistOrIndex(
|
||||
@Ignore
|
||||
override var id: String,
|
||||
@Ignore
|
||||
open var serverId: Int,
|
||||
@Ignore
|
||||
override var name: String? = null,
|
||||
@Ignore
|
||||
open var index: String? = null,
|
||||
|
@ -18,15 +28,15 @@ abstract class ArtistOrIndex(
|
|||
) : GenericEntry() {
|
||||
|
||||
fun compareTo(other: ArtistOrIndex): Int {
|
||||
when {
|
||||
return when {
|
||||
this.closeness == other.closeness -> {
|
||||
return 0
|
||||
0
|
||||
}
|
||||
this.closeness > other.closeness -> {
|
||||
return -1
|
||||
-1
|
||||
}
|
||||
else -> {
|
||||
return 1
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
/*
|
||||
* Index.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "indexes")
|
||||
@Entity(tableName = "indexes", primaryKeys = ["id", "serverId"])
|
||||
data class Index(
|
||||
@PrimaryKey override var id: String,
|
||||
override var id: String,
|
||||
@ColumnInfo(defaultValue = "-1")
|
||||
override var serverId: Int = -1,
|
||||
override var name: String? = null,
|
||||
override var index: String? = null,
|
||||
override var coverArt: String? = null,
|
||||
override var albumCount: Long? = null,
|
||||
override var closeness: Int = 0,
|
||||
var musicFolderId: String? = null
|
||||
) : ArtistOrIndex(id)
|
||||
) : ArtistOrIndex(id, serverId)
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
/*
|
||||
* MusicDirectory.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import java.util.Date
|
||||
|
@ -31,6 +38,7 @@ class MusicDirectory : ArrayList<MusicDirectory.Child>() {
|
|||
|
||||
abstract class Child : GenericEntry() {
|
||||
abstract override var id: String
|
||||
abstract var serverId: Int
|
||||
abstract var parent: String?
|
||||
abstract var isDirectory: Boolean
|
||||
abstract var album: String?
|
||||
|
|
|
@ -1,13 +1,22 @@
|
|||
/*
|
||||
* MusicFolder.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Represents a top level directory in which music or other media is stored.
|
||||
*/
|
||||
@Entity(tableName = "music_folders")
|
||||
@Entity(tableName = "music_folders", primaryKeys = ["id", "serverId"])
|
||||
data class MusicFolder(
|
||||
@PrimaryKey override val id: String,
|
||||
override val name: String
|
||||
override val id: String,
|
||||
override val name: String,
|
||||
@ColumnInfo(defaultValue = "-1")
|
||||
var serverId: Int
|
||||
) : GenericEntry()
|
||||
|
|
|
@ -1,13 +1,22 @@
|
|||
/*
|
||||
* Track.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.io.Serializable
|
||||
import java.util.Date
|
||||
|
||||
@Entity
|
||||
@Entity(tableName = "tracks", primaryKeys = ["id", "serverId"])
|
||||
data class Track(
|
||||
@PrimaryKey override var id: String,
|
||||
override var id: String,
|
||||
@ColumnInfo(defaultValue = "-1")
|
||||
override var serverId: Int = -1,
|
||||
override var parent: String? = null,
|
||||
override var isDirectory: Boolean = false,
|
||||
override var title: String? = null,
|
||||
|
|
|
@ -20,11 +20,3 @@ dependencies {
|
|||
testImplementation libs.mockWebServer
|
||||
testImplementation libs.apacheCodecs
|
||||
}
|
||||
|
||||
ext {
|
||||
// Excluding data classes
|
||||
jacocoExclude = [
|
||||
'**/models/**',
|
||||
'**/di/**'
|
||||
]
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ fun <T : SubsonicResponse> Response<T>.throwOnFailure(): Response<T> {
|
|||
val response = this
|
||||
|
||||
if (response.isSuccessful && response.body()!!.status === SubsonicResponse.Status.OK) {
|
||||
return this as Response<T>
|
||||
return this
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Server error, code: " + response.code())
|
||||
|
|
|
@ -64,10 +64,7 @@ style:
|
|||
WildcardImport:
|
||||
active: true
|
||||
MaxLineLength:
|
||||
active: true
|
||||
maxLineLength: 120
|
||||
excludePackageStatements: false
|
||||
excludeImportStatements: false
|
||||
active: false
|
||||
MagicNumber:
|
||||
# 100 common in percentage, 1000 in milliseconds
|
||||
ignoreNumbers: ['-1', '0', '1', '2', '5', '10', '100', '256', '512', '1000', '1024', '4096']
|
||||
|
|
|
@ -1,28 +1,27 @@
|
|||
[versions]
|
||||
# You need to run ./gradlew wrapper after updating the version
|
||||
gradle = "7.3.2"
|
||||
gradle = "7.3.3"
|
||||
|
||||
navigation = "2.3.5"
|
||||
gradlePlugin = "7.1.1"
|
||||
gradlePlugin = "7.2.1"
|
||||
androidxcore = "1.6.0"
|
||||
ktlint = "0.43.2"
|
||||
ktlintGradle = "10.2.0"
|
||||
detekt = "1.19.0"
|
||||
jacoco = "0.8.7"
|
||||
preferences = "1.1.1"
|
||||
media = "1.3.1"
|
||||
media3 = "1.0.0-alpha03"
|
||||
media3 = "1.0.0-beta01"
|
||||
|
||||
androidSupport = "28.0.0"
|
||||
androidSupport = "1.4.0"
|
||||
androidLegacySupport = "1.0.0"
|
||||
androidSupportDesign = "1.4.0"
|
||||
androidSupportDesign = "1.6.1"
|
||||
constraintLayout = "2.1.1"
|
||||
multidex = "2.0.1"
|
||||
room = "2.4.0"
|
||||
room = "2.4.2"
|
||||
kotlin = "1.6.10"
|
||||
kotlinxCoroutines = "1.6.0-native-mt"
|
||||
kotlinxGuava = "1.6.0"
|
||||
viewModelKtx = "2.3.0"
|
||||
viewModelKtx = "2.4.1"
|
||||
|
||||
retrofit = "2.9.0"
|
||||
jackson = "2.10.1"
|
||||
|
@ -49,12 +48,11 @@ gradle = { module = "com.android.tools.build:gradle", version.r
|
|||
kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
ktlintGradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" }
|
||||
detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
|
||||
jacoco = { module = "org.jacoco:org.jacoco.core", version.ref = "jacoco" }
|
||||
|
||||
core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" }
|
||||
support = { module = "androidx.legacy:legacy-support-v4", version.ref = "androidLegacySupport" }
|
||||
design = { module = "com.google.android.material:material", version.ref = "androidSupportDesign" }
|
||||
annotations = { module = "com.android.support:support-annotations", version.ref = "androidSupport" }
|
||||
annotations = { module = "androidx.annotation:annotation", version.ref = "androidSupport" }
|
||||
multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" }
|
||||
constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintLayout" }
|
||||
room = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
|
@ -103,4 +101,3 @@ kluentAndroid = { module = "org.amshove.kluent:kluent-android", versio
|
|||
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||
apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" }
|
||||
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
ext.versions = [
|
||||
minSdk : 21,
|
||||
targetSdk : 30,
|
||||
targetSdk : 33,
|
||||
compileSdk : 31,
|
||||
]
|
|
@ -1,5 +1,6 @@
|
|||
#Fri Jun 17 23:13:49 CEST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.2-all.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
*/
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'jacoco'
|
||||
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
|
||||
|
||||
android {
|
||||
|
@ -48,10 +47,6 @@ android {
|
|||
|
||||
tasks.withType(Test) {
|
||||
useJUnitPlatform()
|
||||
jacoco {
|
||||
includeNoLocationClasses = true
|
||||
excludes += jacocoExclude
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -61,11 +56,4 @@ dependencies {
|
|||
testRuntimeOnly libs.junitVintage
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion(libs.versions.jacoco.get())
|
||||
}
|
||||
|
||||
ext {
|
||||
jacocoExclude = ['jdk.internal.*']
|
||||
}
|
||||
|
||||
|
|
|
@ -1,92 +0,0 @@
|
|||
apply plugin: 'jacoco'
|
||||
|
||||
jacoco {
|
||||
toolVersion(libs.versions.jacoco.get())
|
||||
}
|
||||
|
||||
def mergedJacocoExec = file("${project.buildDir}/jacoco/jacocoMerged.exec")
|
||||
|
||||
def merge = tasks.register('jacocoMergeReports', JacocoMerge) {
|
||||
group = "Reporting"
|
||||
description = "Merge all jacoco reports from projects into one."
|
||||
|
||||
ListProperty<File> jacocoFiles = project.objects.listProperty(File.class)
|
||||
project.subprojects { subproject ->
|
||||
subproject.plugins.withId("jacoco") {
|
||||
project.logger.info("${subproject.name} has Jacoco plugin applied")
|
||||
subproject.tasks.withType(Test) { task ->
|
||||
File destFile = task.extensions.getByType(JacocoTaskExtension.class).destinationFile
|
||||
if (destFile.exists() && !task.name.contains("Release")) {
|
||||
jacocoFiles.add(destFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executionData(jacocoFiles)
|
||||
destinationFile(mergedJacocoExec)
|
||||
}
|
||||
|
||||
tasks.register('jacocoFullReport', JacocoReport) {
|
||||
dependsOn merge
|
||||
group = "Reporting"
|
||||
description = "Generate full Jacoco coverage report including all modules."
|
||||
|
||||
getClassDirectories().setFrom(files())
|
||||
getSourceDirectories().setFrom(files())
|
||||
getExecutionData().setFrom(files())
|
||||
|
||||
reports {
|
||||
xml.enabled = true
|
||||
html.enabled = true
|
||||
csv.enabled = false
|
||||
}
|
||||
|
||||
// Always run merging, as all input calculation is done in doFirst {}
|
||||
outputs.upToDateWhen { false }
|
||||
// Task will run anyway even if initial inputs are empty
|
||||
onlyIf = { true }
|
||||
|
||||
project.subprojects { subproject ->
|
||||
subproject.plugins.withId("jacoco") {
|
||||
project.logger.info("${subproject.name} has Jacoco plugin applied")
|
||||
subproject.plugins.withId("kotlin-android") {
|
||||
project.logger.info("${subproject.name} is android project")
|
||||
def mainSources = subproject.extensions.findByName("android").sourceSets['main']
|
||||
project.logger.info("Android sources: ${mainSources.java.srcDirs}")
|
||||
mainSources.java.srcDirs.forEach {
|
||||
additionalSourceDirs(it)
|
||||
}
|
||||
project.logger.info("Subproject exclude: ${subproject.jacocoExclude}")
|
||||
additionalClassDirs(fileTree(
|
||||
dir: "${subproject.buildDir}/tmp/kotlin-classes/debug",
|
||||
excludes: subproject.jacocoExclude
|
||||
))
|
||||
}
|
||||
subproject.plugins.withId("kotlin") { plugin ->
|
||||
project.logger.info("${subproject.name} is common kotlin project")
|
||||
SourceDirectorySet mainSources = subproject.extensions.getByName("kotlin")
|
||||
.sourceSets[SourceSet.MAIN_SOURCE_SET_NAME]
|
||||
.kotlin
|
||||
mainSources.srcDirs.forEach {
|
||||
project.logger.debug("Adding sources: $it")
|
||||
additionalSourceDirs(it)
|
||||
}
|
||||
project.logger.info("Subproject exclude: ${subproject.jacocoExclude}")
|
||||
additionalClassDirs(fileTree(
|
||||
dir: "${subproject.buildDir}/classes/kotlin/main",
|
||||
excludes: subproject.jacocoExclude
|
||||
))
|
||||
}
|
||||
|
||||
subproject.tasks.withType(Test) { task ->
|
||||
File destFile = task.extensions.getByType(JacocoTaskExtension.class).destinationFile
|
||||
if (destFile.exists() && !task.name.contains("Release")) {
|
||||
project.logger.info("Adding execution data: $destFile")
|
||||
executionData(destFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -3,7 +3,6 @@
|
|||
*/
|
||||
apply plugin: 'kotlin'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'jacoco'
|
||||
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
|
||||
|
||||
sourceSets {
|
||||
|
@ -21,36 +20,8 @@ dependencies {
|
|||
testRuntimeOnly libs.junitVintage
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion(libs.versions.jacoco.get())
|
||||
}
|
||||
|
||||
ext {
|
||||
// override it in the module
|
||||
jacocoExclude = ['jdk.internal.*']
|
||||
}
|
||||
|
||||
jacocoTestReport {
|
||||
reports {
|
||||
html.required = true
|
||||
xml.required = false
|
||||
csv.required = false
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
getClassDirectories().setFrom(files(classDirectories.files.collect {
|
||||
fileTree(dir: it, excludes: jacocoExclude)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("test").configure {
|
||||
useJUnitPlatform()
|
||||
jacoco {
|
||||
excludes += jacocoExclude
|
||||
includeNoLocationClasses = true
|
||||
}
|
||||
finalizedBy jacocoTestReport
|
||||
}
|
||||
|
||||
tasks.register("ciTest") {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'jacoco'
|
||||
apply from: "../gradle_scripts/code_quality.gradle"
|
||||
|
||||
android {
|
||||
|
@ -64,7 +63,7 @@ android {
|
|||
|
||||
kapt {
|
||||
arguments {
|
||||
arg("room.schemaLocation", "$buildDir/schemas".toString())
|
||||
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas".toString())
|
||||
}
|
||||
}
|
||||
lint {
|
||||
|
@ -74,6 +73,7 @@ android {
|
|||
disable 'IconMissingDensityFolder', 'VectorPath'
|
||||
ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
|
||||
warning 'ImpliedQuantity'
|
||||
disable 'ObsoleteLintCustomCheck'
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -135,36 +135,3 @@ dependencies {
|
|||
|
||||
implementation libs.timber
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion(libs.versions.jacoco.get())
|
||||
}
|
||||
|
||||
// Excluding all java classes and stuff that should not be covered
|
||||
ext {
|
||||
jacocoExclude = [
|
||||
'**/activity/**',
|
||||
'**/audiofx/**',
|
||||
'**/fragment/**',
|
||||
'**/provider/**',
|
||||
'**/receiver/**',
|
||||
'**/service/**',
|
||||
'**/Test/**',
|
||||
'**/util/**',
|
||||
'**/view/**',
|
||||
'**/R$*.class',
|
||||
'**/R.class',
|
||||
'**/BuildConfig.class',
|
||||
'**/di/**',
|
||||
'jdk.internal.*'
|
||||
]
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion(libs.versions.jacoco.get())
|
||||
}
|
||||
|
||||
tasks.withType(Test) {
|
||||
jacoco.includeNoLocationClasses = true
|
||||
jacoco.excludes += jacocoExclude
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 7.1.1" type="baseline" client="gradle" dependencies="false" name="AGP (7.1.1)" variant="all" version="7.1.1">
|
||||
<issues format="6" by="lint 7.2.1" type="baseline" client="gradle" dependencies="false" name="AGP (7.2.1)" variant="all" version="7.2.1">
|
||||
|
||||
<issue
|
||||
id="InflateParams"
|
||||
|
@ -55,7 +55,7 @@
|
|||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="151"
|
||||
line="155"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
|
@ -66,7 +66,7 @@
|
|||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="75"
|
||||
line="79"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
|
@ -77,18 +77,7 @@
|
|||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="65"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IntentFilterExportedReceiver"
|
||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise. For launcher activities, this should be set to `true`."
|
||||
errorLine1=" <activity android:name=".activity.NavigationActivity""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="41"
|
||||
line="68"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
|
@ -180,6 +169,61 @@
|
|||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
|
||||
errorLine1="<vector android:height="48dp""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/media3_notification_pause.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.media3_notification_play` appears to be unused"
|
||||
errorLine1="<vector android:height="48dp""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/media3_notification_play.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.media3_notification_seek_to_next` appears to be unused"
|
||||
errorLine1="<vector android:height="32dp""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/media3_notification_seek_to_next.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.media3_notification_seek_to_previous` appears to be unused"
|
||||
errorLine1="<vector android:height="32dp""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/media3_notification_seek_to_previous.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.media3_notification_small_icon` appears to be unused"
|
||||
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/media3_notification_small_icon.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IconDuplicates"
|
||||
message="The following unrelated icon files have identical contents: list_pressed_holo_dark.9.png, list_pressed_holo_light.9.png">
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "4cea788a99b9bc28500948b1cd92e537",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ServerSetting",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `index` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `color` INTEGER, `userName` TEXT NOT NULL, `password` TEXT NOT NULL, `jukeboxByDefault` INTEGER NOT NULL, `allowSelfSignedCertificate` INTEGER NOT NULL, `ldapSupport` INTEGER NOT NULL, `musicFolderId` TEXT, `minimumApiVersion` TEXT, `chatSupport` INTEGER, `bookmarkSupport` INTEGER, `shareSupport` INTEGER, `podcastSupport` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "userName",
|
||||
"columnName": "userName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "jukeboxByDefault",
|
||||
"columnName": "jukeboxByDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "allowSelfSignedCertificate",
|
||||
"columnName": "allowSelfSignedCertificate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ldapSupport",
|
||||
"columnName": "ldapSupport",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "musicFolderId",
|
||||
"columnName": "musicFolderId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "minimumApiVersion",
|
||||
"columnName": "minimumApiVersion",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chatSupport",
|
||||
"columnName": "chatSupport",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkSupport",
|
||||
"columnName": "bookmarkSupport",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "shareSupport",
|
||||
"columnName": "shareSupport",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "podcastSupport",
|
||||
"columnName": "podcastSupport",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4cea788a99b9bc28500948b1cd92e537')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "0580217b1e87b02d2edaf9b008891cbc",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "artists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "index",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCount",
|
||||
"columnName": "albumCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "indexes",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "index",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCount",
|
||||
"columnName": "albumCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "musicFolderId",
|
||||
"columnName": "musicFolderId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "music_folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0580217b1e87b02d2edaf9b008891cbc')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,474 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "b6ac795e7857eac4fed2dbbd01f80fb8",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "artists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "index",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCount",
|
||||
"columnName": "albumCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "albums",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parent",
|
||||
"columnName": "parent",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "album",
|
||||
"columnName": "album",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "discNumber",
|
||||
"columnName": "discNumber",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "songCount",
|
||||
"columnName": "songCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "created",
|
||||
"columnName": "created",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "artist",
|
||||
"columnName": "artist",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "starred",
|
||||
"columnName": "starred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVideo",
|
||||
"columnName": "isVideo",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parent",
|
||||
"columnName": "parent",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "album",
|
||||
"columnName": "album",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "artist",
|
||||
"columnName": "artist",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "track",
|
||||
"columnName": "track",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentType",
|
||||
"columnName": "contentType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "suffix",
|
||||
"columnName": "suffix",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "transcodedContentType",
|
||||
"columnName": "transcodedContentType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "transcodedSuffix",
|
||||
"columnName": "transcodedSuffix",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "songCount",
|
||||
"columnName": "songCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bitRate",
|
||||
"columnName": "bitRate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVideo",
|
||||
"columnName": "isVideo",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "starred",
|
||||
"columnName": "starred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "discNumber",
|
||||
"columnName": "discNumber",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "created",
|
||||
"columnName": "created",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkPosition",
|
||||
"columnName": "bookmarkPosition",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userRating",
|
||||
"columnName": "userRating",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "averageRating",
|
||||
"columnName": "averageRating",
|
||||
"affinity": "REAL",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "indexes",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "index",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCount",
|
||||
"columnName": "albumCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "musicFolderId",
|
||||
"columnName": "musicFolderId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "music_folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6ac795e7857eac4fed2dbbd01f80fb8')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,514 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "95e83d6663a862c03ac46f9567453ded",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "artists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "index",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCount",
|
||||
"columnName": "albumCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"serverId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "albums",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "parent",
|
||||
"columnName": "parent",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "album",
|
||||
"columnName": "album",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "discNumber",
|
||||
"columnName": "discNumber",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "songCount",
|
||||
"columnName": "songCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "created",
|
||||
"columnName": "created",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "artist",
|
||||
"columnName": "artist",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "starred",
|
||||
"columnName": "starred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVideo",
|
||||
"columnName": "isVideo",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"serverId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`, `serverId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "parent",
|
||||
"columnName": "parent",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "album",
|
||||
"columnName": "album",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "artist",
|
||||
"columnName": "artist",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "track",
|
||||
"columnName": "track",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentType",
|
||||
"columnName": "contentType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "suffix",
|
||||
"columnName": "suffix",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "transcodedContentType",
|
||||
"columnName": "transcodedContentType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "transcodedSuffix",
|
||||
"columnName": "transcodedSuffix",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "songCount",
|
||||
"columnName": "songCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bitRate",
|
||||
"columnName": "bitRate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVideo",
|
||||
"columnName": "isVideo",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "starred",
|
||||
"columnName": "starred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "discNumber",
|
||||
"columnName": "discNumber",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "created",
|
||||
"columnName": "created",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkPosition",
|
||||
"columnName": "bookmarkPosition",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userRating",
|
||||
"columnName": "userRating",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "averageRating",
|
||||
"columnName": "averageRating",
|
||||
"affinity": "REAL",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"serverId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "indexes",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`, `serverId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "index",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverArt",
|
||||
"columnName": "coverArt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCount",
|
||||
"columnName": "albumCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeness",
|
||||
"columnName": "closeness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "musicFolderId",
|
||||
"columnName": "musicFolderId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"serverId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "music_folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT NOT NULL, PRIMARY KEY(`id`, `serverId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"serverId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '95e83d6663a862c03ac46f9567453ded')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -21,6 +21,8 @@
|
|||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/NoActionBar"
|
||||
|
@ -40,7 +42,8 @@
|
|||
|
||||
<activity android:name=".activity.NavigationActivity"
|
||||
android:configChanges="orientation|keyboardHidden"
|
||||
android:launchMode="singleTask">
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<action android:name="android.intent.action.SEARCH"/>
|
||||
|
@ -61,9 +64,10 @@
|
|||
android:exported="false">
|
||||
</service>
|
||||
|
||||
<!-- TODO: Check if it works with exported=false as well -->
|
||||
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
|
||||
<service android:name=".playback.PlaybackService"
|
||||
android:label="@string/common.appname"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
|
@ -150,7 +154,8 @@
|
|||
</receiver>
|
||||
<provider
|
||||
android:name=".provider.SearchSuggestionProvider"
|
||||
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/>
|
||||
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"
|
||||
android:exported="true" />
|
||||
|
||||
</application>
|
||||
|
||||
|
|
|
@ -392,6 +392,8 @@ class NavigationActivity : AppCompatActivity() {
|
|||
if (!infoDialogDisplayed) {
|
||||
infoDialogDisplayed = true
|
||||
|
||||
Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext())
|
||||
|
||||
InfoDialog.Builder(this)
|
||||
.setTitle(R.string.main_welcome_title)
|
||||
.setMessage(R.string.main_welcome_text_demo)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* ArtistRowAdapter.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
* ArtistRowBinder.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
@ -19,6 +19,7 @@ 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.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.ArtistOrIndex
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.imageloader.ImageLoader
|
||||
|
@ -57,7 +58,7 @@ class ArtistRowBinder(
|
|||
|
||||
holder.coverArtId = item.coverArt
|
||||
|
||||
if (Settings.shouldShowArtistPicture) {
|
||||
if (showArtistPicture()) {
|
||||
holder.coverArt.visibility = View.VISIBLE
|
||||
val key = FileUtil.getArtistArtKey(item.name, false)
|
||||
imageLoader.loadImage(
|
||||
|
@ -102,11 +103,16 @@ class ArtistRowBinder(
|
|||
}
|
||||
|
||||
private fun getSectionFromName(name: String): String {
|
||||
var section = name.first().uppercaseChar()
|
||||
if (!section.isLetter()) section = '#'
|
||||
if (name.isEmpty()) return SECTION_KEY_DEFAULT
|
||||
val section = name.first().uppercaseChar()
|
||||
if (!section.isLetter()) return SECTION_KEY_DEFAULT
|
||||
return section.toString()
|
||||
}
|
||||
|
||||
private fun showArtistPicture(): Boolean {
|
||||
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of our ViewHolder class
|
||||
*/
|
||||
|
@ -123,4 +129,8 @@ class ArtistRowBinder(
|
|||
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder {
|
||||
return ViewHolder(inflater.inflate(layout, parent, false))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SECTION_KEY_DEFAULT = "#"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,7 +59,7 @@ class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView
|
|||
throw IllegalAccessException("You must use submitList() to add data to the Adapter")
|
||||
}
|
||||
|
||||
var mDiffer: AsyncListDiffer<T> = AsyncListDiffer(
|
||||
private var mDiffer: AsyncListDiffer<T> = AsyncListDiffer(
|
||||
AdapterListUpdateCallback(this),
|
||||
AsyncDifferConfig.Builder(diffCallback).build()
|
||||
)
|
||||
|
@ -182,12 +182,11 @@ class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView
|
|||
|
||||
// Select them all
|
||||
getCurrentList().mapNotNullTo(
|
||||
selectedSet,
|
||||
{ entry ->
|
||||
selectedSet
|
||||
) { entry ->
|
||||
// Exclude any -1 ids, eg. headers and other UI elements
|
||||
entry.longId.takeIf { it != -1L }
|
||||
}
|
||||
)
|
||||
|
||||
return selectedSet.count()
|
||||
}
|
||||
|
|
|
@ -153,6 +153,8 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
|||
star.setImageDrawable(imageHelper.starHollowDrawable)
|
||||
song.starred = false
|
||||
}
|
||||
|
||||
// Should this be done here ?
|
||||
Thread {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
try {
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
/*
|
||||
* ActiveServerProvider.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import androidx.room.Room
|
||||
|
@ -110,20 +117,23 @@ class ActiveServerProvider(
|
|||
|
||||
Timber.i("Switching to new database, id:$activeServer")
|
||||
cachedServerId = activeServer
|
||||
return buildDatabase(cachedServerId)
|
||||
cachedDatabase = initDatabase(activeServer)
|
||||
|
||||
return cachedDatabase!!
|
||||
}
|
||||
|
||||
val offlineMetaDatabase: MetaDatabase by lazy {
|
||||
buildDatabase(OFFLINE_DB_ID)
|
||||
initDatabase(0)
|
||||
}
|
||||
|
||||
private fun buildDatabase(id: Int?): MetaDatabase {
|
||||
private fun initDatabase(serverId: Int): MetaDatabase {
|
||||
return Room.databaseBuilder(
|
||||
UApp.applicationContext(),
|
||||
MetaDatabase::class.java,
|
||||
METADATA_DB + id
|
||||
METADATA_DB + serverId
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addMigrations(META_MIGRATION_2_3)
|
||||
.fallbackToDestructiveMigrationOnDowngrade()
|
||||
.build()
|
||||
}
|
||||
|
||||
|
@ -239,6 +249,13 @@ class ActiveServerProvider(
|
|||
return preferences.getBoolean(Constants.PREFERENCES_KEY_SCROBBLE, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries if ID3 tags should be used
|
||||
*/
|
||||
fun isID3Enabled(): Boolean {
|
||||
return Settings.shouldUseId3Tags && (!isOffline() || Settings.useId3TagsOffline)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries if Server Scaling is enabled
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
package org.moire.ultrasonic.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
|
||||
@Dao
|
||||
interface AlbumDao : GenericDao<Album> {
|
||||
/**
|
||||
* Clear the whole database
|
||||
*/
|
||||
@Query("DELETE FROM albums")
|
||||
fun clear()
|
||||
|
||||
/**
|
||||
* Get all albums
|
||||
*/
|
||||
@Query("SELECT * FROM albums")
|
||||
fun get(): List<Album>
|
||||
|
||||
/**
|
||||
* Get all albums in a specific range
|
||||
*/
|
||||
@Query("SELECT * FROM albums LIMIT :offset,:size")
|
||||
fun get(size: Int, offset: Int = 0): List<Album>
|
||||
|
||||
/**
|
||||
* Get album by id
|
||||
*/
|
||||
@Query("SELECT * FROM albums where id LIKE :albumId LIMIT 1")
|
||||
fun get(albumId: String): Album
|
||||
|
||||
/**
|
||||
* Get albums by artist
|
||||
*/
|
||||
@Query("SELECT * FROM albums WHERE artistId LIKE :id")
|
||||
fun byArtist(id: String): List<Album>
|
||||
|
||||
/**
|
||||
* Clear albums by artist
|
||||
*/
|
||||
@Query("DELETE FROM albums WHERE artistId LIKE :id")
|
||||
fun clearByArtist(id: String)
|
||||
|
||||
/**
|
||||
* TODO: Make generic
|
||||
* Upserts (insert or update) an object to the database
|
||||
*
|
||||
* @param obj the object to upsert
|
||||
*/
|
||||
@Transaction
|
||||
@JvmSuppressWildcards
|
||||
fun upsert(obj: Album) {
|
||||
val id = insertIgnoring(obj)
|
||||
if (id == -1L) {
|
||||
update(obj)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts (insert or update) a list of objects
|
||||
*
|
||||
* @param objList the object to be upserted
|
||||
*/
|
||||
@Transaction
|
||||
@JvmSuppressWildcards
|
||||
fun upsert(objList: List<Album>) {
|
||||
val insertResult = insertIgnoring(objList)
|
||||
val updateList: MutableList<Album> = ArrayList()
|
||||
for (i in insertResult.indices) {
|
||||
if (insertResult[i] == -1L) {
|
||||
updateList.add(objList[i])
|
||||
}
|
||||
}
|
||||
if (updateList.isNotEmpty()) {
|
||||
update(updateList)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ import androidx.room.Query
|
|||
import org.moire.ultrasonic.domain.Artist
|
||||
|
||||
@Dao
|
||||
interface ArtistsDao {
|
||||
interface ArtistDao {
|
||||
/**
|
||||
* Insert a list in the database. If the item already exists, replace it.
|
||||
*
|
||||
|
@ -43,5 +43,5 @@ interface ArtistsDao {
|
|||
* Get artist by id
|
||||
*/
|
||||
@Query("SELECT * FROM artists WHERE id LIKE :id")
|
||||
fun get(id: String): Artist
|
||||
fun get(id: String): Artist?
|
||||
}
|
|
@ -53,6 +53,7 @@ interface IndexDao : GenericDao<Index> {
|
|||
fun get(musicFolderId: String): List<Index>
|
||||
|
||||
/**
|
||||
* TODO: Make generic
|
||||
* Upserts (insert or update) an object to the database
|
||||
*
|
||||
* @param obj the object to upsert
|
||||
|
|
|
@ -1,23 +1,85 @@
|
|||
/*
|
||||
* MetaDatabase.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import java.util.Date
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
import org.moire.ultrasonic.domain.Index
|
||||
import org.moire.ultrasonic.domain.MusicFolder
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
|
||||
/**
|
||||
* This database is used to store and cache the ID3 metadata
|
||||
*/
|
||||
|
||||
@Database(
|
||||
entities = [Artist::class, Index::class, MusicFolder::class],
|
||||
version = 1
|
||||
entities = [
|
||||
Artist::class,
|
||||
Album::class,
|
||||
Track::class,
|
||||
Index::class,
|
||||
MusicFolder::class
|
||||
],
|
||||
autoMigrations = [
|
||||
AutoMigration(
|
||||
from = 1,
|
||||
to = 2
|
||||
),
|
||||
],
|
||||
exportSchema = true,
|
||||
version = 3
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class MetaDatabase : RoomDatabase() {
|
||||
abstract fun artistsDao(): ArtistsDao
|
||||
abstract fun artistDao(): ArtistDao
|
||||
|
||||
abstract fun albumDao(): AlbumDao
|
||||
|
||||
abstract fun trackDao(): TrackDao
|
||||
|
||||
abstract fun musicFoldersDao(): MusicFoldersDao
|
||||
|
||||
abstract fun indexDao(): IndexDao
|
||||
}
|
||||
|
||||
class Converters {
|
||||
@TypeConverter
|
||||
fun fromTimestamp(value: Long?): Date? {
|
||||
return value?.let { Date(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun dateToTimestamp(date: Date?): Long? {
|
||||
return date?.time
|
||||
}
|
||||
}
|
||||
|
||||
/* ktlint-disable max-line-length */
|
||||
val META_MIGRATION_2_3: Migration = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("DROP TABLE `albums`")
|
||||
database.execSQL("DROP TABLE `indexes`")
|
||||
database.execSQL("DROP TABLE `artists`")
|
||||
database.execSQL("DROP TABLE `tracks`")
|
||||
database.execSQL("DROP TABLE `music_folders`")
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `albums` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))")
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `indexes` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`, `serverId`))")
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `artists` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))")
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `music_folders` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`id`, `serverId`))")
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `tracks` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`, `serverId`))")
|
||||
}
|
||||
}
|
||||
/* ktlint-enable max-line-length */
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
package org.moire.ultrasonic.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Query
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
|
||||
@Dao
|
||||
@Entity(tableName = "tracks")
|
||||
interface TrackDao : GenericDao<Track> {
|
||||
/**
|
||||
* Clear the whole database
|
||||
*/
|
||||
@Query("DELETE FROM tracks")
|
||||
fun clear()
|
||||
|
||||
/**
|
||||
* Get all albums
|
||||
*/
|
||||
@Query("SELECT * FROM tracks")
|
||||
fun get(): List<Track>
|
||||
|
||||
/**
|
||||
* Get albums by artist
|
||||
*/
|
||||
@Query("SELECT * FROM tracks WHERE albumId LIKE :id")
|
||||
fun byAlbum(id: String): List<Track>
|
||||
|
||||
/**
|
||||
* Get albums by artist
|
||||
*/
|
||||
@Query("SELECT * FROM tracks WHERE artistId LIKE :id")
|
||||
fun byArtist(id: String): List<Track>
|
||||
}
|
|
@ -1,3 +1,10 @@
|
|||
/*
|
||||
* APIAlbumConverter.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
// Converts Album entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
|
||||
// to app domain entities.
|
||||
@file:JvmName("APIAlbumConverter")
|
||||
|
@ -6,8 +13,9 @@ package org.moire.ultrasonic.domain
|
|||
import org.moire.ultrasonic.api.subsonic.models.Album
|
||||
typealias DomainAlbum = org.moire.ultrasonic.domain.Album
|
||||
|
||||
fun Album.toDomainEntity(): DomainAlbum = Album(
|
||||
fun Album.toDomainEntity(serverId: Int): DomainAlbum = Album(
|
||||
id = this@toDomainEntity.id,
|
||||
serverId = serverId,
|
||||
title = this@toDomainEntity.name ?: this@toDomainEntity.title,
|
||||
album = this@toDomainEntity.album,
|
||||
coverArt = this@toDomainEntity.coverArt,
|
||||
|
@ -21,8 +29,10 @@ fun Album.toDomainEntity(): DomainAlbum = Album(
|
|||
starred = this@toDomainEntity.starredDate.isNotEmpty()
|
||||
)
|
||||
|
||||
fun Album.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply {
|
||||
addAll(this@toMusicDirectoryDomainEntity.songList.map { it.toTrackEntity() })
|
||||
fun Album.toMusicDirectoryDomainEntity(serverId: Int): MusicDirectory = MusicDirectory().apply {
|
||||
addAll(this@toMusicDirectoryDomainEntity.songList.map { it.toTrackEntity(serverId) })
|
||||
}
|
||||
|
||||
fun List<Album>.toDomainEntityList(): List<DomainAlbum> = this.map { it.toDomainEntity() }
|
||||
fun List<Album>.toDomainEntityList(serverId: Int): List<DomainAlbum> = this.map {
|
||||
it.toDomainEntity(serverId)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
/*
|
||||
* APIArtistConverter.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
// Converts Artist entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
|
||||
// to app domain entities.
|
||||
@file:JvmName("APIArtistConverter")
|
||||
|
@ -6,24 +13,26 @@ package org.moire.ultrasonic.domain
|
|||
import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist
|
||||
|
||||
// When we like to convert to an Artist
|
||||
fun APIArtist.toDomainEntity(): Artist = Artist(
|
||||
fun APIArtist.toDomainEntity(serverId: Int): Artist = Artist(
|
||||
id = this@toDomainEntity.id,
|
||||
serverId = serverId,
|
||||
coverArt = this@toDomainEntity.coverArt,
|
||||
name = this@toDomainEntity.name
|
||||
)
|
||||
|
||||
// When we like to convert to an index (eg. a single directory).
|
||||
fun APIArtist.toIndexEntity(): Index = Index(
|
||||
fun APIArtist.toIndexEntity(serverId: Int): Index = Index(
|
||||
id = this@toIndexEntity.id,
|
||||
serverId = serverId,
|
||||
coverArt = this@toIndexEntity.coverArt,
|
||||
name = this@toIndexEntity.name
|
||||
)
|
||||
|
||||
fun APIArtist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply {
|
||||
fun APIArtist.toMusicDirectoryDomainEntity(serverId: Int): MusicDirectory = MusicDirectory().apply {
|
||||
name = this@toMusicDirectoryDomainEntity.name
|
||||
addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity() })
|
||||
addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity(serverId) })
|
||||
}
|
||||
|
||||
fun APIArtist.toDomainEntityList(): List<Album> {
|
||||
return this.albumsList.map { it.toDomainEntity() }
|
||||
fun APIArtist.toDomainEntityList(serverId: Int): List<Album> {
|
||||
return this.albumsList.map { it.toDomainEntity(serverId) }
|
||||
}
|
||||
|
|
|
@ -1,16 +1,25 @@
|
|||
/*
|
||||
* APIBookmarkConverter.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
// Contains helper functions to convert api Bookmark entity to domain entity
|
||||
@file:JvmName("APIBookmarkConverter")
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import org.moire.ultrasonic.api.subsonic.models.Bookmark as ApiBookmark
|
||||
|
||||
fun ApiBookmark.toDomainEntity(): Bookmark = Bookmark(
|
||||
fun ApiBookmark.toDomainEntity(serverId: Int): Bookmark = Bookmark(
|
||||
position = this@toDomainEntity.position.toInt(),
|
||||
username = this@toDomainEntity.username,
|
||||
comment = this@toDomainEntity.comment,
|
||||
created = this@toDomainEntity.created?.time,
|
||||
changed = this@toDomainEntity.changed?.time,
|
||||
track = this@toDomainEntity.entry.toTrackEntity()
|
||||
track = this@toDomainEntity.entry.toTrackEntity(serverId)
|
||||
)
|
||||
|
||||
fun List<ApiBookmark>.toDomainEntitiesList(): List<Bookmark> = map { it.toDomainEntity() }
|
||||
fun List<ApiBookmark>.toDomainEntitiesList(serverId: Int): List<Bookmark> =
|
||||
map { it.toDomainEntity(serverId) }
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
/*
|
||||
* APIIndexesConverter.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
// Converts Indexes entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
|
||||
// to app domain entities.
|
||||
@file:JvmName("APIIndexesConverter")
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import org.moire.ultrasonic.api.subsonic.models.Index as APIIndex
|
||||
import org.moire.ultrasonic.api.subsonic.models.Indexes as APIIndexes
|
||||
|
||||
fun APIIndexes.toArtistList(): List<Artist> {
|
||||
val shortcuts = this.shortcutList.map { it.toDomainEntity() }.toMutableList()
|
||||
val indexes = this.indexList.foldIndexToArtistList()
|
||||
fun APIIndexes.toArtistList(serverId: Int): List<Artist> {
|
||||
val shortcuts = this.shortcutList.map { it.toDomainEntity(serverId) }.toMutableList()
|
||||
val indexes = this.indexList.foldIndexToArtistList(serverId)
|
||||
|
||||
indexes.forEach {
|
||||
if (!shortcuts.contains(it)) {
|
||||
|
@ -19,9 +27,9 @@ fun APIIndexes.toArtistList(): List<Artist> {
|
|||
return shortcuts
|
||||
}
|
||||
|
||||
fun APIIndexes.toIndexList(musicFolderId: String?): List<Index> {
|
||||
val shortcuts = this.shortcutList.map { it.toIndexEntity() }.toMutableList()
|
||||
val indexes = this.indexList.foldIndexToIndexList(musicFolderId)
|
||||
fun APIIndexes.toIndexList(serverId: Int, musicFolderId: String?): List<Index> {
|
||||
val shortcuts = this.shortcutList.map { it.toIndexEntity(serverId) }.toMutableList()
|
||||
val indexes = this.indexList.foldIndexToIndexList(musicFolderId, serverId)
|
||||
|
||||
indexes.forEach {
|
||||
if (!shortcuts.contains(it)) {
|
||||
|
@ -32,22 +40,23 @@ fun APIIndexes.toIndexList(musicFolderId: String?): List<Index> {
|
|||
return shortcuts
|
||||
}
|
||||
|
||||
private fun List<APIIndex>.foldIndexToArtistList(): List<Artist> = this.fold(
|
||||
listOf(),
|
||||
{ acc, index ->
|
||||
private fun List<APIIndex>.foldIndexToArtistList(serverId: Int): List<Artist> = this.fold(
|
||||
listOf()
|
||||
) { acc, index ->
|
||||
acc + index.artists.map {
|
||||
it.toDomainEntity()
|
||||
it.toDomainEntity(serverId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private fun List<APIIndex>.foldIndexToIndexList(musicFolderId: String?): List<Index> = this.fold(
|
||||
listOf(),
|
||||
{ acc, index ->
|
||||
private fun List<APIIndex>.foldIndexToIndexList(
|
||||
musicFolderId: String?,
|
||||
serverId: Int
|
||||
): List<Index> = this.fold(
|
||||
listOf()
|
||||
) { acc, index ->
|
||||
acc + index.artists.map {
|
||||
val ret = it.toIndexEntity()
|
||||
val ret = it.toIndexEntity(serverId)
|
||||
ret.musicFolderId = musicFolderId
|
||||
ret
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* APIMusicDirectoryConverter.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
@ -26,12 +26,12 @@ internal val dateFormat: DateFormat by lazy {
|
|||
SimpleDateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, Locale.getDefault())
|
||||
}
|
||||
|
||||
fun MusicDirectoryChild.toTrackEntity(): Track = Track(id).apply {
|
||||
fun MusicDirectoryChild.toTrackEntity(serverId: Int): Track = Track(id, serverId).apply {
|
||||
populateCommonProps(this, this@toTrackEntity)
|
||||
populateTrackProps(this, this@toTrackEntity)
|
||||
}
|
||||
|
||||
fun MusicDirectoryChild.toAlbumEntity(): Album = Album(id).apply {
|
||||
fun MusicDirectoryChild.toAlbumEntity(serverId: Int): Album = Album(id, serverId).apply {
|
||||
populateCommonProps(this, this@toAlbumEntity)
|
||||
}
|
||||
|
||||
|
@ -80,24 +80,24 @@ private fun populateTrackProps(
|
|||
track.averageRating = source.averageRating
|
||||
}
|
||||
|
||||
fun List<MusicDirectoryChild>.toDomainEntityList(): List<MusicDirectory.Child> {
|
||||
fun List<MusicDirectoryChild>.toDomainEntityList(serverId: Int): List<MusicDirectory.Child> {
|
||||
val newList: MutableList<MusicDirectory.Child> = mutableListOf()
|
||||
|
||||
forEach {
|
||||
if (it.isDir)
|
||||
newList.add(it.toAlbumEntity())
|
||||
newList.add(it.toAlbumEntity(serverId))
|
||||
else
|
||||
newList.add(it.toTrackEntity())
|
||||
newList.add(it.toTrackEntity(serverId))
|
||||
}
|
||||
|
||||
return newList
|
||||
}
|
||||
|
||||
fun List<MusicDirectoryChild>.toTrackList(): List<Track> = this.map {
|
||||
it.toTrackEntity()
|
||||
fun List<MusicDirectoryChild>.toTrackList(serverId: Int): List<Track> = this.map {
|
||||
it.toTrackEntity(serverId)
|
||||
}
|
||||
|
||||
fun APIMusicDirectory.toDomainEntity(): MusicDirectory = MusicDirectory().apply {
|
||||
fun APIMusicDirectory.toDomainEntity(serverId: Int): MusicDirectory = MusicDirectory().apply {
|
||||
name = this@toDomainEntity.name
|
||||
addAll(this@toDomainEntity.childList.toDomainEntityList())
|
||||
addAll(this@toDomainEntity.childList.toDomainEntityList(serverId))
|
||||
}
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
/*
|
||||
* APIMusicFolderConverter.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
// Converts MusicFolder entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
|
||||
// to app domain entities.
|
||||
@file:JvmName("APIMusicFolderConverter")
|
||||
|
@ -5,7 +12,15 @@ package org.moire.ultrasonic.domain
|
|||
|
||||
import org.moire.ultrasonic.api.subsonic.models.MusicFolder as APIMusicFolder
|
||||
|
||||
fun APIMusicFolder.toDomainEntity(): MusicFolder = MusicFolder(this.id, this.name)
|
||||
fun APIMusicFolder.toDomainEntity(serverId: Int): MusicFolder = MusicFolder(
|
||||
id = this.id,
|
||||
serverId = serverId,
|
||||
name = this.name
|
||||
)
|
||||
|
||||
fun List<APIMusicFolder>.toDomainEntityList(): List<MusicFolder> =
|
||||
this.map { it.toDomainEntity() }
|
||||
fun List<APIMusicFolder>.toDomainEntityList(serverId: Int): List<MusicFolder> =
|
||||
this.map {
|
||||
val item = it.toDomainEntity(serverId)
|
||||
item.serverId = serverId
|
||||
item
|
||||
}
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
/*
|
||||
* APIPlaylistConverter.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
// Converts Playlist entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
|
||||
// to app domain entities.
|
||||
@file:JvmName("APIPlaylistConverter")
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
|
@ -10,9 +18,16 @@ import org.moire.ultrasonic.util.Util.ifNotNull
|
|||
|
||||
internal val playlistDateFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
|
||||
|
||||
fun APIPlaylist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply {
|
||||
fun APIPlaylist.toMusicDirectoryDomainEntity(serverId: Int): MusicDirectory =
|
||||
MusicDirectory().apply {
|
||||
name = this@toMusicDirectoryDomainEntity.name
|
||||
addAll(this@toMusicDirectoryDomainEntity.entriesList.map { it.toTrackEntity() })
|
||||
addAll(
|
||||
this@toMusicDirectoryDomainEntity.entriesList.map {
|
||||
val item = it.toTrackEntity(serverId)
|
||||
item.serverId = serverId
|
||||
item
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun APIPlaylist.toDomainEntity(): Playlist = Playlist(
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
/*
|
||||
* APISearchConverter.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
// Converts SearchResult entities from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
|
||||
// to app domain entities.
|
||||
@file:JvmName("APISearchConverter")
|
||||
|
@ -7,19 +14,19 @@ import org.moire.ultrasonic.api.subsonic.models.SearchResult as APISearchResult
|
|||
import org.moire.ultrasonic.api.subsonic.models.SearchThreeResult
|
||||
import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult
|
||||
|
||||
fun APISearchResult.toDomainEntity(): SearchResult = SearchResult(
|
||||
fun APISearchResult.toDomainEntity(serverId: Int): SearchResult = SearchResult(
|
||||
emptyList(), emptyList(),
|
||||
this.matchList.map { it.toTrackEntity() }
|
||||
this.matchList.map { it.toTrackEntity(serverId) }
|
||||
)
|
||||
|
||||
fun SearchTwoResult.toDomainEntity(): SearchResult = SearchResult(
|
||||
this.artistList.map { it.toIndexEntity() },
|
||||
this.albumList.map { it.toDomainEntity() },
|
||||
this.songList.map { it.toTrackEntity() }
|
||||
fun SearchTwoResult.toDomainEntity(serverId: Int): SearchResult = SearchResult(
|
||||
this.artistList.map { it.toIndexEntity(serverId) },
|
||||
this.albumList.map { it.toDomainEntity(serverId) },
|
||||
this.songList.map { it.toTrackEntity(serverId) }
|
||||
)
|
||||
|
||||
fun SearchThreeResult.toDomainEntity(): SearchResult = SearchResult(
|
||||
this.artistList.map { it.toDomainEntity() },
|
||||
this.albumList.map { it.toDomainEntity() },
|
||||
this.songList.map { it.toTrackEntity() }
|
||||
fun SearchThreeResult.toDomainEntity(serverId: Int): SearchResult = SearchResult(
|
||||
this.artistList.map { it.toDomainEntity(serverId) },
|
||||
this.albumList.map { it.toDomainEntity(serverId) },
|
||||
this.songList.map { it.toTrackEntity(serverId) }
|
||||
)
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
/*
|
||||
* APIShareConverter.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
// Contains helper method to convert subsonic api share to domain model
|
||||
@file:JvmName("APIShareConverter")
|
||||
package org.moire.ultrasonic.domain
|
||||
|
@ -9,11 +16,11 @@ import org.moire.ultrasonic.util.Util.ifNotNull
|
|||
|
||||
internal val shareTimeFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
|
||||
|
||||
fun List<APIShare>.toDomainEntitiesList(): List<Share> = this.map {
|
||||
it.toDomainEntity()
|
||||
fun List<APIShare>.toDomainEntitiesList(serverId: Int): List<Share> = this.map {
|
||||
it.toDomainEntity(serverId)
|
||||
}
|
||||
|
||||
fun APIShare.toDomainEntity(): Share = Share(
|
||||
fun APIShare.toDomainEntity(serverId: Int): Share = Share(
|
||||
created = this@toDomainEntity.created.ifNotNull { shareTimeFormat.format(it.time) },
|
||||
description = this@toDomainEntity.description,
|
||||
expires = this@toDomainEntity.expires.ifNotNull { shareTimeFormat.format(it.time) },
|
||||
|
@ -22,5 +29,5 @@ fun APIShare.toDomainEntity(): Share = Share(
|
|||
url = this@toDomainEntity.url,
|
||||
username = this@toDomainEntity.username,
|
||||
visitCount = this@toDomainEntity.visitCount.toLong(),
|
||||
tracks = this@toDomainEntity.items.toTrackList().toMutableList()
|
||||
tracks = this@toDomainEntity.items.toTrackList(serverId).toMutableList()
|
||||
)
|
||||
|
|
|
@ -66,17 +66,17 @@ class MainFragment : Fragment(), KoinComponent {
|
|||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
var shouldRestart = false
|
||||
var shouldRelayout = false
|
||||
val currentId3Setting = Settings.shouldUseId3Tags
|
||||
|
||||
// If setting has changed...
|
||||
if (currentId3Setting != cachedId3Setting) {
|
||||
cachedId3Setting = currentId3Setting
|
||||
shouldRestart = true
|
||||
if (currentId3Setting != useId3) {
|
||||
useId3 = currentId3Setting
|
||||
shouldRelayout = true
|
||||
}
|
||||
|
||||
// then setup the list anew.
|
||||
if (shouldRestart) {
|
||||
if (shouldRelayout) {
|
||||
setupItemVisibility()
|
||||
}
|
||||
}
|
||||
|
@ -109,17 +109,19 @@ class MainFragment : Fragment(), KoinComponent {
|
|||
|
||||
private fun setupItemVisibility() {
|
||||
// Cache some values
|
||||
cachedId3Setting = Settings.shouldUseId3Tags
|
||||
useId3 = Settings.shouldUseId3Tags
|
||||
useId3Offline = Settings.useId3TagsOffline
|
||||
|
||||
val isOnline = !isOffline()
|
||||
|
||||
// Music
|
||||
musicTitle.isVisible = true
|
||||
artistsButton.isVisible = true
|
||||
albumsButton.isVisible = isOnline
|
||||
albumsButton.isVisible = isOnline || useId3Offline
|
||||
genresButton.isVisible = true
|
||||
|
||||
// Songs
|
||||
songsTitle.isVisible = isOnline
|
||||
songsTitle.isVisible = true
|
||||
randomSongsButton.isVisible = true
|
||||
songsStarredButton.isVisible = isOnline
|
||||
|
||||
|
@ -128,7 +130,7 @@ class MainFragment : Fragment(), KoinComponent {
|
|||
albumsNewestButton.isVisible = isOnline
|
||||
albumsRecentButton.isVisible = isOnline
|
||||
albumsFrequentButton.isVisible = isOnline
|
||||
albumsHighestButton.isVisible = isOnline && !cachedId3Setting
|
||||
albumsHighestButton.isVisible = isOnline && !useId3
|
||||
albumsRandomButton.isVisible = isOnline
|
||||
albumsStarredButton.isVisible = isOnline
|
||||
albumsAlphaByNameButton.isVisible = isOnline
|
||||
|
@ -240,6 +242,7 @@ class MainFragment : Fragment(), KoinComponent {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private var cachedId3Setting = false
|
||||
private var useId3 = false
|
||||
private var useId3Offline = false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,17 +33,22 @@ import android.widget.LinearLayout
|
|||
import android.widget.SeekBar
|
||||
import android.widget.SeekBar.OnSeekBarChangeListener
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import android.widget.ViewFlipper
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.navigation.Navigation
|
||||
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 com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
|
@ -747,7 +752,14 @@ class PlayerFragment :
|
|||
if (currentSong == null) return true
|
||||
|
||||
val isStarred = currentSong!!.starred
|
||||
val id = currentSong!!.id
|
||||
|
||||
mediaPlayerController.controller?.setRating(
|
||||
HeartRating(!isStarred)
|
||||
)?.let {
|
||||
Futures.addCallback(
|
||||
it,
|
||||
object : FutureCallback<SessionResult> {
|
||||
override fun onSuccess(result: SessionResult?) {
|
||||
if (isStarred) {
|
||||
starMenuItem.icon = hollowStar
|
||||
currentSong!!.starred = false
|
||||
|
@ -755,18 +767,17 @@ class PlayerFragment :
|
|||
starMenuItem.icon = fullStar
|
||||
currentSong!!.starred = true
|
||||
}
|
||||
Thread {
|
||||
val musicService = getMusicService()
|
||||
try {
|
||||
if (isStarred) {
|
||||
musicService.unstar(id, null, null)
|
||||
} else {
|
||||
musicService.star(id, null, null)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}.start()
|
||||
},
|
||||
this.executorService
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_bookmark_set -> {
|
||||
|
|
|
@ -6,11 +6,16 @@ import android.content.DialogInterface
|
|||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.text.SpannableString
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.DialogFragment
|
||||
|
@ -76,6 +81,7 @@ class SettingsFragment :
|
|||
private var directoryCacheTime: ListPreference? = null
|
||||
private var mediaButtonsEnabled: CheckBoxPreference? = null
|
||||
private var showArtistPicture: CheckBoxPreference? = null
|
||||
private var useId3TagsOffline: CheckBoxPreference? = null
|
||||
private var sharingDefaultDescription: EditTextPreference? = null
|
||||
private var sharingDefaultGreeting: EditTextPreference? = null
|
||||
private var sharingDefaultExpiration: TimeSpanPreference? = null
|
||||
|
@ -121,14 +127,39 @@ class SettingsFragment :
|
|||
pauseOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE)
|
||||
debugLogToFile = findPreference(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE)
|
||||
showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE)
|
||||
useId3TagsOffline = findPreference(Constants.PREFERENCES_KEY_ID3_TAGS_OFFLINE)
|
||||
customCacheLocation = findPreference(Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION)
|
||||
|
||||
sharingDefaultGreeting?.text = shareGreeting
|
||||
|
||||
setupTextColors()
|
||||
setupClearSearchPreference()
|
||||
setupCacheLocationPreference()
|
||||
setupBluetoothDevicePreferences()
|
||||
}
|
||||
|
||||
private fun setupTextColors(enabled: Boolean = shouldUseId3Tags) {
|
||||
val firstPart = getString(R.string.settings_use_id3_offline_warning)
|
||||
var secondPart = getString(R.string.settings_use_id3_offline_summary)
|
||||
|
||||
// Little hack to circumvent a bug in Android. If we just change the color,
|
||||
// the text is not refreshed. If we also change the string, it is refreshed.
|
||||
if (enabled) secondPart += " "
|
||||
|
||||
val color = if (enabled) "#bd5164" else "#813b48"
|
||||
|
||||
Timber.i(color)
|
||||
|
||||
val warning = SpannableString(firstPart + "\n" + secondPart)
|
||||
warning.setSpan(
|
||||
ForegroundColorSpan(Color.parseColor(color)), 0, firstPart.length, 0
|
||||
)
|
||||
warning.setSpan(
|
||||
StyleSpan(Typeface.BOLD), 0, firstPart.length, 0
|
||||
)
|
||||
useId3TagsOffline?.summary = warning
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
update()
|
||||
|
@ -196,7 +227,10 @@ class SettingsFragment :
|
|||
setDebugLogToFile(sharedPreferences.getBoolean(key, false))
|
||||
}
|
||||
Constants.PREFERENCES_KEY_ID3_TAGS -> {
|
||||
showArtistPicture!!.isEnabled = sharedPreferences.getBoolean(key, false)
|
||||
val enabled = sharedPreferences.getBoolean(key, false)
|
||||
showArtistPicture?.isEnabled = enabled
|
||||
useId3TagsOffline?.isEnabled = enabled
|
||||
setupTextColors(enabled)
|
||||
}
|
||||
Constants.PREFERENCES_KEY_THEME -> {
|
||||
RxBus.themeChangedEventPublisher.onNext(Unit)
|
||||
|
@ -372,6 +406,7 @@ class SettingsFragment :
|
|||
debugLogToFile?.summary = ""
|
||||
}
|
||||
showArtistPicture?.isEnabled = shouldUseId3Tags
|
||||
useId3TagsOffline?.isEnabled = shouldUseId3Tags
|
||||
}
|
||||
|
||||
private fun setHideMedia(hide: Boolean) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* TrackCollectionFragment.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
@ -31,6 +31,7 @@ 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
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
|
@ -47,6 +48,7 @@ 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 timber.log.Timber
|
||||
|
||||
/**
|
||||
* Displays a group of tracks, eg. the songs of an album, of a playlist etc.
|
||||
|
@ -61,11 +63,11 @@ import org.moire.ultrasonic.util.Util
|
|||
open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
||||
|
||||
private var albumButtons: View? = null
|
||||
internal var selectButton: ImageView? = null
|
||||
private var selectButton: ImageView? = null
|
||||
internal var playNowButton: ImageView? = null
|
||||
private var playNextButton: ImageView? = null
|
||||
private var playLastButton: ImageView? = null
|
||||
internal var pinButton: ImageView? = null
|
||||
private var pinButton: ImageView? = null
|
||||
private var unpinButton: ImageView? = null
|
||||
private var downloadButton: ImageView? = null
|
||||
private var deleteButton: ImageView? = null
|
||||
|
@ -144,11 +146,10 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
|
||||
// Update the buttons when the selection has changed
|
||||
viewAdapter.selectionRevision.observe(
|
||||
viewLifecycleOwner,
|
||||
{
|
||||
viewLifecycleOwner
|
||||
) {
|
||||
enableButtons()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
internal open fun setupButtons(view: View) {
|
||||
|
@ -267,10 +268,10 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
private val childCount: Int
|
||||
get() {
|
||||
val count = viewAdapter.getCurrentList().count()
|
||||
if (listModel.showHeader) {
|
||||
return count - 1
|
||||
return if (listModel.showHeader) {
|
||||
count - 1
|
||||
} else {
|
||||
return count
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -320,13 +321,13 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
} as List<Track>
|
||||
}
|
||||
|
||||
internal fun selectAllOrNone() {
|
||||
private fun selectAllOrNone() {
|
||||
val someUnselected = viewAdapter.selectedSet.size < childCount
|
||||
|
||||
selectAll(someUnselected, true)
|
||||
}
|
||||
|
||||
internal fun selectAll(selected: Boolean, toast: Boolean) {
|
||||
private fun selectAll(selected: Boolean, toast: Boolean) {
|
||||
var selectedCount = viewAdapter.selectedSet.size * -1
|
||||
|
||||
selectedCount += viewAdapter.setSelectionStatusOfAll(selected)
|
||||
|
@ -366,7 +367,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
deleteButton?.isVisible = (enabled && deleteEnabled)
|
||||
}
|
||||
|
||||
internal fun downloadBackground(save: Boolean) {
|
||||
private fun downloadBackground(save: Boolean) {
|
||||
var songs = getSelectedSongs()
|
||||
|
||||
if (songs.isEmpty()) {
|
||||
|
@ -426,6 +427,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
|
||||
override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = {
|
||||
|
||||
Timber.i("Received list")
|
||||
val entryList: MutableList<MusicDirectory.Child> = it.toMutableList()
|
||||
|
||||
if (listModel.currentListIsSortable && Settings.shouldSortByDisc) {
|
||||
|
@ -454,9 +456,9 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
moreButton!!.visibility = View.GONE
|
||||
} else {
|
||||
moreButton!!.visibility = View.VISIBLE
|
||||
if (arguments?.getInt(Constants.INTENT_RANDOM, 0) ?: 0 > 0) {
|
||||
if ((arguments?.getInt(Constants.INTENT_RANDOM, 0) ?: 0) > 0) {
|
||||
moreRandomTracks()
|
||||
} else if (arguments?.getString(Constants.INTENT_GENRE_NAME, "") ?: "" != "") {
|
||||
} else if ((arguments?.getString(Constants.INTENT_GENRE_NAME, "") ?: "") != "") {
|
||||
moreSongsForGenre()
|
||||
}
|
||||
}
|
||||
|
@ -497,6 +499,8 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
}
|
||||
|
||||
listModel.currentListIsSortable = true
|
||||
|
||||
Timber.i("Processed list")
|
||||
}
|
||||
|
||||
private fun moreSongsForGenre(args: Bundle = requireArguments()) {
|
||||
|
@ -556,6 +560,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
args: Bundle?,
|
||||
refresh: Boolean
|
||||
): LiveData<List<MusicDirectory.Child>> {
|
||||
Timber.i("Starting gathering track collection data...")
|
||||
if (args == null) return listModel.currentList
|
||||
val id = args.getString(Constants.INTENT_ID)
|
||||
val isAlbum = args.getBoolean(Constants.INTENT_IS_ALBUM, false)
|
||||
|
@ -600,7 +605,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
listModel.getRandom(albumListSize)
|
||||
} else {
|
||||
setTitle(name)
|
||||
if (!isOffline() && Settings.shouldUseId3Tags) {
|
||||
if (ActiveServerProvider.isID3Enabled()) {
|
||||
if (isAlbum) {
|
||||
listModel.getAlbum(refresh2, id!!, name)
|
||||
} else {
|
||||
|
@ -669,7 +674,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
return true
|
||||
}
|
||||
|
||||
internal fun getClickedSong(item: MusicDirectory.Child): List<Track> {
|
||||
private fun getClickedSong(item: MusicDirectory.Child): List<Track> {
|
||||
// This can probably be done better
|
||||
return viewAdapter.getCurrentList().mapNotNull {
|
||||
if (it is Track && (it.id == item.id))
|
||||
|
|
|
@ -39,7 +39,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
|
|||
id: String,
|
||||
name: String?
|
||||
) {
|
||||
list.postValue(musicService.getArtist(id, name, refresh))
|
||||
list.postValue(musicService.getAlbumsOfArtist(id, name, refresh))
|
||||
}
|
||||
|
||||
override fun load(
|
||||
|
|
|
@ -1,20 +1,8 @@
|
|||
/*
|
||||
This file is part of Subsonic.
|
||||
|
||||
Subsonic is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Subsonic is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Copyright 2020 (C) Jozsef Varga
|
||||
* ArtistListModel.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
package org.moire.ultrasonic.model
|
||||
|
||||
|
@ -24,6 +12,7 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import java.text.Collator
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.ArtistOrIndex
|
||||
import org.moire.ultrasonic.service.MusicService
|
||||
|
||||
|
@ -56,12 +45,10 @@ class ArtistListModel(application: Application) : GenericListModel(application)
|
|||
|
||||
val musicFolderId = activeServer.musicFolderId
|
||||
|
||||
val result: List<ArtistOrIndex>
|
||||
|
||||
if (!isOffline && useId3Tags) {
|
||||
result = musicService.getArtists(refresh)
|
||||
val result = if (ActiveServerProvider.isID3Enabled()) {
|
||||
musicService.getArtists(refresh)
|
||||
} else {
|
||||
result = musicService.getIndexes(musicFolderId, refresh)
|
||||
musicService.getIndexes(musicFolderId, refresh)
|
||||
}
|
||||
|
||||
artists.postValue(result.toMutableList().sortedWith(comparator))
|
||||
|
|
|
@ -251,15 +251,15 @@ open class APIDataSource private constructor(
|
|||
@Suppress("ThrowsCount")
|
||||
@Throws(HttpDataSourceException::class)
|
||||
private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) {
|
||||
var bytesToSkip = bytesToSkip
|
||||
if (bytesToSkip == 0L) {
|
||||
var bytesToSkipCpy = bytesToSkip
|
||||
if (bytesToSkipCpy == 0L) {
|
||||
return
|
||||
}
|
||||
val skipBuffer = ByteArray(4096)
|
||||
try {
|
||||
while (bytesToSkip > 0) {
|
||||
while (bytesToSkipCpy > 0) {
|
||||
val readLength =
|
||||
bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt()
|
||||
bytesToSkipCpy.coerceAtMost(skipBuffer.size.toLong()).toInt()
|
||||
val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength)
|
||||
if (Thread.currentThread().isInterrupted) {
|
||||
throw InterruptedIOException()
|
||||
|
@ -271,7 +271,7 @@ open class APIDataSource private constructor(
|
|||
HttpDataSourceException.TYPE_OPEN
|
||||
)
|
||||
}
|
||||
bytesToSkip -= read.toLong()
|
||||
bytesToSkipCpy -= read.toLong()
|
||||
bytesTransferred(read)
|
||||
}
|
||||
return
|
||||
|
@ -305,8 +305,8 @@ open class APIDataSource private constructor(
|
|||
*/
|
||||
@Throws(IOException::class)
|
||||
private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||
var readLength = readLength
|
||||
if (readLength == 0) {
|
||||
var readLengthCpy = readLength
|
||||
if (readLengthCpy == 0) {
|
||||
return 0
|
||||
}
|
||||
if (bytesToRead != C.LENGTH_UNSET.toLong()) {
|
||||
|
@ -314,9 +314,9 @@ open class APIDataSource private constructor(
|
|||
if (bytesRemaining == 0L) {
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt()
|
||||
readLengthCpy = readLengthCpy.toLong().coerceAtMost(bytesRemaining).toInt()
|
||||
}
|
||||
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength)
|
||||
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLengthCpy)
|
||||
if (read == -1) {
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ package org.moire.ultrasonic.playback
|
|||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
|
||||
|
@ -18,11 +21,17 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
|
|||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Rating
|
||||
import androidx.media3.session.LibraryResult
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.session.SessionResult.RESULT_ERROR_BAD_VALUE
|
||||
import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN
|
||||
import androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
@ -42,6 +51,7 @@ import org.moire.ultrasonic.domain.SearchResult
|
|||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
|
@ -81,19 +91,18 @@ private const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM"
|
|||
private const val DISPLAY_LIMIT = 100
|
||||
private const val SEARCH_LIMIT = 10
|
||||
|
||||
private const val SEARCH_QUERY_PREFIX_COMPAT = "androidx://media3-session/playFromSearch"
|
||||
private const val SEARCH_QUERY_PREFIX = "androidx://media3-session/setMediaUri"
|
||||
// List of available custom SessionCommands
|
||||
const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
|
||||
|
||||
/**
|
||||
* MediaBrowserService implementation for e.g. Android Auto
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
|
||||
class AutoMediaBrowserCallback(var player: Player) :
|
||||
MediaLibraryService.MediaLibrarySession.MediaLibrarySessionCallback, KoinComponent {
|
||||
class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
|
||||
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
|
||||
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private val musicService = MusicServiceFactory.getMusicService()
|
||||
|
||||
private val serviceJob = Job()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
||||
|
@ -103,6 +112,7 @@ class AutoMediaBrowserCallback(var player: Player) :
|
|||
private var randomSongsCache: List<Track>? = null
|
||||
private var searchSongsCache: List<Track>? = null
|
||||
|
||||
private val musicService get() = MusicServiceFactory.getMusicService()
|
||||
private val isOffline get() = ActiveServerProvider.isOffline()
|
||||
private val useId3Tags get() = Settings.shouldUseId3Tags
|
||||
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
|
||||
|
@ -154,6 +164,25 @@ class AutoMediaBrowserCallback(var player: Player) :
|
|||
)
|
||||
}
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
val connectionResult = super.onConnect(session, controller)
|
||||
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
||||
|
||||
/*
|
||||
* TODO: Currently we need to create a custom session command, see https://github.com/androidx/media/issues/107
|
||||
* When this issue is fixed we should be able to remove this method again
|
||||
*/
|
||||
availableSessionCommands.add(SessionCommand(SESSION_CUSTOM_SET_RATING, Bundle()))
|
||||
|
||||
return MediaSession.ConnectionResult.accept(
|
||||
availableSessionCommands.build(),
|
||||
connectionResult.availablePlayerCommands
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
|
@ -181,39 +210,120 @@ class AutoMediaBrowserCallback(var player: Player) :
|
|||
return onLoadChildren(parentId)
|
||||
}
|
||||
|
||||
private fun setMediaItemFromSearchQuery(query: String) {
|
||||
// Only accept query with pattern "play [Title]" or "[Title]"
|
||||
// Where [Title]: must be exactly matched
|
||||
// If no media with exact name found, play a random media instead
|
||||
val mediaTitle =
|
||||
if (query.startsWith("play ", ignoreCase = true)) {
|
||||
query.drop(5)
|
||||
} else {
|
||||
query
|
||||
}
|
||||
|
||||
playFromMediaId(mediaTitle)
|
||||
}
|
||||
|
||||
override fun onSetMediaUri(
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
uri: Uri,
|
||||
extras: Bundle
|
||||
): Int {
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
|
||||
if (uri.toString().startsWith(SEARCH_QUERY_PREFIX) ||
|
||||
uri.toString().startsWith(SEARCH_QUERY_PREFIX_COMPAT)
|
||||
) {
|
||||
val searchQuery =
|
||||
uri.getQueryParameter("query")
|
||||
?: return SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
setMediaItemFromSearchQuery(searchQuery)
|
||||
var customCommandFuture: ListenableFuture<SessionResult>? = null
|
||||
|
||||
return SessionResult.RESULT_SUCCESS
|
||||
} else {
|
||||
return SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
when (customCommand.customAction) {
|
||||
SESSION_CUSTOM_SET_RATING -> {
|
||||
/*
|
||||
* It is currently not possible to edit a MediaItem after creation so the isRated value
|
||||
* is stored in the track.starred value
|
||||
* See https://github.com/androidx/media/issues/33
|
||||
*/
|
||||
val track = mediaPlayerController.currentPlayingLegacy?.track
|
||||
if (track != null) {
|
||||
customCommandFuture = onSetRating(
|
||||
session,
|
||||
controller,
|
||||
HeartRating(!track.starred)
|
||||
)
|
||||
Futures.addCallback(
|
||||
customCommandFuture,
|
||||
object : FutureCallback<SessionResult> {
|
||||
override fun onSuccess(result: SessionResult) {
|
||||
track.starred = !track.starred
|
||||
// This needs to be called on the main Thread
|
||||
libraryService.onUpdateNotification(session)
|
||||
}
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
Toast.makeText(
|
||||
mediaPlayerController.context,
|
||||
"There was an error updating the rating",
|
||||
LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
},
|
||||
MainThreadExecutor()
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Timber.d(
|
||||
"CustomCommand not recognized %s with extra %s",
|
||||
customCommand.customAction,
|
||||
customCommand.customExtras.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
if (customCommandFuture != null)
|
||||
return customCommandFuture
|
||||
return super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
override fun onSetRating(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
rating: Rating
|
||||
): ListenableFuture<SessionResult> {
|
||||
if (session.player.currentMediaItem != null)
|
||||
return onSetRating(
|
||||
session,
|
||||
controller,
|
||||
session.player.currentMediaItem!!.mediaId,
|
||||
rating
|
||||
)
|
||||
return super.onSetRating(session, controller, rating)
|
||||
}
|
||||
|
||||
override fun onSetRating(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaId: String,
|
||||
rating: Rating
|
||||
): ListenableFuture<SessionResult> {
|
||||
return serviceScope.future {
|
||||
if (rating is HeartRating) {
|
||||
try {
|
||||
if (rating.isHeart) {
|
||||
musicService.star(mediaId, null, null)
|
||||
} else {
|
||||
musicService.unstar(mediaId, null, null)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
// TODO: Better handle exception
|
||||
return@future SessionResult(RESULT_ERROR_UNKNOWN)
|
||||
}
|
||||
return@future SessionResult(RESULT_SUCCESS)
|
||||
}
|
||||
return@future SessionResult(RESULT_ERROR_BAD_VALUE)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer,
|
||||
* and thereby customarily it is required to rebuild it..
|
||||
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
|
||||
*/
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
|
||||
val updatedMediaItems = mediaItems.map { mediaItem ->
|
||||
mediaItem.buildUpon()
|
||||
.setUri(mediaItem.requestMetadata.mediaUri)
|
||||
.build()
|
||||
}
|
||||
return Futures.immediateFuture(updatedMediaItems.toMutableList())
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "ComplexMethod")
|
||||
|
@ -525,7 +635,7 @@ class AutoMediaBrowserCallback(var player: Player) :
|
|||
|
||||
return serviceScope.future {
|
||||
val albums = if (!isOffline && useId3Tags) {
|
||||
callWithErrorHandling { musicService.getArtist(id, name, false) }
|
||||
callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
|
||||
} else {
|
||||
callWithErrorHandling {
|
||||
musicService.getMusicDirectory(id, name, false).getAlbums()
|
||||
|
@ -554,8 +664,8 @@ class AutoMediaBrowserCallback(var player: Player) :
|
|||
val songs = listSongsInMusicService(id, name)
|
||||
|
||||
if (songs != null) {
|
||||
if (songs.getChildren(includeDirs = true, includeFiles = false).count() == 0 &&
|
||||
songs.getChildren(includeDirs = false, includeFiles = true).count() > 0
|
||||
if (songs.getChildren(includeDirs = true, includeFiles = false).isEmpty() &&
|
||||
songs.getChildren(includeDirs = false, includeFiles = true).isNotEmpty()
|
||||
)
|
||||
mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|"))
|
||||
|
||||
|
@ -1096,6 +1206,7 @@ class AutoMediaBrowserCallback(var player: Player) :
|
|||
album = track.album,
|
||||
artist = track.artist,
|
||||
genre = track.genre,
|
||||
starred = track.starred
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1110,6 +1221,7 @@ class AutoMediaBrowserCallback(var player: Player) :
|
|||
genre: String? = null,
|
||||
sourceUri: Uri? = null,
|
||||
imageUri: Uri? = null,
|
||||
starred: Boolean = false
|
||||
): MediaItem {
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
|
@ -1117,6 +1229,7 @@ class AutoMediaBrowserCallback(var player: Player) :
|
|||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setGenre(genre)
|
||||
.setUserRating(HeartRating(starred))
|
||||
.setFolderType(folderType)
|
||||
.setIsPlayable(isPlayable)
|
||||
.setArtworkUri(imageUri)
|
||||
|
|
|
@ -101,8 +101,8 @@ class CachedDataSource(
|
|||
}
|
||||
|
||||
private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||
var readLength = readLength
|
||||
if (readLength == 0) {
|
||||
var readLengthCpy = readLength
|
||||
if (readLengthCpy == 0) {
|
||||
return 0
|
||||
}
|
||||
if (bytesToRead != C.LENGTH_UNSET.toLong()) {
|
||||
|
@ -110,9 +110,9 @@ class CachedDataSource(
|
|||
if (bytesRemaining == 0L) {
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt()
|
||||
readLengthCpy = readLengthCpy.toLong().coerceAtMost(bytesRemaining).toInt()
|
||||
}
|
||||
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength)
|
||||
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLengthCpy)
|
||||
if (read == -1) {
|
||||
Timber.i("CachedDatasource: EndOfInput")
|
||||
return C.RESULT_END_OF_INPUT
|
||||
|
@ -134,15 +134,15 @@ class CachedDataSource(
|
|||
@Suppress("ThrowsCount")
|
||||
@Throws(HttpDataSourceException::class)
|
||||
private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) {
|
||||
var bytesToSkip = bytesToSkip
|
||||
if (bytesToSkip == 0L) {
|
||||
var bytesToSkipCpy = bytesToSkip
|
||||
if (bytesToSkipCpy == 0L) {
|
||||
return
|
||||
}
|
||||
val skipBuffer = ByteArray(4096)
|
||||
try {
|
||||
while (bytesToSkip > 0) {
|
||||
while (bytesToSkipCpy > 0) {
|
||||
val readLength =
|
||||
bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt()
|
||||
bytesToSkipCpy.coerceAtMost(skipBuffer.size.toLong()).toInt()
|
||||
val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength)
|
||||
if (Thread.currentThread().isInterrupted) {
|
||||
throw InterruptedIOException()
|
||||
|
@ -154,7 +154,7 @@ class CachedDataSource(
|
|||
HttpDataSourceException.TYPE_OPEN
|
||||
)
|
||||
}
|
||||
bytesToSkip -= read.toLong()
|
||||
bytesToSkipCpy -= read.toLong()
|
||||
bytesTransferred(read)
|
||||
}
|
||||
return
|
||||
|
|
|
@ -50,7 +50,7 @@ class LegacyPlaylistManager : KoinComponent {
|
|||
|
||||
for (i in 0 until n) {
|
||||
val item = controller.getMediaItemAt(i)
|
||||
val file = mediaItemCache[item.mediaMetadata.mediaUri.toString()]
|
||||
val file = mediaItemCache[item.requestMetadata.toString()]
|
||||
if (file != null)
|
||||
_playlist.add(file)
|
||||
}
|
||||
|
@ -59,11 +59,11 @@ class LegacyPlaylistManager : KoinComponent {
|
|||
}
|
||||
|
||||
fun addToCache(item: MediaItem, file: DownloadFile) {
|
||||
mediaItemCache.put(item.mediaMetadata.mediaUri.toString(), file)
|
||||
mediaItemCache.put(item.requestMetadata.toString(), file)
|
||||
}
|
||||
|
||||
fun updateCurrentPlaying(item: MediaItem?) {
|
||||
currentPlaying = mediaItemCache[item?.mediaMetadata?.mediaUri.toString()]
|
||||
currentPlaying = mediaItemCache[item?.requestMetadata.toString()]
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
|
|
@ -7,144 +7,88 @@
|
|||
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.Assertions
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaNotification.ActionFactory
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
|
||||
@UnstableApi
|
||||
class MediaNotificationProvider(context: Context) :
|
||||
DefaultMediaNotificationProvider(context), KoinComponent {
|
||||
|
||||
/*
|
||||
* This is a copy of DefaultMediaNotificationProvider.java with some small changes
|
||||
* I have opened a bug https://github.com/androidx/media/issues/65 to make it easier to customize
|
||||
* the icons and actions without creating our own copy of this class..
|
||||
* It is currently not possible to edit a MediaItem after creation so the isRated value
|
||||
* is stored in the track.starred value. See https://github.com/androidx/media/issues/33
|
||||
* TODO: Once the bug is fixed remove this circular reference!
|
||||
*/
|
||||
@UnstableApi
|
||||
/* package */
|
||||
internal class MediaNotificationProvider(context: Context) :
|
||||
MediaNotification.Provider {
|
||||
private val context: Context = context.applicationContext
|
||||
private val notificationManager: NotificationManager = Assertions.checkStateNotNull(
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
)
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override fun createNotification(
|
||||
mediaController: MediaController,
|
||||
actionFactory: ActionFactory,
|
||||
onNotificationChangedCallback: MediaNotification.Provider.Callback
|
||||
): MediaNotification {
|
||||
ensureNotificationChannel()
|
||||
val builder: NotificationCompat.Builder = NotificationCompat.Builder(
|
||||
context,
|
||||
NOTIFICATION_CHANNEL_ID
|
||||
)
|
||||
// Skip to previous action.
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
IconCompat.createWithResource(
|
||||
context,
|
||||
R.drawable.media3_notification_seek_to_previous
|
||||
),
|
||||
context.getString(R.string.media3_controls_seek_to_previous_description),
|
||||
ActionFactory.COMMAND_SKIP_TO_PREVIOUS
|
||||
)
|
||||
)
|
||||
if (mediaController.playbackState == Player.STATE_ENDED ||
|
||||
!mediaController.playWhenReady
|
||||
) {
|
||||
// Play action.
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
IconCompat.createWithResource(context, R.drawable.media3_notification_play),
|
||||
context.getString(R.string.media3_controls_play_description),
|
||||
ActionFactory.COMMAND_PLAY
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Pause action.
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
IconCompat.createWithResource(context, R.drawable.media3_notification_pause),
|
||||
context.getString(R.string.media3_controls_pause_description),
|
||||
ActionFactory.COMMAND_PAUSE
|
||||
)
|
||||
override fun addNotificationActions(
|
||||
mediaSession: MediaSession,
|
||||
mediaButtons: MutableList<CommandButton>,
|
||||
builder: NotificationCompat.Builder,
|
||||
actionFactory: MediaNotification.ActionFactory
|
||||
): IntArray {
|
||||
val tmp: MutableList<CommandButton> = mutableListOf()
|
||||
/*
|
||||
* TODO:
|
||||
* It is currently not possible to edit a MediaItem after creation so the isRated value
|
||||
* is stored in the track.starred value
|
||||
* See https://github.com/androidx/media/issues/33
|
||||
*/
|
||||
val rating = mediaPlayerController.currentPlayingLegacy?.track?.starred?.let {
|
||||
HeartRating(
|
||||
it
|
||||
)
|
||||
}
|
||||
// Skip to next action.
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next),
|
||||
context.getString(R.string.media3_controls_seek_to_next_description),
|
||||
ActionFactory.COMMAND_SKIP_TO_NEXT
|
||||
if (rating is HeartRating) {
|
||||
tmp.add(
|
||||
CommandButton.Builder()
|
||||
.setDisplayName("Love")
|
||||
.setIconResId(
|
||||
if (rating.isHeart) R.drawable.ic_star_full_dark
|
||||
else R.drawable.ic_star_hollow_dark
|
||||
)
|
||||
.setSessionCommand(
|
||||
SessionCommand(
|
||||
SESSION_CUSTOM_SET_RATING,
|
||||
HeartRating(rating.isHeart).toBundle()
|
||||
)
|
||||
)
|
||||
|
||||
// Set metadata info in the notification.
|
||||
val metadata = mediaController.mediaMetadata
|
||||
builder.setContentTitle(metadata.title).setContentText(metadata.artist)
|
||||
if (metadata.artworkData != null) {
|
||||
val artworkBitmap =
|
||||
BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData!!.size)
|
||||
builder.setLargeIcon(artworkBitmap)
|
||||
}
|
||||
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
val notification: Notification = builder
|
||||
.setContentIntent(mediaController.sessionActivity)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSmallIcon(getSmallIconResId())
|
||||
.setStyle(mediaStyle)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(false)
|
||||
.setExtras(HeartRating(rating.isHeart).toBundle())
|
||||
.setEnabled(true)
|
||||
.build()
|
||||
return MediaNotification(
|
||||
NOTIFICATION_ID,
|
||||
notification
|
||||
)
|
||||
}
|
||||
return super.addNotificationActions(
|
||||
mediaSession,
|
||||
mediaButtons + tmp,
|
||||
builder,
|
||||
actionFactory
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleCustomAction(
|
||||
mediaController: MediaController,
|
||||
action: String,
|
||||
extras: Bundle
|
||||
) {
|
||||
// We don't handle custom commands.
|
||||
override fun getMediaButtons(
|
||||
playerCommands: Player.Commands,
|
||||
customLayout: MutableList<CommandButton>,
|
||||
playWhenReady: Boolean
|
||||
): MutableList<CommandButton> {
|
||||
val commands = super.getMediaButtons(playerCommands, customLayout, playWhenReady)
|
||||
|
||||
commands.forEachIndexed { index, command ->
|
||||
command.extras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, index)
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Util.SDK_INT < Build.VERSION_CODES.O ||
|
||||
notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NOTIFICATION_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.setShowBadge(false)
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
|
||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
|
||||
private const val NOTIFICATION_ID = 3032
|
||||
private fun getSmallIconResId(): Int {
|
||||
return R.drawable.ic_stat_ultrasonic
|
||||
}
|
||||
return commands
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,9 +11,7 @@ import android.content.Intent
|
|||
import android.os.Build
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.C.CONTENT_TYPE_MUSIC
|
||||
import androidx.media3.common.C.USAGE_MEDIA
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
|
@ -38,29 +36,12 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
private lateinit var apiDataSource: APIDataSource.Factory
|
||||
|
||||
private lateinit var librarySessionCallback: MediaLibrarySession.MediaLibrarySessionCallback
|
||||
private lateinit var librarySessionCallback: MediaLibrarySession.Callback
|
||||
|
||||
private var rxBusSubscription = CompositeDisposable()
|
||||
|
||||
private var isStarted = false
|
||||
|
||||
/*
|
||||
* For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer,
|
||||
* and thereby customarily it is required to rebuild it..
|
||||
*/
|
||||
private class CustomMediaItemFiller : MediaSession.MediaItemFiller {
|
||||
override fun fillInLocalConfiguration(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItem: MediaItem
|
||||
): MediaItem {
|
||||
// Again, set the Uri, so that it will get a LocalConfiguration
|
||||
return mediaItem.buildUpon()
|
||||
.setUri(mediaItem.mediaMetadata.mediaUri)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
Timber.i("onCreate called")
|
||||
super.onCreate()
|
||||
|
@ -130,11 +111,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||
player.experimentalSetOffloadSchedulingEnabled(true)
|
||||
|
||||
// Create browser interface
|
||||
librarySessionCallback = AutoMediaBrowserCallback(player)
|
||||
librarySessionCallback = AutoMediaBrowserCallback(player, this)
|
||||
|
||||
// This will need to use the AutoCalls
|
||||
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
||||
.setMediaItemFiller(CustomMediaItemFiller())
|
||||
.setSessionActivity(getPendingIntentForContent())
|
||||
.build()
|
||||
|
||||
|
@ -171,7 +151,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||
private fun getAudioAttributes(): AudioAttributes {
|
||||
return AudioAttributes.Builder()
|
||||
.setUsage(USAGE_MEDIA)
|
||||
.setContentType(CONTENT_TYPE_MUSIC)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,6 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
|||
|
||||
// Old style TimeLimitedCache
|
||||
private val cachedMusicDirectories: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
|
||||
private val cachedArtist: LRUCache<String, TimeLimitedCache<List<Album>>>
|
||||
private val cachedAlbum: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
|
||||
private val cachedUserInfo: LRUCache<String, TimeLimitedCache<UserInfo?>>
|
||||
private val cachedLicenseValid = TimeLimitedCache<Boolean>(120, TimeUnit.SECONDS)
|
||||
|
@ -53,7 +52,8 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
|||
private val cachedGenres = TimeLimitedCache<List<Genre>>(10 * 3600, TimeUnit.SECONDS)
|
||||
|
||||
// New Room Database
|
||||
private var cachedArtists = metaDatabase.artistsDao()
|
||||
private var cachedArtists = metaDatabase.artistDao()
|
||||
private var cachedAlbums = metaDatabase.albumDao()
|
||||
private var cachedIndexes = metaDatabase.indexDao()
|
||||
private val cachedMusicFolders = metaDatabase.musicFoldersDao()
|
||||
|
||||
|
@ -103,10 +103,10 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
|||
|
||||
var indexes: List<Index>
|
||||
|
||||
if (musicFolderId == null) {
|
||||
indexes = cachedIndexes.get()
|
||||
indexes = if (musicFolderId == null) {
|
||||
cachedIndexes.get()
|
||||
} else {
|
||||
indexes = cachedIndexes.get(musicFolderId)
|
||||
cachedIndexes.get(musicFolderId)
|
||||
}
|
||||
|
||||
if (indexes.isEmpty()) {
|
||||
|
@ -120,14 +120,15 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
|||
@Throws(Exception::class)
|
||||
override fun getArtists(refresh: Boolean): List<Artist> {
|
||||
checkSettingsChanged()
|
||||
|
||||
if (refresh) {
|
||||
cachedArtists.clear()
|
||||
}
|
||||
|
||||
var result = cachedArtists.get()
|
||||
|
||||
if (result.isEmpty()) {
|
||||
result = musicService.getArtists(refresh)
|
||||
cachedArtist.clear()
|
||||
cachedArtists.set(result)
|
||||
}
|
||||
return result
|
||||
|
@ -149,21 +150,29 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
|||
return dir
|
||||
}
|
||||
|
||||
/*
|
||||
* Retrieves all albums of the provided artist.
|
||||
* Cached in the RoomDB
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun getArtist(id: String, name: String?, refresh: Boolean):
|
||||
override fun getAlbumsOfArtist(id: String, name: String?, refresh: Boolean):
|
||||
List<Album> {
|
||||
checkSettingsChanged()
|
||||
var cache = if (refresh) null else cachedArtist[id]
|
||||
var dir = cache?.get()
|
||||
if (dir == null) {
|
||||
dir = musicService.getArtist(id, name, refresh)
|
||||
cache = TimeLimitedCache(
|
||||
Settings.directoryCacheTime.toLong(), TimeUnit.SECONDS
|
||||
)
|
||||
cache.set(dir)
|
||||
cachedArtist.put(id, cache)
|
||||
|
||||
var result: List<Album>
|
||||
|
||||
result = if (refresh) {
|
||||
cachedAlbums.clearByArtist(id)
|
||||
listOf()
|
||||
} else {
|
||||
cachedAlbums.byArtist(id)
|
||||
}
|
||||
return dir
|
||||
|
||||
if (result.isEmpty()) {
|
||||
result = musicService.getAlbumsOfArtist(id, name, refresh)
|
||||
cachedAlbums.upsert(result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -326,7 +335,8 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
|||
if (!Util.equals(newUrl, restUrl) || !Util.equals(cachedMusicFolderId, newFolderId)) {
|
||||
// Switch database
|
||||
metaDatabase = activeServerProvider.getActiveMetaDatabase()
|
||||
cachedArtists = metaDatabase.artistsDao()
|
||||
cachedArtists = metaDatabase.artistDao()
|
||||
cachedAlbums = metaDatabase.albumDao()
|
||||
cachedIndexes = metaDatabase.indexDao()
|
||||
|
||||
// Clear in memory caches
|
||||
|
@ -335,7 +345,6 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
|||
cachedPlaylists.clear()
|
||||
cachedGenres.clear()
|
||||
cachedAlbum.clear()
|
||||
cachedArtist.clear()
|
||||
cachedUserInfo.clear()
|
||||
|
||||
// Set the cache keys
|
||||
|
@ -472,7 +481,6 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
|||
|
||||
init {
|
||||
cachedMusicDirectories = LRUCache(MUSIC_DIR_CACHE_SIZE)
|
||||
cachedArtist = LRUCache(MUSIC_DIR_CACHE_SIZE)
|
||||
cachedAlbum = LRUCache(MUSIC_DIR_CACHE_SIZE)
|
||||
cachedUserInfo = LRUCache(MUSIC_DIR_CACHE_SIZE)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import java.util.PriorityQueue
|
|||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.MetaDatabase
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
||||
|
@ -402,6 +403,14 @@ class Downloader(
|
|||
downloadFile.completeFile
|
||||
)
|
||||
}
|
||||
|
||||
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
|
||||
try {
|
||||
downloadFile.track.cacheMetadata()
|
||||
} catch (ignore: Exception) {
|
||||
Timber.w(ignore)
|
||||
}
|
||||
|
||||
downloadFile.status.postValue(newStatus)
|
||||
return
|
||||
}
|
||||
|
@ -457,8 +466,10 @@ class Downloader(
|
|||
)
|
||||
}
|
||||
|
||||
if (downloadFile.track.artistId != null) {
|
||||
cacheMetadata(downloadFile.track.artistId!!)
|
||||
try {
|
||||
downloadFile.track.cacheMetadata()
|
||||
} catch (ignore: Exception) {
|
||||
Timber.w(ignore)
|
||||
}
|
||||
|
||||
downloadAndSaveCoverArt()
|
||||
|
@ -510,13 +521,35 @@ class Downloader(
|
|||
return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track)
|
||||
}
|
||||
|
||||
private fun cacheMetadata(artistId: String) {
|
||||
// TODO: Right now it's caching the track artist.
|
||||
// Once the albums are cached in db, we should retrieve the album,
|
||||
// and then cache the album artist.
|
||||
if (artistId.isEmpty()) return
|
||||
var artist: Artist? =
|
||||
activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId)
|
||||
private fun Track.cacheMetadata() {
|
||||
if (artistId.isNullOrEmpty()) return
|
||||
|
||||
val onlineDB = activeServerProvider.getActiveMetaDatabase()
|
||||
val offlineDB = activeServerProvider.offlineMetaDatabase
|
||||
|
||||
cacheArtist(onlineDB, offlineDB, artistId!!)
|
||||
|
||||
// Now cache the album
|
||||
if (albumId?.isNotEmpty() == true) {
|
||||
// This is a cached call
|
||||
val albums = musicService.getAlbumsOfArtist(artistId!!, null, false)
|
||||
val album = albums.find { it.id == albumId }
|
||||
|
||||
if (album != null) {
|
||||
offlineDB.albumDao().insert(album)
|
||||
|
||||
// If the album is a Compilation, also cache the Album artist
|
||||
if (album.artistId != null && album.artistId != artistId)
|
||||
cacheArtist(onlineDB, offlineDB, album.artistId!!)
|
||||
}
|
||||
}
|
||||
|
||||
// Now cache the track data
|
||||
offlineDB.trackDao().insert(this)
|
||||
}
|
||||
|
||||
private fun cacheArtist(onlineDB: MetaDatabase, offlineDB: MetaDatabase, artistId: String) {
|
||||
var artist: Artist? = onlineDB.artistDao().get(artistId)
|
||||
|
||||
// If we are downloading a new album, and the user has not visited the Artists list
|
||||
// recently, then the artist won't be in the database.
|
||||
|
@ -527,9 +560,9 @@ class Downloader(
|
|||
}
|
||||
}
|
||||
|
||||
// If we have found an artist, catch it.
|
||||
// If we have found an artist, cache it.
|
||||
if (artist != null) {
|
||||
activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist)
|
||||
offlineDB.artistDao().insert(artist)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,13 +9,18 @@ package org.moire.ultrasonic.service
|
|||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
@ -34,6 +39,7 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
|
|||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -579,25 +585,32 @@ class MediaPlayerController(
|
|||
if (legacyPlaylistManager.currentPlaying == null) return
|
||||
val song = legacyPlaylistManager.currentPlaying!!.track
|
||||
|
||||
Thread {
|
||||
val musicService = getMusicService()
|
||||
try {
|
||||
if (song.starred) {
|
||||
musicService.unstar(song.id, null, null)
|
||||
} else {
|
||||
musicService.star(song.id, null, null)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}.start()
|
||||
|
||||
controller?.setRating(
|
||||
HeartRating(!song.starred)
|
||||
).let {
|
||||
Futures.addCallback(
|
||||
it,
|
||||
object : FutureCallback<SessionResult> {
|
||||
override fun onSuccess(result: SessionResult?) {
|
||||
// Trigger an update
|
||||
// TODO Update Metadata of MediaItem...
|
||||
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
||||
song.starred = !song.starred
|
||||
}
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"There was an error updating the rating",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
},
|
||||
MainThreadExecutor()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
||||
fun setSongRating(rating: Int) {
|
||||
if (!Settings.useFiveStarRating) return
|
||||
|
@ -659,16 +672,22 @@ fun Track.toMediaItem(): MediaItem {
|
|||
val bitrate = Settings.maxBitRate
|
||||
val uri = "$id|$bitrate|$filePath"
|
||||
|
||||
val rmd = MediaItem.RequestMetadata.Builder()
|
||||
.setMediaUri(uri.toUri())
|
||||
.build()
|
||||
|
||||
val metadata = MediaMetadata.Builder()
|
||||
metadata.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.setMediaUri(uri.toUri())
|
||||
.setAlbumArtist(artist)
|
||||
.setUserRating(HeartRating(starred))
|
||||
.build()
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMediaId(id)
|
||||
.setRequestMetadata(rmd)
|
||||
.setMediaMetadata(metadata.build())
|
||||
|
||||
return mediaItem.build()
|
||||
|
|
|
@ -59,7 +59,7 @@ interface MusicService {
|
|||
fun getMusicDirectory(id: String, name: String?, refresh: Boolean): MusicDirectory
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getArtist(id: String, name: String?, refresh: Boolean): List<Album>
|
||||
fun getAlbumsOfArtist(id: String, name: String?, refresh: Boolean): List<Album>
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* OfflineMusicService.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
@ -23,6 +23,7 @@ import java.util.regex.Pattern
|
|||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.MetaDatabase
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
import org.moire.ultrasonic.domain.ArtistOrIndex
|
||||
|
@ -43,6 +44,7 @@ import org.moire.ultrasonic.domain.Track
|
|||
import org.moire.ultrasonic.domain.UserInfo
|
||||
import org.moire.ultrasonic.util.AbstractFile
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import org.moire.ultrasonic.util.Util.safeClose
|
||||
|
@ -52,12 +54,19 @@ import timber.log.Timber
|
|||
class OfflineMusicService : MusicService, KoinComponent {
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private var metaDatabase: MetaDatabase = activeServerProvider.getActiveMetaDatabase()
|
||||
|
||||
// New Room Database
|
||||
private var cachedArtists = metaDatabase.artistDao()
|
||||
private var cachedAlbums = metaDatabase.albumDao()
|
||||
private var cachedTracks = metaDatabase.trackDao()
|
||||
|
||||
override fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index> {
|
||||
val indexes: MutableList<Index> = ArrayList()
|
||||
val root = FileUtil.musicDirectory
|
||||
for (file in FileUtil.listFiles(root)) {
|
||||
if (file.isDirectory) {
|
||||
val index = Index(file.path)
|
||||
val index = Index(id = file.path)
|
||||
index.id = file.path
|
||||
index.index = file.name.substring(0, 1)
|
||||
index.name = file.name
|
||||
|
@ -97,6 +106,13 @@ class OfflineMusicService : MusicService, KoinComponent {
|
|||
return indexes
|
||||
}
|
||||
|
||||
@Throws(OfflineException::class)
|
||||
override fun getArtists(refresh: Boolean): List<Artist> {
|
||||
val result = cachedArtists.get()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/*
|
||||
* Especially when dealing with indexes, this method can return Albums, Entries or a mix of both!
|
||||
*/
|
||||
|
@ -312,7 +328,8 @@ class OfflineMusicService : MusicService, KoinComponent {
|
|||
offset: Int,
|
||||
musicFolderId: String?
|
||||
): List<Album> {
|
||||
throw OfflineException("getAlbumList2 isn't available in offline mode")
|
||||
// TODO: Implement filtering by musicFolder?
|
||||
return cachedAlbums.get(size, offset)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -450,20 +467,39 @@ class OfflineMusicService : MusicService, KoinComponent {
|
|||
|
||||
override fun isLicenseValid(): Boolean = true
|
||||
|
||||
@Throws(OfflineException::class)
|
||||
override fun getArtists(refresh: Boolean): List<Artist> {
|
||||
throw OfflineException("getArtists isn't available in offline mode")
|
||||
@Throws(Exception::class)
|
||||
override fun getAlbumsOfArtist(id: String, name: String?, refresh: Boolean):
|
||||
List<Album> {
|
||||
val directAlbums = cachedAlbums.byArtist(id)
|
||||
|
||||
// The direct albums won't contain any compilations that the artist has participated in
|
||||
// We need to fetch the tracks of the artist and then gather the compilation albums from that.
|
||||
val tracks = cachedTracks.byArtist(id)
|
||||
val albumIds = tracks.map {
|
||||
it.albumId
|
||||
}.distinct().filterNotNull()
|
||||
|
||||
val compilationAlbums = albumIds.map {
|
||||
cachedAlbums.get(it)
|
||||
}
|
||||
|
||||
@Throws(OfflineException::class)
|
||||
override fun getArtist(id: String, name: String?, refresh: Boolean):
|
||||
List<Album> {
|
||||
throw OfflineException("getArtist isn't available in offline mode")
|
||||
return directAlbums.plus(compilationAlbums).distinct()
|
||||
}
|
||||
|
||||
@Throws(OfflineException::class)
|
||||
override fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory {
|
||||
throw OfflineException("getAlbum isn't available in offline mode")
|
||||
|
||||
Timber.i("Starting album query...")
|
||||
|
||||
val list = cachedTracks
|
||||
.byAlbum(id)
|
||||
.sortedWith(EntryByDiscAndTrackComparator())
|
||||
|
||||
val dir = MusicDirectory()
|
||||
dir.addAll(list)
|
||||
|
||||
Timber.i("Returning query.")
|
||||
return dir
|
||||
}
|
||||
|
||||
@Throws(OfflineException::class)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* RestMusicService.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
* RESTMusicService.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
@ -78,7 +78,7 @@ open class RESTMusicService(
|
|||
): List<MusicFolder> {
|
||||
val response = API.getMusicFolders().execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.musicFolders.toDomainEntityList()
|
||||
return response.body()!!.musicFolders.toDomainEntityList(activeServerId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -91,7 +91,10 @@ open class RESTMusicService(
|
|||
): List<Index> {
|
||||
val response = API.getIndexes(musicFolderId, null).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.indexes.toIndexList(musicFolderId)
|
||||
return response.body()!!.indexes.toIndexList(
|
||||
ActiveServerProvider.getActiveServerId(),
|
||||
musicFolderId
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -100,7 +103,7 @@ open class RESTMusicService(
|
|||
): List<Artist> {
|
||||
val response = API.getArtists(null).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.indexes.toArtistList()
|
||||
return response.body()!!.indexes.toArtistList(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -137,18 +140,18 @@ open class RESTMusicService(
|
|||
): MusicDirectory {
|
||||
val response = API.getMusicDirectory(id).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.musicDirectory.toDomainEntity()
|
||||
return response.body()!!.musicDirectory.toDomainEntity(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun getArtist(
|
||||
override fun getAlbumsOfArtist(
|
||||
id: String,
|
||||
name: String?,
|
||||
refresh: Boolean
|
||||
): List<Album> {
|
||||
val response = API.getArtist(id).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.artist.toDomainEntityList()
|
||||
return response.body()!!.artist.toDomainEntityList(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -159,7 +162,7 @@ open class RESTMusicService(
|
|||
): MusicDirectory {
|
||||
val response = API.getAlbum(id).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.album.toMusicDirectoryDomainEntity()
|
||||
return response.body()!!.album.toMusicDirectoryDomainEntity(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -189,7 +192,7 @@ open class RESTMusicService(
|
|||
API.search(null, null, null, criteria.query, criteria.songCount, null, null)
|
||||
.execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.searchResult.toDomainEntity()
|
||||
return response.body()!!.searchResult.toDomainEntity(activeServerId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -205,7 +208,7 @@ open class RESTMusicService(
|
|||
criteria.songCount, null
|
||||
).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.searchResult.toDomainEntity()
|
||||
return response.body()!!.searchResult.toDomainEntity(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -218,7 +221,7 @@ open class RESTMusicService(
|
|||
criteria.songCount, null
|
||||
).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.searchResult.toDomainEntity()
|
||||
return response.body()!!.searchResult.toDomainEntity(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -228,7 +231,7 @@ open class RESTMusicService(
|
|||
): MusicDirectory {
|
||||
val response = API.getPlaylist(id).execute().throwOnFailure()
|
||||
|
||||
val playlist = response.body()!!.playlist.toMusicDirectoryDomainEntity()
|
||||
val playlist = response.body()!!.playlist.toMusicDirectoryDomainEntity(activeServerId)
|
||||
savePlaylist(name, playlist)
|
||||
|
||||
return playlist
|
||||
|
@ -319,7 +322,7 @@ open class RESTMusicService(
|
|||
"skipped" != podcastEntry.status &&
|
||||
"error" != podcastEntry.status
|
||||
) {
|
||||
val entry = podcastEntry.toTrackEntity()
|
||||
val entry = podcastEntry.toTrackEntity(activeServerId)
|
||||
entry.track = null
|
||||
musicDirectory.add(entry)
|
||||
}
|
||||
|
@ -363,7 +366,7 @@ open class RESTMusicService(
|
|||
musicFolderId
|
||||
).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.albumList.toDomainEntityList()
|
||||
return response.body()!!.albumList.toDomainEntityList(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -383,7 +386,7 @@ open class RESTMusicService(
|
|||
musicFolderId
|
||||
).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.albumList.toDomainEntityList()
|
||||
return response.body()!!.albumList.toDomainEntityList(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -399,7 +402,7 @@ open class RESTMusicService(
|
|||
).execute().throwOnFailure()
|
||||
|
||||
val result = MusicDirectory()
|
||||
result.addAll(response.body()!!.songsList.toDomainEntityList())
|
||||
result.addAll(response.body()!!.songsList.toDomainEntityList(activeServerId))
|
||||
|
||||
return result
|
||||
}
|
||||
|
@ -408,14 +411,14 @@ open class RESTMusicService(
|
|||
override fun getStarred(): SearchResult {
|
||||
val response = API.getStarred(null).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.starred.toDomainEntity()
|
||||
return response.body()!!.starred.toDomainEntity(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun getStarred2(): SearchResult {
|
||||
val response = API.getStarred2(null).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.starred2.toDomainEntity()
|
||||
return response.body()!!.starred2.toDomainEntity(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -546,7 +549,7 @@ open class RESTMusicService(
|
|||
): List<Share> {
|
||||
val response = API.getShares().execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.shares.toDomainEntitiesList()
|
||||
return response.body()!!.shares.toDomainEntitiesList(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -567,7 +570,7 @@ open class RESTMusicService(
|
|||
val response = API.getSongsByGenre(genre, count, offset, null).execute().throwOnFailure()
|
||||
|
||||
val result = MusicDirectory()
|
||||
result.addAll(response.body()!!.songsList.toDomainEntityList())
|
||||
result.addAll(response.body()!!.songsList.toDomainEntityList(activeServerId))
|
||||
|
||||
return result
|
||||
}
|
||||
|
@ -601,7 +604,7 @@ open class RESTMusicService(
|
|||
override fun getBookmarks(): List<Bookmark> {
|
||||
val response = API.getBookmarks().execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.bookmarkList.toDomainEntitiesList()
|
||||
return response.body()!!.bookmarkList.toDomainEntitiesList(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -626,7 +629,7 @@ open class RESTMusicService(
|
|||
val response = API.getVideos().execute().throwOnFailure()
|
||||
|
||||
val musicDirectory = MusicDirectory()
|
||||
musicDirectory.addAll(response.body()!!.videosList.toDomainEntityList())
|
||||
musicDirectory.addAll(response.body()!!.videosList.toDomainEntityList(activeServerId))
|
||||
|
||||
return musicDirectory
|
||||
}
|
||||
|
@ -639,7 +642,7 @@ open class RESTMusicService(
|
|||
): List<Share> {
|
||||
val response = API.createShare(ids, description, expires).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.shares.toDomainEntitiesList()
|
||||
return response.body()!!.shares.toDomainEntitiesList(activeServerId)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
|
@ -663,6 +666,9 @@ open class RESTMusicService(
|
|||
API.updateShare(id, description, expiresValue).execute().throwOnFailure()
|
||||
}
|
||||
|
||||
private val activeServerId: Int
|
||||
get() = ActiveServerProvider.getActiveServerId()
|
||||
|
||||
init {
|
||||
// The client will notice if the minimum supported API version has changed
|
||||
// By registering a callback we ensure this info is saved in the database as well
|
||||
|
|
|
@ -269,7 +269,7 @@ class DownloadHandler(
|
|||
return
|
||||
}
|
||||
val musicService = getMusicService()
|
||||
val artist = musicService.getArtist(id, "", false)
|
||||
val artist = musicService.getAlbumsOfArtist(id, "", false)
|
||||
for ((id1) in artist) {
|
||||
val albumDirectory = musicService.getAlbum(
|
||||
id1,
|
||||
|
|
|
@ -86,6 +86,7 @@ object Constants {
|
|||
const val PREFERENCES_KEY_INCREMENT_TIME = "incrementTime"
|
||||
const val PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS = "showNowPlayingDetails"
|
||||
const val PREFERENCES_KEY_ID3_TAGS = "useId3Tags"
|
||||
const val PREFERENCES_KEY_ID3_TAGS_OFFLINE = "useId3TagsOffline"
|
||||
const val PREFERENCES_KEY_SHOW_ARTIST_PICTURE = "showArtistPicture"
|
||||
const val PREFERENCES_KEY_CHAT_REFRESH_INTERVAL = "chatRefreshInterval"
|
||||
const val PREFERENCES_KEY_DIRECTORY_CACHE_TIME = "directoryCacheTime"
|
||||
|
@ -104,6 +105,7 @@ object Constants {
|
|||
const val PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE = "pauseOnBluetoothDevice"
|
||||
const val PREFERENCES_KEY_DEBUG_LOG_TO_FILE = "debugLogToFile"
|
||||
const val PREFERENCES_KEY_OVERRIDE_LANGUAGE = "overrideLanguage"
|
||||
const val PREFERENCES_FIRST_INSTALLED_VERSION = "firstInstalledVersion"
|
||||
const val PREFERENCE_VALUE_ALL = 0
|
||||
const val PREFERENCE_VALUE_A2DP = 1
|
||||
const val PREFERENCE_VALUE_DISABLED = 2
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* MainThreadExecutor.java
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/*
|
||||
* Executor for running Futures on the main thread
|
||||
* See https://stackoverflow.com/questions/52642246/how-to-get-executor-for-main-thread-on-api-level-28
|
||||
*/
|
||||
class MainThreadExecutor : Executor {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
override fun execute(r: Runnable) {
|
||||
handler.post(r)
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Settings.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
@ -13,7 +13,6 @@ import androidx.preference.PreferenceManager
|
|||
import java.util.regex.Pattern
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
|
||||
/**
|
||||
* Contains convenience functions for reading and writing preferences
|
||||
|
@ -131,6 +130,7 @@ object Settings {
|
|||
@JvmStatic
|
||||
var mediaButtonsEnabled
|
||||
by BooleanSetting(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true)
|
||||
|
||||
var resumePlayOnHeadphonePlug
|
||||
by BooleanSetting(R.string.setting_keys_resume_play_on_headphones_plug, true)
|
||||
|
||||
|
@ -160,9 +160,14 @@ object Settings {
|
|||
var showNowPlayingDetails
|
||||
by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS, false)
|
||||
|
||||
// Normally you don't need to use these Settings directly,
|
||||
// use ActiveServerProvider.isID3Enabled() instead
|
||||
@JvmStatic
|
||||
var shouldUseId3Tags
|
||||
by BooleanSetting(Constants.PREFERENCES_KEY_ID3_TAGS, false)
|
||||
var shouldUseId3Tags by BooleanSetting(Constants.PREFERENCES_KEY_ID3_TAGS, false)
|
||||
|
||||
// See comment above.
|
||||
@JvmStatic
|
||||
var useId3TagsOffline by BooleanSetting(Constants.PREFERENCES_KEY_ID3_TAGS_OFFLINE, false)
|
||||
|
||||
var activeServer by IntSetting(Constants.PREFERENCES_KEY_SERVER_INSTANCE, -1)
|
||||
|
||||
|
@ -170,15 +175,8 @@ object Settings {
|
|||
|
||||
var firstRunExecuted by BooleanSetting(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, false)
|
||||
|
||||
val shouldShowArtistPicture: Boolean
|
||||
get() {
|
||||
val preferences = preferences
|
||||
val isOffline = ActiveServerProvider.isOffline()
|
||||
val isId3Enabled = preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false)
|
||||
val shouldShowArtistPicture =
|
||||
preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false)
|
||||
return !isOffline && isId3Enabled && shouldShowArtistPicture
|
||||
}
|
||||
val shouldShowArtistPicture
|
||||
by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false)
|
||||
|
||||
@JvmStatic
|
||||
var chatRefreshInterval by StringIntSetting(
|
||||
|
@ -253,6 +251,9 @@ object Settings {
|
|||
|
||||
var useHwOffload by BooleanSetting(Constants.PREFERENCES_KEY_HARDWARE_OFFLOAD, false)
|
||||
|
||||
@JvmStatic
|
||||
var firstInstalledVersion by IntSetting(Constants.PREFERENCES_FIRST_INSTALLED_VERSION, 0)
|
||||
|
||||
// TODO: Remove in December 2022
|
||||
fun migrateFeatureStorage() {
|
||||
val sp = appContext.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:color="?android:colorControlHighlight"
|
||||
tools:targetApi="lollipop">
|
||||
android:color="?android:colorControlHighlight">
|
||||
|
||||
<item android:id="@android:id/mask">
|
||||
<shape android:shape="rectangle">
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="m12,3.8438c-4.9703,0 -9,4.0292 -9,9l0,5.0625c0,1.2426 1.0074,2.25 2.25,2.25 1.2426,0 2.25,-1.0074 2.25,-2.25l0,-3.375c0,-1.2426 -1.0074,-2.25 -2.25,-2.25 -0.4067,0 -0.783,0.1164 -1.1121,0.3049C4.2752,8.3573 7.7379,4.9688 12,4.9688 16.2621,4.9688 19.7242,8.3573 19.8621,12.5861 19.5336,12.3977 19.1567,12.2813 18.75,12.2813c-1.2426,0 -2.25,1.0074 -2.25,2.25l0,3.375c0,1.2426 1.0074,2.25 2.25,2.25 1.2426,0 2.25,-1.0074 2.25,-2.25L21,12.8438C21,7.8729 16.9708,3.8438 12,3.8438ZM5.25,13.4063c0.621,0 1.125,0.504 1.125,1.125l0,3.375c0,0.621 -0.504,1.125 -1.125,1.125 -0.621,0 -1.125,-0.504 -1.125,-1.125l0,-3.375c0,-0.621 0.504,-1.125 1.125,-1.125zM19.875,17.9063c0,0.621 -0.504,1.125 -1.125,1.125 -0.621,0 -1.125,-0.504 -1.125,-1.125l0,-3.375c0,-0.621 0.504,-1.125 1.125,-1.125 0.621,0 1.125,0.504 1.125,1.125z"/>
|
||||
</vector>
|
|
@ -1,9 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:color="?android:colorControlHighlight"
|
||||
tools:targetApi="lollipop">
|
||||
android:color="?android:colorControlHighlight">
|
||||
|
||||
<item android:id="@android:id/mask">
|
||||
<shape android:shape="rectangle">
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:color="?android:colorControlHighlight"
|
||||
tools:targetApi="lollipop">
|
||||
android:color="?android:colorControlHighlight">
|
||||
|
||||
<item android:id="@android:id/mask">
|
||||
<shape android:shape="oval">
|
||||
|
|
|
@ -17,6 +17,10 @@
|
|||
<item>3</item>
|
||||
<item>5</item>
|
||||
<item>10</item>
|
||||
<item>50</item>
|
||||
<item>100</item>
|
||||
<item>500</item>
|
||||
<item>1000</item>
|
||||
<item>-1</item>
|
||||
</string-array>
|
||||
<string-array name="preloadCountNames" translatable="false">
|
||||
|
|
|
@ -316,6 +316,9 @@
|
|||
<string name="settings.show_now_playing_details">Show details in Now Playing</string>
|
||||
<string name="settings.use_id3">Browse Using ID3 Tags</string>
|
||||
<string name="settings.use_id3_summary">Use ID3 tag methods instead of file system based methods</string>
|
||||
<string name="settings.use_id3_offline">Use ID3 method also when offline</string>
|
||||
<string name="settings.use_id3_offline_warning">Experimental: If you enable this Setting it will only show the music that you have downloaded with Ultrasonic 4.0 or later.</string>
|
||||
<string name="settings.use_id3_offline_summary">Earlier downloads don\'t have the necessary metadata downloaded. You can toggle between Pin and Save mode to trigger the download of the missing metadata.</string>
|
||||
<string name="settings.show_artist_picture">Show artist picture in artist list</string>
|
||||
<string name="settings.show_artist_picture_summary">Displays the artist picture in the artist list if available</string>
|
||||
<string name="main.video" tools:ignore="UnusedResources">Video</string>
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Targeting Android 11 or lower -->
|
||||
<full-backup-content>
|
||||
<!-- The following "exclude" elements are not part of the auto backup -->
|
||||
<!--exclude domain="database" path="name.db" /-->
|
||||
<exclude domain="root"/>
|
||||
<!-- Exclude specific shared preferences that contain GCM registration Id -->
|
||||
|
||||
<!-- The following "include" elements are part of the auto backup -->
|
||||
<include domain="sharedpref" path="."/>
|
||||
<include domain="database" path="."/>
|
||||
</full-backup-content>
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Targeting Android 12 or higher -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||
<!--include domain=["file" | "database" | "sharedpref" | "external" | "root"] path="string"/-->
|
||||
<!--exclude domain=["file" | "database" | "sharedpref" | "external" | "root"] path="string"/-->
|
||||
<exclude domain="root"/>
|
||||
<include domain="sharedpref" path="."/>
|
||||
<include domain="database" path="."/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<!-- Specifying nothing here means: Include all: https://developer.android.com/guide/topics/data/autobackup#include-exclude-android-11 -->
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
|
@ -59,6 +59,12 @@
|
|||
a:summary="@string/settings.use_id3_summary"
|
||||
a:title="@string/settings.use_id3"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="true"
|
||||
a:key="useId3TagsOffline"
|
||||
a:summary="@string/settings.use_id3_offline_summary"
|
||||
a:title="@string/settings.use_id3_offline"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="true"
|
||||
a:key="showArtistPicture"
|
||||
|
|
|
@ -12,6 +12,8 @@ import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
|
|||
* Unit test for extension functions in [APIAlbumConverter.kt] file.
|
||||
*/
|
||||
class APIAlbumConverterTest {
|
||||
private val serverId = -1
|
||||
|
||||
@Test
|
||||
fun `Should convert Album to domain entity`() {
|
||||
val entity = Album(
|
||||
|
@ -20,7 +22,7 @@ class APIAlbumConverterTest {
|
|||
created = Calendar.getInstance(), year = 2017, genre = "some-genre"
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toDomainEntity()
|
||||
val convertedEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
id `should be equal to` entity.id
|
||||
|
@ -46,12 +48,12 @@ class APIAlbumConverterTest {
|
|||
songList = listOf(MusicDirectoryChild())
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toMusicDirectoryDomainEntity()
|
||||
val convertedEntity = entity.toMusicDirectoryDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
name `should be equal to` null
|
||||
size `should be equal to` entity.songList.size
|
||||
this[0] `should be equal to` entity.songList[0].toTrackEntity()
|
||||
this[0] `should be equal to` entity.songList[0].toTrackEntity(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -59,12 +61,12 @@ class APIAlbumConverterTest {
|
|||
fun `Should convert list of Album entities to domain list entities`() {
|
||||
val entityList = listOf(Album(id = "455"), Album(id = "1"), Album(id = "1000"))
|
||||
|
||||
val convertedList = entityList.toDomainEntityList()
|
||||
val convertedList = entityList.toDomainEntityList(serverId)
|
||||
|
||||
with(convertedList) {
|
||||
size `should be equal to` entityList.size
|
||||
forEachIndexed { index, entry ->
|
||||
entry `should be equal to` entityList[index].toDomainEntity()
|
||||
entry `should be equal to` entityList[index].toDomainEntity(serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,12 +11,12 @@ import org.moire.ultrasonic.api.subsonic.models.Artist
|
|||
/**
|
||||
* Unit test for extension functions in APIArtistConverter.kt file.
|
||||
*/
|
||||
class APIArtistConverterTest {
|
||||
class APIArtistConverterTest : BaseTest() {
|
||||
@Test
|
||||
fun `Should convert artist entity`() {
|
||||
val entity = Artist(id = "10", name = "artist-name", starred = Calendar.getInstance())
|
||||
|
||||
val convertedEntity = entity.toDomainEntity()
|
||||
val convertedEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
id `should be equal to` entity.id
|
||||
|
@ -38,12 +38,12 @@ class APIArtistConverterTest {
|
|||
)
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toMusicDirectoryDomainEntity()
|
||||
val convertedEntity = entity.toMusicDirectoryDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
name `should be equal to` entity.name
|
||||
getChildren() `should be equal to` entity.albumsList
|
||||
.map { it.toDomainEntity() }.toMutableList()
|
||||
.map { it.toDomainEntity(serverId) }.toMutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,8 @@ import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
|
|||
/**
|
||||
* Unit test for function that converts [Bookmark] api entity to domain.
|
||||
*/
|
||||
class APIBookmarkConverterTest {
|
||||
class APIBookmarkConverterTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `Should convert to domain entity`() {
|
||||
val entity = Bookmark(
|
||||
|
@ -19,7 +20,7 @@ class APIBookmarkConverterTest {
|
|||
Calendar.getInstance(), MusicDirectoryChild(id = "12333")
|
||||
)
|
||||
|
||||
val domainEntity = entity.toDomainEntity()
|
||||
val domainEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
with(domainEntity) {
|
||||
position `should be equal to` entity.position.toInt()
|
||||
|
@ -27,7 +28,7 @@ class APIBookmarkConverterTest {
|
|||
comment `should be equal to` entity.comment
|
||||
created `should be equal to` entity.created?.time
|
||||
changed `should be equal to` entity.changed?.time
|
||||
track `should be equal to` entity.entry.toTrackEntity()
|
||||
track `should be equal to` entity.entry.toTrackEntity(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,11 +36,11 @@ class APIBookmarkConverterTest {
|
|||
fun `Should convert list of entities to domain entities`() {
|
||||
val entitiesList = listOf(Bookmark(443L), Bookmark(444L))
|
||||
|
||||
val domainEntitiesList = entitiesList.toDomainEntitiesList()
|
||||
val domainEntitiesList = entitiesList.toDomainEntitiesList(serverId)
|
||||
|
||||
domainEntitiesList.size `should be equal to` entitiesList.size
|
||||
domainEntitiesList.forEachIndexed({ index, bookmark ->
|
||||
bookmark `should be equal to` entitiesList[index].toDomainEntity()
|
||||
})
|
||||
domainEntitiesList.forEachIndexed { index, bookmark ->
|
||||
bookmark `should be equal to` entitiesList[index].toDomainEntity(serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import org.moire.ultrasonic.api.subsonic.models.Indexes
|
|||
/**
|
||||
* Unit tests for extension functions in [APIIndexesConverter.kt].
|
||||
*/
|
||||
class APIIndexConverterTest {
|
||||
class APIIndexConverterTest : BaseTest() {
|
||||
@Test
|
||||
fun `Should convert Indexes entity`() {
|
||||
val artistsA = listOf(
|
||||
|
@ -31,9 +31,12 @@ class APIIndexConverterTest {
|
|||
shortcutList = artistsA
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toArtistList()
|
||||
val convertedEntity = entity.toArtistList(serverId)
|
||||
|
||||
val expectedArtists = (artistsA + artistsT).map {
|
||||
it.toDomainEntity(serverId)
|
||||
}.toMutableList()
|
||||
|
||||
val expectedArtists = (artistsA + artistsT).map { it.toDomainEntity() }.toMutableList()
|
||||
with(convertedEntity) {
|
||||
size `should be equal to` expectedArtists.size
|
||||
this `should be equal to` expectedArtists
|
||||
|
|
|
@ -11,7 +11,7 @@ import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
|
|||
/**
|
||||
* Unit test for extension functions in APIMusicDirectoryConverter.kt file.
|
||||
*/
|
||||
class APIMusicDirectoryConverterTest {
|
||||
class APIMusicDirectoryConverterTest : BaseTest() {
|
||||
@Test
|
||||
fun `Should convert MusicDirectory entity`() {
|
||||
val entity = MusicDirectory(
|
||||
|
@ -20,13 +20,13 @@ class APIMusicDirectoryConverterTest {
|
|||
childList = listOf(MusicDirectoryChild("1"), MusicDirectoryChild("2"))
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toDomainEntity()
|
||||
val convertedEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
name `should be equal to` entity.name
|
||||
size `should be equal to` entity.childList.size
|
||||
getChildren() `should be equal to` entity.childList
|
||||
.map { it.toTrackEntity() }.toMutableList()
|
||||
.map { it.toTrackEntity(serverId) }.toMutableList()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,7 +44,7 @@ class APIMusicDirectoryConverterTest {
|
|||
starred = Calendar.getInstance(), userRating = 3, averageRating = 2.99F
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toTrackEntity()
|
||||
val convertedEntity = entity.toTrackEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
id `should be equal to` entity.id
|
||||
|
@ -84,7 +84,7 @@ class APIMusicDirectoryConverterTest {
|
|||
artist = "some-artist", publishDate = Calendar.getInstance()
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toTrackEntity()
|
||||
val convertedEntity = entity.toTrackEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
id `should be equal to` entity.streamId
|
||||
|
@ -96,11 +96,11 @@ class APIMusicDirectoryConverterTest {
|
|||
fun `Should convert list of MusicDirectoryChild to domain entity list`() {
|
||||
val entitiesList = listOf(MusicDirectoryChild(id = "45"), MusicDirectoryChild(id = "34"))
|
||||
|
||||
val domainList = entitiesList.toDomainEntityList()
|
||||
val domainList = entitiesList.toDomainEntityList(serverId)
|
||||
|
||||
domainList.size `should be equal to` entitiesList.size
|
||||
domainList.forEachIndexed { index, entry ->
|
||||
entry `should be equal to` entitiesList[index].toTrackEntity()
|
||||
entry `should be equal to` entitiesList[index].toTrackEntity(serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,12 +9,12 @@ import org.moire.ultrasonic.api.subsonic.models.MusicFolder
|
|||
/**
|
||||
* Unit test for extension functions in file APIMusicFolderConverter.kt.
|
||||
*/
|
||||
class APIMusicFolderConverterTest {
|
||||
class APIMusicFolderConverterTest : BaseTest() {
|
||||
@Test
|
||||
fun `Should convert MusicFolder entity`() {
|
||||
val entity = MusicFolder(id = "10", name = "some-name")
|
||||
|
||||
val convertedEntity = entity.toDomainEntity()
|
||||
val convertedEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
convertedEntity.name `should be equal to` entity.name
|
||||
convertedEntity.id `should be equal to` entity.id
|
||||
|
@ -27,7 +27,7 @@ class APIMusicFolderConverterTest {
|
|||
MusicFolder(id = "4", name = "some-name-4")
|
||||
)
|
||||
|
||||
val convertedList = entityList.toDomainEntityList()
|
||||
val convertedList = entityList.toDomainEntityList(serverId)
|
||||
|
||||
with(convertedList) {
|
||||
size `should be equal to` entityList.size
|
||||
|
|
|
@ -11,7 +11,7 @@ import org.moire.ultrasonic.api.subsonic.models.Playlist
|
|||
/**
|
||||
* Unit test for extension functions that converts api playlist entity to domain.
|
||||
*/
|
||||
class APIPlaylistConverterTest {
|
||||
class APIPlaylistConverterTest : BaseTest() {
|
||||
@Test
|
||||
fun `Should convert Playlist to MusicDirectory domain entity`() {
|
||||
val entity = Playlist(
|
||||
|
@ -22,13 +22,13 @@ class APIPlaylistConverterTest {
|
|||
)
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toMusicDirectoryDomainEntity()
|
||||
val convertedEntity = entity.toMusicDirectoryDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
name `should be equal to` entity.name
|
||||
size `should be equal to` entity.entriesList.size
|
||||
this[0] `should be equal to` entity.entriesList[0].toTrackEntity()
|
||||
this[1] `should be equal to` entity.entriesList[1].toTrackEntity()
|
||||
this[0] `should be equal to` entity.entriesList[0].toTrackEntity(serverId)
|
||||
this[1] `should be equal to` entity.entriesList[1].toTrackEntity(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult
|
|||
/**
|
||||
* Unit test for extension function in APISearchConverter.kt file.
|
||||
*/
|
||||
class APISearchConverterTest {
|
||||
class APISearchConverterTest : BaseTest() {
|
||||
@Test
|
||||
fun `Should convert SearchResult to domain entity`() {
|
||||
val entity = SearchResult(
|
||||
|
@ -26,7 +26,7 @@ class APISearchConverterTest {
|
|||
)
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toDomainEntity()
|
||||
val convertedEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
albums `should not be equal to` null
|
||||
|
@ -34,7 +34,7 @@ class APISearchConverterTest {
|
|||
artists `should not be equal to` null
|
||||
artists.size `should be equal to` 0
|
||||
songs.size `should be equal to` entity.matchList.size
|
||||
songs[0] `should be equal to` entity.matchList[0].toTrackEntity()
|
||||
songs[0] `should be equal to` entity.matchList[0].toTrackEntity(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -46,15 +46,15 @@ class APISearchConverterTest {
|
|||
listOf(MusicDirectoryChild(id = "9118", parent = "112"))
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toDomainEntity()
|
||||
val convertedEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
artists.size `should be equal to` entity.artistList.size
|
||||
artists[0] `should be equal to` entity.artistList[0].toIndexEntity()
|
||||
artists[0] `should be equal to` entity.artistList[0].toIndexEntity(serverId)
|
||||
albums.size `should be equal to` entity.albumList.size
|
||||
albums[0] `should be equal to` entity.albumList[0].toDomainEntity()
|
||||
albums[0] `should be equal to` entity.albumList[0].toDomainEntity(serverId)
|
||||
songs.size `should be equal to` entity.songList.size
|
||||
songs[0] `should be equal to` entity.songList[0].toTrackEntity()
|
||||
songs[0] `should be equal to` entity.songList[0].toTrackEntity(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -66,15 +66,15 @@ class APISearchConverterTest {
|
|||
songList = listOf(MusicDirectoryChild(id = "7123", title = "song1"))
|
||||
)
|
||||
|
||||
val convertedEntity = entity.toDomainEntity()
|
||||
val convertedEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
with(convertedEntity) {
|
||||
artists.size `should be equal to` entity.artistList.size
|
||||
artists[0] `should be equal to` entity.artistList[0].toDomainEntity()
|
||||
artists[0] `should be equal to` entity.artistList[0].toDomainEntity(serverId)
|
||||
albums.size `should be equal to` entity.albumList.size
|
||||
albums[0] `should be equal to` entity.albumList[0].toDomainEntity()
|
||||
albums[0] `should be equal to` entity.albumList[0].toDomainEntity(serverId)
|
||||
songs.size `should be equal to` entity.songList.size
|
||||
songs[0] `should be equal to` entity.songList[0].toTrackEntity()
|
||||
songs[0] `should be equal to` entity.songList[0].toTrackEntity(serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,12 +11,12 @@ import org.moire.ultrasonic.api.subsonic.models.Share
|
|||
/**
|
||||
* Unit test for api to domain share entity converter functions.
|
||||
*/
|
||||
class APIShareConverterTest {
|
||||
class APIShareConverterTest : BaseTest() {
|
||||
@Test
|
||||
fun `Should convert share entity to domain`() {
|
||||
val entity = createFakeShare()
|
||||
|
||||
val domainEntity = entity.toDomainEntity()
|
||||
val domainEntity = entity.toDomainEntity(serverId)
|
||||
|
||||
with(domainEntity) {
|
||||
id `should be equal to` entity.id
|
||||
|
@ -27,7 +27,7 @@ class APIShareConverterTest {
|
|||
lastVisited `should be equal to` shareTimeFormat.format(entity.lastVisited!!.time)
|
||||
expires `should be equal to` shareTimeFormat.format(entity.expires!!.time)
|
||||
visitCount `should be equal to` entity.visitCount.toLong()
|
||||
this.getEntries() `should be equal to` entity.items.toDomainEntityList()
|
||||
this.getEntries() `should be equal to` entity.items.toDomainEntityList(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,10 +47,10 @@ class APIShareConverterTest {
|
|||
createFakeShare().copy(id = "554", lastVisited = null)
|
||||
)
|
||||
|
||||
val domainEntityList = entityList.toDomainEntitiesList()
|
||||
val domainEntityList = entityList.toDomainEntitiesList(serverId)
|
||||
|
||||
domainEntityList.size `should be equal to` entityList.size
|
||||
domainEntityList[0] `should be equal to` entityList[0].toDomainEntity()
|
||||
domainEntityList[1] `should be equal to` entityList[1].toDomainEntity()
|
||||
domainEntityList[0] `should be equal to` entityList[0].toDomainEntity(serverId)
|
||||
domainEntityList[1] `should be equal to` entityList[1].toDomainEntity(serverId)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* BaseTest.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.domain
|
||||
|
||||
open class BaseTest {
|
||||
internal val serverId = -1
|
||||
}
|
Loading…
Reference in New Issue