Compare commits

...

20 Commits

Author SHA1 Message Date
Óscar García Amor 2165ce75b3
Merge branch 'develop' of github.com:ultrasonic/ultrasonic into develop 2022-07-08 12:42:04 +02:00
Óscar García Amor 8b3ee0a8d6
Migrate to GitLab 2022-07-08 12:41:53 +02:00
birdbird 695b2df63f
Merge pull request #750 from ultrasonic/offline/trackDao
Finish offline ID3 support
2022-07-08 09:20:40 +02:00
tzugen 798d795e81
Add Album list support in Offline 2022-07-07 19:20:40 +02:00
tzugen ecfce59e0f
Add clearer warning to ID3 offline setting 2022-07-07 18:53:36 +02:00
tzugen de0cb7713b
Improve offline support for Compilations 2022-07-06 15:35:34 +02:00
tzugen 78bfab3753
Conditionally hide offline Id3 Setting 2022-07-06 12:26:48 +02:00
tzugen b955d77152
Make Id3 offline dependent on Id3 2022-07-06 11:16:53 +02:00
tzugen b11694d6a2
Fix logic whether to showArtistPicture 2022-07-06 11:16:52 +02:00
tzugen 31a1fdace1
Formatting 2022-07-06 11:16:52 +02:00
tzugen 5b03b632fd
Fix three tests. 2022-07-06 11:16:52 +02:00
tzugen 152b1d261a
Fix two tests. 2022-07-06 11:16:52 +02:00
tzugen 53a1a5545a
Formatting.
Disable line length check in detekt. It's being guarded by KtLint already.
2022-07-06 11:16:52 +02:00
tzugen ad54db5bcb
Make Ids composite of Item Id + Server Id 2022-07-06 11:16:52 +02:00
tzugen 177329abcf
Add Migration 2022-07-06 11:16:52 +02:00
tzugen 241e51015f
Clean & formatting
Update room 2.4.0 -> 2.4.2
2022-07-06 11:16:52 +02:00
tzugen 60dbe70ca5
Add code to Downloader 2022-07-06 11:16:52 +02:00
tzugen 8490f7115d
Add Offline support for tracks 2022-07-06 11:16:52 +02:00
tzugen ee67f4c744
Add track Dao 2022-07-06 11:16:52 +02:00
tzugen 3a3bd10fdb
Add AlbumDao, rename getArtist to getAlbumsOfArtist 2022-07-06 11:16:52 +02:00
55 changed files with 1814 additions and 316 deletions

View File

@ -1,15 +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/-/issues
[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.md](https://github.com/ultrasonic/ultrasonic/blob/develop/CONTRIBUTING.md) to get started.
Have a look at [CONTRIBUTING](CONTRIBUTING.md) to get started.
## Download
@ -17,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.
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 havent 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 havent 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
@ -42,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

View File

@ -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,

View File

@ -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)

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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?

View File

@ -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()

View File

@ -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,

View File

@ -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']

View File

@ -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')"
]
}
}

View File

@ -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')"
]
}
}

View File

@ -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)

View File

@ -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(
@ -108,6 +109,10 @@ class ArtistRowBinder(
return section.toString()
}
private fun showArtistPicture(): Boolean {
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
}
/**
* Creates an instance of our ViewHolder class
*/

View File

@ -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 ->
// Exclude any -1 ids, eg. headers and other UI elements
entry.longId.takeIf { it != -1L }
}
)
selectedSet
) { entry ->
// Exclude any -1 ids, eg. headers and other UI elements
entry.longId.takeIf { it != -1L }
}
return selectedSet.count()
}

View File

@ -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
*/

View File

@ -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)
}
}
}

View File

@ -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?
}

View File

@ -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

View File

@ -1,24 +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,
exportSchema = true
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 */

View File

@ -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>
}

View File

@ -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)
}

View File

@ -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) }
}

View File

@ -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) }

View File

@ -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 ->
acc + index.artists.map {
it.toDomainEntity()
}
private fun List<APIIndex>.foldIndexToArtistList(serverId: Int): List<Artist> = this.fold(
listOf()
) { acc, index ->
acc + index.artists.map {
it.toDomainEntity(serverId)
}
)
}
private fun List<APIIndex>.foldIndexToIndexList(musicFolderId: String?): List<Index> = this.fold(
listOf(),
{ acc, index ->
acc + index.artists.map {
val ret = it.toIndexEntity()
ret.musicFolderId = musicFolderId
ret
}
private fun List<APIIndex>.foldIndexToIndexList(
musicFolderId: String?,
serverId: Int
): List<Index> = this.fold(
listOf()
) { acc, index ->
acc + index.artists.map {
val ret = it.toIndexEntity(serverId)
ret.musicFolderId = musicFolderId
ret
}
)
}

View File

@ -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))
}

View File

@ -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
}

View File

@ -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,10 +18,17 @@ import org.moire.ultrasonic.util.Util.ifNotNull
internal val playlistDateFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
fun APIPlaylist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply {
name = this@toMusicDirectoryDomainEntity.name
addAll(this@toMusicDirectoryDomainEntity.entriesList.map { it.toTrackEntity() })
}
fun APIPlaylist.toMusicDirectoryDomainEntity(serverId: Int): MusicDirectory =
MusicDirectory().apply {
name = this@toMusicDirectoryDomainEntity.name
addAll(
this@toMusicDirectoryDomainEntity.entriesList.map {
val item = it.toTrackEntity(serverId)
item.serverId = serverId
item
}
)
}
fun APIPlaylist.toDomainEntity(): Playlist = Playlist(
this.id, this.name, this.owner,

View File

@ -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) }
)

View File

@ -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()
)

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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,
{
enableButtons()
}
)
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))

View File

@ -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(

View File

@ -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))

View File

@ -635,7 +635,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
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()

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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(OfflineException::class)
override fun getArtist(id: String, name: String?, refresh: Boolean):
@Throws(Exception::class)
override fun getAlbumsOfArtist(id: String, name: String?, refresh: Boolean):
List<Album> {
throw OfflineException("getArtist isn't available in offline mode")
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)
}
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)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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"

View File

@ -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)
}
}
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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
}