Compare commits
99 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6e83b8b83d | ||
![]() |
a0cab24483 | ||
![]() |
964fa42a0f | ||
![]() |
ac6a68bb16 | ||
![]() |
c5d2209359 | ||
![]() |
ad1faa5d36 | ||
![]() |
29e3d00c88 | ||
![]() |
0c4ae81406 | ||
![]() |
69e6d52fcf | ||
![]() |
dcab9c6ad2 | ||
![]() |
3e94e63471 | ||
![]() |
8769922742 | ||
![]() |
75601215d5 | ||
![]() |
feceffe314 | ||
![]() |
ac3019ef25 | ||
![]() |
fab56dd302 | ||
![]() |
11426d6803 | ||
![]() |
da41ae7e45 | ||
![]() |
490d2d686d | ||
![]() |
4fbadc7c1e | ||
![]() |
5c056a3b84 | ||
![]() |
accbfc47d0 | ||
![]() |
33221746fc | ||
![]() |
f3995b8c46 | ||
![]() |
a43b6f10e3 | ||
![]() |
5878b0ebdd | ||
![]() |
82b9b9cc08 | ||
![]() |
055ad86f2c | ||
![]() |
ed8eb7cf8a | ||
![]() |
f4a55ff2ad | ||
![]() |
2d632c19de | ||
![]() |
5de8221f4a | ||
![]() |
1221ddf424 | ||
![]() |
1bdc120430 | ||
![]() |
14fcd0788c | ||
![]() |
fd54a4c483 | ||
![]() |
bb4c9ada73 | ||
![]() |
9bd227e807 | ||
![]() |
a8431e13ee | ||
![]() |
d1f4aa4577 | ||
![]() |
fc569ea5f9 | ||
![]() |
720c73d9fb | ||
![]() |
5d37b831a3 | ||
![]() |
913f69293a | ||
![]() |
867dfb09d0 | ||
![]() |
fb0e8af507 | ||
![]() |
d20e35a547 | ||
![]() |
edf82862ea | ||
![]() |
b8905a04e7 | ||
![]() |
192263d95b | ||
![]() |
43fda751b6 | ||
![]() |
6ebb5dfc65 | ||
![]() |
7869f1a388 | ||
![]() |
6dd88b796e | ||
![]() |
9aa051068b | ||
![]() |
58720f8cb9 | ||
![]() |
e1f85b1d6a | ||
![]() |
8bd4e0e715 | ||
![]() |
1e6a6dc6de | ||
![]() |
6c98cc1496 | ||
![]() |
df36075c3e | ||
![]() |
9a5ea69de4 | ||
![]() |
28e69756d9 | ||
![]() |
149d0a6ce3 | ||
![]() |
058b539ae4 | ||
![]() |
0c3c56efd8 | ||
![]() |
25fd43b98d | ||
![]() |
214136a13e | ||
![]() |
554dea3fba | ||
![]() |
0de5330676 | ||
![]() |
92141f4f49 | ||
![]() |
7ed138ea51 | ||
![]() |
c8d03a9c9d | ||
![]() |
f318e71d20 | ||
![]() |
3194fe3387 | ||
![]() |
29878a6432 | ||
![]() |
83d559830f | ||
![]() |
91ec216679 | ||
![]() |
f63c9b1423 | ||
![]() |
38e46db354 | ||
![]() |
12f8b6fbcb | ||
![]() |
f726d3e934 | ||
![]() |
39b2cc2239 | ||
![]() |
5e5868bd3c | ||
![]() |
cfe5dc965f | ||
![]() |
493fc75345 | ||
![]() |
fa4fd276b3 | ||
![]() |
34f4404329 | ||
![]() |
6d6a839c2d | ||
![]() |
270986215c | ||
![]() |
6fb8e41a04 | ||
![]() |
d0cffe466b | ||
![]() |
fae96d1114 | ||
![]() |
ff611d792e | ||
![]() |
6d2b075720 | ||
![]() |
b9ecb0c669 | ||
![]() |
6b01fbb008 | ||
![]() |
d49ac4fa13 | ||
![]() |
c00a079715 |
192 changed files with 4058 additions and 2443 deletions
12
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
12
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
@ -1,14 +1,12 @@
|
|||
name: Bug report
|
||||
description: Create a bug report to help us improve
|
||||
name: 🐛 Bug report
|
||||
description: Something isn't working, uh?
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**1- I am able to reproduce the bug with the [latest version](https://github.com/vfsfitvnm/vimusic/releases/latest).**
|
||||
**2- I've checked that there is no issue about this bug.**
|
||||
**3- This issue contains only one bug.**
|
||||
**4- The title of this issue accurately describes the bug.**
|
||||
## ⚠️ Make sure you are able to reproduce the bug with the [latest version](https://github.com/vfsfitvnm/vimusic/releases/latest).
|
||||
## ⚠️ Make sure there is no issue about this bug already.
|
||||
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
|
@ -65,7 +63,7 @@ body:
|
|||
attributes:
|
||||
label: ViMusic version
|
||||
placeholder: |
|
||||
Example: "0.1.2"
|
||||
Example: "0.5.4"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
34
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
34
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
@ -1,34 +0,0 @@
|
|||
name: Feature request
|
||||
description: Suggest an idea for ViMusic
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**1- I've checked that there is no other issue about this feature request.**
|
||||
**2- This issue contains only one feature request.**
|
||||
**3- The title of this issue accurately describes the feature request.**
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Feature description
|
||||
description: What feature you want the app to have? Provide detailed description about what it should look like or where it should be added.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why-is-the-feature-requested
|
||||
attributes:
|
||||
label: Why do you want this feature?
|
||||
description: Describe the problem or limitation that motivates you to want this feature to be added.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
6
.github/workflows/android.yml
vendored
6
.github/workflows/android.yml
vendored
|
@ -1,10 +1,6 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
- Skip silence
|
||||
- Sleep timer
|
||||
- Audio normalization
|
||||
- Android Auto
|
||||
- Persistent queue
|
||||
- Open YouTube/YouTube Music links (`watch`, `playlist`, `channel`)
|
||||
- ...
|
||||
|
@ -46,13 +47,11 @@
|
|||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/it.vfsfitvnm.vimusic/)
|
||||
|
||||
## Similar projects, inspirations and acknowledgments
|
||||
- [**Beatbump**](https://github.com/snuffyDev/Beatbump): Alternative YouTube Music frontend built with Svelte/SvelteKit.
|
||||
- [**music**](https://github.com/z-huang/music): A material design music player with music from YouTube/YouTube Music.
|
||||
## Acknowledgments
|
||||
- [**YouTube-Internal-Clients**](https://github.com/zerodytrash/YouTube-Internal-Clients): A python script that discovers hidden YouTube API clients. Just a research project.
|
||||
- [**ionicons**](https://github.com/ionic-team/ionicons): Premium hand-crafted icons built by Ionic, for Ionic apps and web apps everywhere.
|
||||
|
||||
<a href="https://www.flaticon.com/free-icons/music" title="music icons">App icon based on icon created by Ilham Fitrotul Hayat - Flaticon</a>
|
||||
<a href="https://www.flaticon.com/authors/ilham-fitrotul-hayat" title="music icons">App icon based on icon created by Ilham Fitrotul Hayat - Flaticon</a>
|
||||
|
||||
## Disclaimer
|
||||
This project and its contents are not affiliated with, funded, authorized, endorsed by, or in any way associated with YouTube, Google LLC or any of its affiliates and subsidiaries.
|
||||
|
|
|
@ -10,9 +10,9 @@ android {
|
|||
defaultConfig {
|
||||
applicationId = "it.vfsfitvnm.vimusic"
|
||||
minSdk = 21
|
||||
targetSdk = 32
|
||||
versionCode = 16
|
||||
versionName = "0.5.0"
|
||||
targetSdk = 33
|
||||
versionCode = 20
|
||||
versionName = "0.5.4"
|
||||
}
|
||||
|
||||
splits {
|
||||
|
@ -70,6 +70,7 @@ kapt {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.composePersist)
|
||||
implementation(projects.composeRouting)
|
||||
implementation(projects.composeReordering)
|
||||
|
||||
|
@ -87,7 +88,6 @@ dependencies {
|
|||
|
||||
implementation(libs.room)
|
||||
kapt(libs.room.compiler)
|
||||
annotationProcessor(libs.room.compiler)
|
||||
|
||||
implementation(projects.innertube)
|
||||
implementation(projects.kugou)
|
||||
|
|
640
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/22.json
Normal file
640
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/22.json
Normal file
|
@ -0,0 +1,640 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 22,
|
||||
"identityHash": "ca98e767afd3ae8c801377ee3d18c71e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Song",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistsText",
|
||||
"columnName": "artistsText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationText",
|
||||
"columnName": "durationText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lyrics",
|
||||
"columnName": "lyrics",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "synchronizedLyrics",
|
||||
"columnName": "synchronizedLyrics",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "likedAt",
|
||||
"columnName": "likedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "totalPlayTimeMs",
|
||||
"columnName": "totalPlayTimeMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongPlaylistMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playlistId",
|
||||
"columnName": "playlistId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"playlistId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongPlaylistMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongPlaylistMap_playlistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"playlistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Playlist",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"playlistId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Playlist",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "browseId",
|
||||
"columnName": "browseId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Artist",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkedAt",
|
||||
"columnName": "bookmarkedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongArtistMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"artistId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongArtistMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongArtistMap_artistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"artistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Artist",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"artistId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Album",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorsText",
|
||||
"columnName": "authorsText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "shareUrl",
|
||||
"columnName": "shareUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkedAt",
|
||||
"columnName": "bookmarkedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongAlbumMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"albumId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongAlbumMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongAlbumMap_albumId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"albumId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Album",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"albumId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "SearchQuery",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "query",
|
||||
"columnName": "query",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SearchQuery_query",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"query"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "QueuedMediaItem",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mediaItem",
|
||||
"columnName": "mediaItem",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Format",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "itag",
|
||||
"columnName": "itag",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bitrate",
|
||||
"columnName": "bitrate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentLength",
|
||||
"columnName": "contentLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "loudnessDb",
|
||||
"columnName": "loudnessDb",
|
||||
"affinity": "REAL",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Event",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playTime",
|
||||
"columnName": "playTime",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Event_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"viewName": "SortedSongPlaylistMap",
|
||||
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position"
|
||||
}
|
||||
],
|
||||
"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, 'ca98e767afd3ae8c801377ee3d18c71e')"
|
||||
]
|
||||
}
|
||||
}
|
672
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json
Normal file
672
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json
Normal file
|
@ -0,0 +1,672 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 23,
|
||||
"identityHash": "205c24811149a247279bcbfdc2d6c396",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Song",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistsText",
|
||||
"columnName": "artistsText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationText",
|
||||
"columnName": "durationText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "likedAt",
|
||||
"columnName": "likedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "totalPlayTimeMs",
|
||||
"columnName": "totalPlayTimeMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongPlaylistMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playlistId",
|
||||
"columnName": "playlistId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"playlistId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongPlaylistMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongPlaylistMap_playlistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"playlistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Playlist",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"playlistId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Playlist",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "browseId",
|
||||
"columnName": "browseId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Artist",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkedAt",
|
||||
"columnName": "bookmarkedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongArtistMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"artistId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongArtistMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongArtistMap_artistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"artistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Artist",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"artistId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Album",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorsText",
|
||||
"columnName": "authorsText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "shareUrl",
|
||||
"columnName": "shareUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkedAt",
|
||||
"columnName": "bookmarkedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongAlbumMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"albumId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongAlbumMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongAlbumMap_albumId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"albumId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Album",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"albumId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "SearchQuery",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "query",
|
||||
"columnName": "query",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SearchQuery_query",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"query"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "QueuedMediaItem",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mediaItem",
|
||||
"columnName": "mediaItem",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Format",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "itag",
|
||||
"columnName": "itag",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bitrate",
|
||||
"columnName": "bitrate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentLength",
|
||||
"columnName": "contentLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "loudnessDb",
|
||||
"columnName": "loudnessDb",
|
||||
"affinity": "REAL",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Event",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playTime",
|
||||
"columnName": "playTime",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Event_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Lyrics",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fixed",
|
||||
"columnName": "fixed",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "synced",
|
||||
"columnName": "synced",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"viewName": "SortedSongPlaylistMap",
|
||||
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position"
|
||||
}
|
||||
],
|
||||
"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, '205c24811149a247279bcbfdc2d6c396')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
@ -18,6 +19,7 @@
|
|||
<application
|
||||
android:name=".MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:configChanges="colorMode|density|fontScale|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher_round"
|
||||
|
@ -36,6 +38,7 @@
|
|||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
|
@ -93,8 +96,23 @@
|
|||
android:foregroundServiceType="mediaPlayback">
|
||||
</service>
|
||||
|
||||
<service android:name=".service.PlayerMediaBrowserService"
|
||||
android:exported="true"
|
||||
android:enabled="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".service.PlayerService$NotificationDismissReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<meta-data android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@drawable/app_icon" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -2,7 +2,7 @@ package it.vfsfitvnm.vimusic
|
|||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.SQLException
|
||||
import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE
|
||||
import android.os.Parcel
|
||||
import androidx.core.database.getFloatOrNull
|
||||
|
@ -15,6 +15,7 @@ import androidx.room.DeleteTable
|
|||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.RenameColumn
|
||||
import androidx.room.RenameTable
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
|
@ -29,6 +30,7 @@ import androidx.room.migration.AutoMigrationSpec
|
|||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import it.vfsfitvnm.vimusic.enums.AlbumSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.ArtistSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.PlaylistSortBy
|
||||
|
@ -36,10 +38,11 @@ import it.vfsfitvnm.vimusic.enums.SongSortBy
|
|||
import it.vfsfitvnm.vimusic.enums.SortOrder
|
||||
import it.vfsfitvnm.vimusic.models.Album
|
||||
import it.vfsfitvnm.vimusic.models.Artist
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSongWithContentLength
|
||||
import it.vfsfitvnm.vimusic.models.SongWithContentLength
|
||||
import it.vfsfitvnm.vimusic.models.Event
|
||||
import it.vfsfitvnm.vimusic.models.Format
|
||||
import it.vfsfitvnm.vimusic.models.Info
|
||||
import it.vfsfitvnm.vimusic.models.Lyrics
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistPreview
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
|
||||
|
@ -50,8 +53,8 @@ import it.vfsfitvnm.vimusic.models.SongAlbumMap
|
|||
import it.vfsfitvnm.vimusic.models.SongArtistMap
|
||||
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
|
||||
import it.vfsfitvnm.vimusic.models.SortedSongPlaylistMap
|
||||
import kotlin.jvm.Throws
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Dao
|
||||
interface Database {
|
||||
|
@ -60,34 +63,34 @@ interface Database {
|
|||
@Transaction
|
||||
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID ASC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun songsByRowIdAsc(): Flow<List<DetailedSong>>
|
||||
fun songsByRowIdAsc(): Flow<List<Song>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID DESC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun songsByRowIdDesc(): Flow<List<DetailedSong>>
|
||||
fun songsByRowIdDesc(): Flow<List<Song>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title ASC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun songsByTitleAsc(): Flow<List<DetailedSong>>
|
||||
fun songsByTitleAsc(): Flow<List<Song>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title DESC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun songsByTitleDesc(): Flow<List<DetailedSong>>
|
||||
fun songsByTitleDesc(): Flow<List<Song>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs ASC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun songsByPlayTimeAsc(): Flow<List<DetailedSong>>
|
||||
fun songsByPlayTimeAsc(): Flow<List<Song>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs DESC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun songsByPlayTimeDesc(): Flow<List<DetailedSong>>
|
||||
fun songsByPlayTimeDesc(): Flow<List<Song>>
|
||||
|
||||
fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow<List<DetailedSong>> {
|
||||
fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow<List<Song>> {
|
||||
return when (sortBy) {
|
||||
SongSortBy.PlayTime -> when (sortOrder) {
|
||||
SortOrder.Ascending -> songsByPlayTimeAsc()
|
||||
|
@ -107,7 +110,7 @@ interface Database {
|
|||
@Transaction
|
||||
@Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun favorites(): Flow<List<DetailedSong>>
|
||||
fun favorites(): Flow<List<Song>>
|
||||
|
||||
@Query("SELECT * FROM QueuedMediaItem")
|
||||
fun queue(): List<QueuedMediaItem>
|
||||
|
@ -136,34 +139,22 @@ interface Database {
|
|||
@Query("UPDATE Song SET durationText = :durationText WHERE id = :songId")
|
||||
fun updateDurationText(songId: String, durationText: String): Int
|
||||
|
||||
@Query("SELECT lyrics FROM Song WHERE id = :songId")
|
||||
fun lyrics(songId: String): Flow<String?>
|
||||
|
||||
@Query("SELECT synchronizedLyrics FROM Song WHERE id = :songId")
|
||||
fun synchronizedLyrics(songId: String): Flow<String?>
|
||||
|
||||
@Query("UPDATE Song SET lyrics = :lyrics WHERE id = :songId")
|
||||
fun updateLyrics(songId: String, lyrics: String?): Int
|
||||
|
||||
@Query("UPDATE Song SET synchronizedLyrics = :lyrics WHERE id = :songId")
|
||||
fun updateSynchronizedLyrics(songId: String, lyrics: String?): Int
|
||||
@Query("SELECT * FROM Lyrics WHERE songId = :songId")
|
||||
fun lyrics(songId: String): Flow<Lyrics?>
|
||||
|
||||
@Query("SELECT * FROM Artist WHERE id = :id")
|
||||
fun artist(id: String): Flow<Artist?>
|
||||
|
||||
@Query("SELECT timestamp FROM Artist WHERE id = :id")
|
||||
fun artistTimestamp(id: String): Long?
|
||||
|
||||
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name DESC")
|
||||
fun artistsByNameDesc(): Flow<List<Artist>>
|
||||
|
||||
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name ASC")
|
||||
fun artistsByNameAsc(): Flow<List<Artist>>
|
||||
|
||||
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID DESC")
|
||||
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC")
|
||||
fun artistsByRowIdDesc(): Flow<List<Artist>>
|
||||
|
||||
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID ASC")
|
||||
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC")
|
||||
fun artistsByRowIdAsc(): Flow<List<Artist>>
|
||||
|
||||
fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow<List<Artist>> {
|
||||
|
@ -188,7 +179,7 @@ interface Database {
|
|||
@Transaction
|
||||
@Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun albumSongs(albumId: String): Flow<List<DetailedSong>>
|
||||
fun albumSongs(albumId: String): Flow<List<Song>>
|
||||
|
||||
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title ASC")
|
||||
fun albumsByTitleAsc(): Flow<List<Album>>
|
||||
|
@ -196,7 +187,7 @@ interface Database {
|
|||
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year ASC")
|
||||
fun albumsByYearAsc(): Flow<List<Album>>
|
||||
|
||||
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID ASC")
|
||||
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC")
|
||||
fun albumsByRowIdAsc(): Flow<List<Album>>
|
||||
|
||||
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title DESC")
|
||||
|
@ -205,7 +196,7 @@ interface Database {
|
|||
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year DESC")
|
||||
fun albumsByYearDesc(): Flow<List<Album>>
|
||||
|
||||
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID DESC")
|
||||
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC")
|
||||
fun albumsByRowIdDesc(): Flow<List<Album>>
|
||||
|
||||
fun albums(sortBy: AlbumSortBy, sortOrder: SortOrder): Flow<List<Album>> {
|
||||
|
@ -234,28 +225,44 @@ interface Database {
|
|||
|
||||
@Transaction
|
||||
@Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name ASC")
|
||||
fun playlistPreviewsByName(): Flow<List<PlaylistPreview>>
|
||||
fun playlistPreviewsByNameAsc(): Flow<List<PlaylistPreview>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID ASC")
|
||||
fun playlistPreviewsByDateAdded(): Flow<List<PlaylistPreview>>
|
||||
fun playlistPreviewsByDateAddedAsc(): Flow<List<PlaylistPreview>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount ASC")
|
||||
fun playlistPreviewsByDateSongCount(): Flow<List<PlaylistPreview>>
|
||||
fun playlistPreviewsByDateSongCountAsc(): Flow<List<PlaylistPreview>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name DESC")
|
||||
fun playlistPreviewsByNameDesc(): Flow<List<PlaylistPreview>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID DESC")
|
||||
fun playlistPreviewsByDateAddedDesc(): Flow<List<PlaylistPreview>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount DESC")
|
||||
fun playlistPreviewsByDateSongCountDesc(): Flow<List<PlaylistPreview>>
|
||||
|
||||
fun playlistPreviews(
|
||||
sortBy: PlaylistSortBy,
|
||||
sortOrder: SortOrder
|
||||
): Flow<List<PlaylistPreview>> {
|
||||
return when (sortBy) {
|
||||
PlaylistSortBy.Name -> playlistPreviewsByName()
|
||||
PlaylistSortBy.DateAdded -> playlistPreviewsByDateAdded()
|
||||
PlaylistSortBy.SongCount -> playlistPreviewsByDateSongCount()
|
||||
}.map {
|
||||
when (sortOrder) {
|
||||
SortOrder.Ascending -> it
|
||||
SortOrder.Descending -> it.reversed()
|
||||
PlaylistSortBy.Name -> when (sortOrder) {
|
||||
SortOrder.Ascending -> playlistPreviewsByNameAsc()
|
||||
SortOrder.Descending -> playlistPreviewsByNameDesc()
|
||||
}
|
||||
PlaylistSortBy.SongCount -> when (sortOrder) {
|
||||
SortOrder.Ascending -> playlistPreviewsByDateSongCountAsc()
|
||||
SortOrder.Descending -> playlistPreviewsByDateSongCountDesc()
|
||||
}
|
||||
PlaylistSortBy.DateAdded -> when (sortOrder) {
|
||||
SortOrder.Ascending -> playlistPreviewsByDateAddedAsc()
|
||||
SortOrder.Descending -> playlistPreviewsByDateAddedDesc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -266,15 +273,14 @@ interface Database {
|
|||
@Transaction
|
||||
@Query("SELECT * FROM Song JOIN SongArtistMap ON Song.id = SongArtistMap.songId WHERE SongArtistMap.artistId = :artistId AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun artistSongs(artistId: String): Flow<List<DetailedSong>>
|
||||
fun artistSongs(artistId: String): Flow<List<Song>>
|
||||
|
||||
@Query("SELECT * FROM Format WHERE songId = :songId")
|
||||
fun format(songId: String): Flow<Format>
|
||||
fun format(songId: String): Flow<Format?>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Song JOIN Format ON id = songId WHERE contentLength IS NOT NULL AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun songsWithContentLength(): Flow<List<DetailedSongWithContentLength>>
|
||||
@Query("SELECT Song.*, contentLength FROM Song JOIN Format ON id = songId WHERE contentLength IS NOT NULL AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC")
|
||||
fun songsWithContentLength(): Flow<List<SongWithContentLength>>
|
||||
|
||||
@Query("""
|
||||
UPDATE SongPlaylistMap SET position =
|
||||
|
@ -290,18 +296,37 @@ interface Database {
|
|||
@Query("DELETE FROM SongPlaylistMap WHERE playlistId = :id")
|
||||
fun clearPlaylist(id: Long)
|
||||
|
||||
@Query("DELETE FROM SongAlbumMap WHERE albumId = :id")
|
||||
fun clearAlbum(id: String)
|
||||
|
||||
@Query("SELECT loudnessDb FROM Format WHERE songId = :songId")
|
||||
fun loudnessDb(songId: String): Flow<Float?>
|
||||
|
||||
@Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query")
|
||||
fun search(query: String): Flow<List<DetailedSong>>
|
||||
fun search(query: String): Flow<List<Song>>
|
||||
|
||||
@Query("SELECT albumId AS id, NULL AS name FROM SongAlbumMap WHERE songId = :songId")
|
||||
fun songAlbumInfo(songId: String): Info
|
||||
|
||||
@Query("SELECT id, name FROM Artist LEFT JOIN SongArtistMap ON id = artistId WHERE songId = :songId")
|
||||
fun songArtistInfo(songId: String): List<Info>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT Song.* FROM Event JOIN Song ON Song.id = songId GROUP BY songId ORDER BY SUM(CAST(playTime AS REAL) / (((:now - timestamp) / 86400000) + 1)) DESC LIMIT 1")
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
fun trending(now: Long = System.currentTimeMillis()): Flow<DetailedSong?>
|
||||
fun trending(now: Long = System.currentTimeMillis()): Flow<Song?>
|
||||
|
||||
@Insert
|
||||
@Query("SELECT COUNT (*) FROM Event")
|
||||
fun eventsCount(): Flow<Int>
|
||||
|
||||
@Query("DELETE FROM Event")
|
||||
fun clearEvents()
|
||||
|
||||
@Query("DELETE FROM Event WHERE songId = :songId")
|
||||
fun clearEventsFor(songId: String)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
@Throws(SQLException::class)
|
||||
fun insert(event: Event)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
|
@ -378,6 +403,9 @@ interface Database {
|
|||
@Update
|
||||
fun update(playlist: Playlist)
|
||||
|
||||
@Upsert
|
||||
fun upsert(lyrics: Lyrics)
|
||||
|
||||
@Upsert
|
||||
fun upsert(album: Album, songAlbumMaps: List<SongAlbumMap>)
|
||||
|
||||
|
@ -395,6 +423,13 @@ interface Database {
|
|||
|
||||
@Delete
|
||||
fun delete(songPlaylistMap: SongPlaylistMap)
|
||||
|
||||
@RawQuery
|
||||
fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int
|
||||
|
||||
fun checkpoint() {
|
||||
raw(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)"))
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.room.Database(
|
||||
|
@ -410,11 +445,12 @@ interface Database {
|
|||
QueuedMediaItem::class,
|
||||
Format::class,
|
||||
Event::class,
|
||||
Lyrics::class,
|
||||
],
|
||||
views = [
|
||||
SortedSongPlaylistMap::class
|
||||
],
|
||||
version = 21,
|
||||
version = 23,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
|
@ -434,6 +470,7 @@ interface Database {
|
|||
AutoMigration(from = 18, to = 19),
|
||||
AutoMigration(from = 19, to = 20),
|
||||
AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class),
|
||||
AutoMigration(from = 21, to = 22, spec = DatabaseInitializer.From21To22Migration::class),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
@ -451,7 +488,8 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
|
|||
.addMigrations(
|
||||
From8To9Migration(),
|
||||
From10To11Migration(),
|
||||
From14To15Migration()
|
||||
From14To15Migration(),
|
||||
From22To23Migration()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
@ -575,6 +613,30 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
|
|||
DeleteColumn("Artist", "radioPlaylistId"),
|
||||
)
|
||||
class From20To21Migration : AutoMigrationSpec
|
||||
|
||||
@DeleteColumn.Entries(DeleteColumn("Artist", "info"))
|
||||
class From21To22Migration : AutoMigrationSpec
|
||||
|
||||
class From22To23Migration : Migration(22, 23) {
|
||||
override fun migrate(it: SupportSQLiteDatabase) {
|
||||
it.execSQL("CREATE TABLE IF NOT EXISTS Lyrics (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)")
|
||||
|
||||
it.query(SimpleSQLiteQuery("SELECT id, lyrics, synchronizedLyrics FROM Song;")).use { cursor ->
|
||||
val lyricsValues = ContentValues(3)
|
||||
while (cursor.moveToNext()) {
|
||||
lyricsValues.put("songId", cursor.getString(0))
|
||||
lyricsValues.put("fixed", cursor.getString(1))
|
||||
lyricsValues.put("synced", cursor.getString(2))
|
||||
it.insert("Lyrics", CONFLICT_IGNORE, lyricsValues)
|
||||
}
|
||||
}
|
||||
|
||||
it.execSQL("CREATE TABLE IF NOT EXISTS Song_new (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))")
|
||||
it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs) SELECT id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs FROM Song;")
|
||||
it.execSQL("DROP TABLE Song;")
|
||||
it.execSQL("ALTER TABLE Song_new RENAME TO Song;")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverters
|
||||
|
@ -620,19 +682,3 @@ fun transaction(block: () -> Unit) = with(DatabaseInitializer.Instance) {
|
|||
|
||||
val RoomDatabase.path: String?
|
||||
get() = openHelper.writableDatabase.path
|
||||
|
||||
fun RoomDatabase.checkpoint() {
|
||||
openHelper.writableDatabase.run {
|
||||
query("PRAGMA journal_mode").use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
when (cursor.getString(0).lowercase()) {
|
||||
"wal" -> {
|
||||
query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst)
|
||||
query("PRAGMA wal_checkpoint(TRUNCATE)").use(Cursor::moveToFirst)
|
||||
query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import android.content.Intent
|
|||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.widget.Toast
|
||||
|
@ -49,6 +48,8 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.coerceIn
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
|
@ -57,6 +58,12 @@ import androidx.media3.common.MediaItem
|
|||
import androidx.media3.common.Player
|
||||
import com.valentinilk.shimmer.LocalShimmerTheme
|
||||
import com.valentinilk.shimmer.defaultShimmerTheme
|
||||
import it.vfsfitvnm.compose.persist.PersistMap
|
||||
import it.vfsfitvnm.compose.persist.PersistMapOwner
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.innertube.requests.playlistPage
|
||||
import it.vfsfitvnm.innertube.requests.song
|
||||
import it.vfsfitvnm.vimusic.enums.ColorPaletteMode
|
||||
import it.vfsfitvnm.vimusic.enums.ColorPaletteName
|
||||
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
|
||||
|
@ -75,19 +82,18 @@ import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
|||
import it.vfsfitvnm.vimusic.ui.styling.colorPaletteOf
|
||||
import it.vfsfitvnm.vimusic.ui.styling.dynamicColorPaletteOf
|
||||
import it.vfsfitvnm.vimusic.ui.styling.typographyOf
|
||||
import it.vfsfitvnm.vimusic.utils.applyFontPaddingKey
|
||||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey
|
||||
import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.vimusic.utils.getEnum
|
||||
import it.vfsfitvnm.vimusic.utils.intent
|
||||
import it.vfsfitvnm.vimusic.utils.listener
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid8
|
||||
import it.vfsfitvnm.vimusic.utils.preferences
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.playlistPage
|
||||
import it.vfsfitvnm.youtubemusic.requests.song
|
||||
import it.vfsfitvnm.vimusic.utils.useSystemFontKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
@ -95,7 +101,7 @@ import kotlinx.coroutines.flow.first
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : ComponentActivity(), PersistMapOwner {
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
if (service is PlayerService.Binder) {
|
||||
|
@ -110,20 +116,20 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
private var binder by mutableStateOf<PlayerService.Binder?>(null)
|
||||
|
||||
override lateinit var persistMap: PersistMap
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
bindService(intent<PlayerService>(), serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
unbindService(serviceConnection)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@Suppress("DEPRECATION", "UNCHECKED_CAST")
|
||||
persistMap = lastCustomNonConfigurationInstance as? PersistMap ?: PersistMap()
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
val launchedFromNotification = intent?.extras?.getBoolean("expandPlayerBottomSheet") == true
|
||||
|
@ -142,6 +148,9 @@ class MainActivity : ComponentActivity() {
|
|||
val thumbnailRoundness =
|
||||
getEnum(thumbnailRoundnessKey, ThumbnailRoundness.Light)
|
||||
|
||||
val useSystemFont = getBoolean(useSystemFontKey, false)
|
||||
val applyFontPadding = getBoolean(applyFontPaddingKey, false)
|
||||
|
||||
val colorPalette =
|
||||
colorPaletteOf(colorPaletteName, colorPaletteMode, isSystemInDarkTheme)
|
||||
|
||||
|
@ -150,7 +159,7 @@ class MainActivity : ComponentActivity() {
|
|||
mutableStateOf(
|
||||
Appearance(
|
||||
colorPalette = colorPalette,
|
||||
typography = typographyOf(colorPalette.text),
|
||||
typography = typographyOf(colorPalette.text, useSystemFont, applyFontPadding),
|
||||
thumbnailShape = thumbnailRoundness.shape()
|
||||
)
|
||||
)
|
||||
|
@ -177,7 +186,7 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
appearance = appearance.copy(
|
||||
colorPalette = colorPalette,
|
||||
typography = typographyOf(colorPalette.text)
|
||||
typography = appearance.typography.copy(colorPalette.text)
|
||||
)
|
||||
|
||||
return@setBitmapListener
|
||||
|
@ -190,7 +199,7 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
appearance = appearance.copy(
|
||||
colorPalette = it,
|
||||
typography = typographyOf(it.text)
|
||||
typography = appearance.typography.copy(it.text)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -229,7 +238,7 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
appearance = appearance.copy(
|
||||
colorPalette = colorPalette,
|
||||
typography = typographyOf(colorPalette.text),
|
||||
typography = appearance.typography.copy(colorPalette.text),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -242,6 +251,15 @@ class MainActivity : ComponentActivity() {
|
|||
thumbnailShape = thumbnailRoundness.shape()
|
||||
)
|
||||
}
|
||||
|
||||
useSystemFontKey, applyFontPaddingKey -> {
|
||||
val useSystemFont = sharedPreferences.getBoolean(useSystemFontKey, false)
|
||||
val applyFontPadding = sharedPreferences.getBoolean(applyFontPaddingKey, false)
|
||||
|
||||
appearance = appearance.copy(
|
||||
typography = typographyOf(appearance.colorPalette.text, useSystemFont, applyFontPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -327,7 +345,8 @@ class MainActivity : ComponentActivity() {
|
|||
LocalRippleTheme provides rippleTheme,
|
||||
LocalShimmerTheme provides shimmerTheme,
|
||||
LocalPlayerServiceBinder provides binder,
|
||||
LocalPlayerAwareWindowInsets provides playerAwareWindowInsets
|
||||
LocalPlayerAwareWindowInsets provides playerAwareWindowInsets,
|
||||
LocalLayoutDirection provides LayoutDirection.Ltr
|
||||
) {
|
||||
HomeScreen(
|
||||
onPlaylistUrl = { url ->
|
||||
|
@ -359,20 +378,28 @@ class MainActivity : ComponentActivity() {
|
|||
if (playerBottomSheetState.isDismissed) {
|
||||
if (launchedFromNotification) {
|
||||
intent.replaceExtras(Bundle())
|
||||
playerBottomSheetState.expandSoft()
|
||||
playerBottomSheetState.expand(tween(700))
|
||||
} else {
|
||||
playerBottomSheetState.collapseSoft()
|
||||
playerBottomSheetState.collapse(tween(700))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
player.listener(object : Player.Listener {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null) {
|
||||
playerBottomSheetState.expand(tween(500))
|
||||
if (mediaItem.mediaMetadata.extras?.getBoolean("isFromPersistentQueue") != true) {
|
||||
playerBottomSheetState.expand(tween(500))
|
||||
} else {
|
||||
playerBottomSheetState.collapse(tween(700))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
player.addListener(listener)
|
||||
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -426,18 +453,33 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onRetainCustomNonConfigurationInstance() = persistMap
|
||||
|
||||
override fun onStop() {
|
||||
unbindService(serviceConnection)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (!isChangingConfigurations) {
|
||||
persistMap.clear()
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun setSystemBarAppearance(isDark: Boolean) {
|
||||
with(WindowCompat.getInsetsController(window, window.decorView.rootView)) {
|
||||
isAppearanceLightStatusBars = !isDark
|
||||
isAppearanceLightNavigationBars = !isDark
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 23) {
|
||||
if (!isAtLeastAndroid6) {
|
||||
window.statusBarColor =
|
||||
(if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
if (!isAtLeastAndroid8) {
|
||||
window.navigationBarColor =
|
||||
(if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb()
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ class MainApplication : Application(), ImageLoaderFactory {
|
|||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.crossfade(true)
|
||||
.respectCacheHeaders(false)
|
||||
.diskCache(
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("coil"))
|
||||
|
|
|
@ -10,7 +10,6 @@ data class Artist(
|
|||
@PrimaryKey val id: String,
|
||||
val name: String? = null,
|
||||
val thumbnailUrl: String? = null,
|
||||
val info: String? = null,
|
||||
val timestamp: Long? = null,
|
||||
val bookmarkedAt: Long? = null,
|
||||
)
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
|
||||
@Immutable
|
||||
open class DetailedSong(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artistsText: String? = null,
|
||||
val durationText: String?,
|
||||
val thumbnailUrl: String?,
|
||||
val totalPlayTimeMs: Long = 0,
|
||||
@Relation(
|
||||
entity = SongAlbumMap::class,
|
||||
entityColumn = "songId",
|
||||
parentColumn = "id",
|
||||
projection = ["albumId"]
|
||||
)
|
||||
val albumId: String?,
|
||||
@Relation(
|
||||
entity = Artist::class,
|
||||
entityColumn = "id",
|
||||
parentColumn = "id",
|
||||
associateBy = Junction(
|
||||
value = SongArtistMap::class,
|
||||
parentColumn = "songId",
|
||||
entityColumn = "artistId"
|
||||
),
|
||||
projection = ["id", "name"]
|
||||
)
|
||||
val artists: List<Info>?
|
||||
) {
|
||||
val formattedTotalPlayTime: String
|
||||
get() {
|
||||
val seconds = totalPlayTimeMs / 1000
|
||||
|
||||
val hours = seconds / 3600
|
||||
|
||||
return when {
|
||||
hours == 0L -> "${seconds / 60}m"
|
||||
hours < 24L -> "${hours}h"
|
||||
else -> "${hours / 24}d"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Relation
|
||||
|
||||
@Immutable
|
||||
class DetailedSongWithContentLength(
|
||||
id: String,
|
||||
title: String,
|
||||
artistsText: String? = null,
|
||||
durationText: String,
|
||||
thumbnailUrl: String?,
|
||||
totalPlayTimeMs: Long = 0,
|
||||
albumId: String?,
|
||||
artists: List<Info>?,
|
||||
@Relation(
|
||||
entity = Format::class,
|
||||
entityColumn = "songId",
|
||||
parentColumn = "id",
|
||||
projection = ["contentLength"]
|
||||
)
|
||||
val contentLength: Long?
|
||||
) : DetailedSong(id, title, artistsText, durationText, thumbnailUrl, totalPlayTimeMs, albumId, artists)
|
23
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Lyrics.kt
Normal file
23
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Lyrics.kt
Normal file
|
@ -0,0 +1,23 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Immutable
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Song::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
]
|
||||
)
|
||||
class Lyrics(
|
||||
@PrimaryKey val songId: String,
|
||||
val fixed: String?,
|
||||
val synced: String?,
|
||||
)
|
|
@ -18,5 +18,5 @@ data class PlaylistWithSongs(
|
|||
entityColumn = "songId"
|
||||
)
|
||||
)
|
||||
val songs: List<DetailedSong>
|
||||
val songs: List<Song>
|
||||
)
|
||||
|
|
|
@ -12,11 +12,22 @@ data class Song(
|
|||
val artistsText: String? = null,
|
||||
val durationText: String?,
|
||||
val thumbnailUrl: String?,
|
||||
val lyrics: String? = null,
|
||||
val synchronizedLyrics: String? = null,
|
||||
val likedAt: Long? = null,
|
||||
val totalPlayTimeMs: Long = 0
|
||||
) {
|
||||
val formattedTotalPlayTime: String
|
||||
get() {
|
||||
val seconds = totalPlayTimeMs / 1000
|
||||
|
||||
val hours = seconds / 3600
|
||||
|
||||
return when {
|
||||
hours == 0L -> "${seconds / 60}m"
|
||||
hours < 24L -> "${hours}h"
|
||||
else -> "${hours / 24}d"
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLike(): Song {
|
||||
return copy(
|
||||
likedAt = if (likedAt == null) System.currentTimeMillis() else null
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Embedded
|
||||
|
||||
@Immutable
|
||||
data class SongWithContentLength(
|
||||
@Embedded val song: Song,
|
||||
val contentLength: Long?
|
||||
)
|
|
@ -1,31 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.vimusic.models.Album
|
||||
|
||||
object AlbumSaver : Saver<Album, List<Any?>> {
|
||||
override fun SaverScope.save(value: Album): List<Any?> = listOf(
|
||||
value.id,
|
||||
value.title,
|
||||
value.thumbnailUrl,
|
||||
value.year,
|
||||
value.authorsText,
|
||||
value.shareUrl,
|
||||
value.timestamp,
|
||||
value.bookmarkedAt,
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>): Album = Album(
|
||||
id = value[0] as String,
|
||||
title = value[1] as String,
|
||||
thumbnailUrl = value[2] as String?,
|
||||
year = value[3] as String?,
|
||||
authorsText = value[4] as String?,
|
||||
shareUrl = value[5] as String?,
|
||||
timestamp = value[6] as Long?,
|
||||
bookmarkedAt = value[7] as Long?,
|
||||
)
|
||||
}
|
||||
|
||||
val AlbumListSaver = listSaver(AlbumSaver)
|
|
@ -1,27 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.vimusic.models.Artist
|
||||
|
||||
object ArtistSaver : Saver<Artist, List<Any?>> {
|
||||
override fun SaverScope.save(value: Artist): List<Any?> = listOf(
|
||||
value.id,
|
||||
value.name,
|
||||
value.thumbnailUrl,
|
||||
value.info,
|
||||
value.timestamp,
|
||||
value.bookmarkedAt,
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>): Artist = Artist(
|
||||
id = value[0] as String,
|
||||
name = value[1] as String,
|
||||
thumbnailUrl = value[2] as String?,
|
||||
info = value[3] as String?,
|
||||
timestamp = value[4] as Long?,
|
||||
bookmarkedAt = value[5] as Long?,
|
||||
)
|
||||
}
|
||||
|
||||
val ArtistListSaver = listSaver(ArtistSaver)
|
|
@ -1,33 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
|
||||
object DetailedSongSaver : Saver<DetailedSong, List<Any?>> {
|
||||
override fun SaverScope.save(value: DetailedSong) =
|
||||
listOf(
|
||||
value.id,
|
||||
value.title,
|
||||
value.artistsText,
|
||||
value.durationText,
|
||||
value.thumbnailUrl,
|
||||
value.totalPlayTimeMs,
|
||||
value.albumId,
|
||||
value.artists?.let { with(InfoListSaver) { save(it) } }
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any?>) = DetailedSong(
|
||||
id = value[0] as String,
|
||||
title = value[1] as String,
|
||||
artistsText = value[2] as String?,
|
||||
durationText = value[3] as String?,
|
||||
thumbnailUrl = value[4] as String?,
|
||||
totalPlayTimeMs = value[5] as Long,
|
||||
albumId = value[6] as String?,
|
||||
artists = (value[7] as List<List<String>>?)?.let(InfoListSaver::restore)
|
||||
)
|
||||
}
|
||||
|
||||
val DetailedSongListSaver = listSaver(DetailedSongSaver)
|
|
@ -1,13 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.vimusic.models.Info
|
||||
|
||||
object InfoSaver : Saver<Info, List<String?>> {
|
||||
override fun SaverScope.save(value: Info) = listOf(value.id, value.name)
|
||||
|
||||
override fun restore(value: List<String?>) = Info(id = value[0] as String, name = value[1])
|
||||
}
|
||||
|
||||
val InfoListSaver = listSaver(InfoSaver)
|
|
@ -1,24 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
object InnertubeAlbumItemSaver : Saver<Innertube.AlbumItem, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.AlbumItem): List<Any?> = listOf(
|
||||
value.info?.let { with(InnertubeBrowseInfoSaver) { save(it) } },
|
||||
value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } },
|
||||
value.year,
|
||||
value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } }
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any?>) = Innertube.AlbumItem(
|
||||
info = (value[0] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
|
||||
authors = (value[1] as List<List<Any?>>?)?.let(InnertubeBrowseInfoListSaver::restore),
|
||||
year = value[2] as String?,
|
||||
thumbnail = (value[3] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
|
||||
)
|
||||
}
|
||||
|
||||
val InnertubeAlbumItemListSaver = listSaver(InnertubeAlbumItemSaver)
|
|
@ -1,21 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
object InnertubeArtistItemSaver : Saver<Innertube.ArtistItem, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.ArtistItem): List<Any?> = listOf(
|
||||
value.info?.let { with(InnertubeBrowseInfoSaver) { save(it) } },
|
||||
value.subscribersCountText,
|
||||
value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } }
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>) = Innertube.ArtistItem(
|
||||
info = (value[0] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
|
||||
subscribersCountText = value[1] as String?,
|
||||
thumbnail = (value[2] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
|
||||
)
|
||||
}
|
||||
|
||||
val InnertubeArtistItemListSaver = listSaver(InnertubeArtistItemSaver)
|
|
@ -1,36 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
object InnertubeArtistPageSaver : Saver<Innertube.ArtistPage, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.ArtistPage) = listOf(
|
||||
value.name,
|
||||
value.description,
|
||||
value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } },
|
||||
value.shuffleEndpoint?.let { with(InnertubeWatchEndpointSaver) { save(it) } },
|
||||
value.radioEndpoint?.let { with(InnertubeWatchEndpointSaver) { save(it) } },
|
||||
value.songs?.let { with(InnertubeSongItemListSaver) { save(it) } },
|
||||
value.songsEndpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } },
|
||||
value.albums?.let { with(InnertubeAlbumItemListSaver) { save(it) } },
|
||||
value.albumsEndpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } },
|
||||
value.singles?.let { with(InnertubeAlbumItemListSaver) { save(it) } },
|
||||
value.singlesEndpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } },
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any?>) = Innertube.ArtistPage(
|
||||
name = value[0] as String?,
|
||||
description = value[1] as String?,
|
||||
thumbnail = (value[2] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore),
|
||||
shuffleEndpoint = (value[3] as List<Any?>?)?.let(InnertubeWatchEndpointSaver::restore),
|
||||
radioEndpoint = (value[4] as List<Any?>?)?.let(InnertubeWatchEndpointSaver::restore),
|
||||
songs = (value[5] as List<List<Any?>>?)?.let(InnertubeSongItemListSaver::restore),
|
||||
songsEndpoint = (value[6] as List<Any?>?)?.let(InnertubeBrowseEndpointSaver::restore),
|
||||
albums = (value[7] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
|
||||
albumsEndpoint = (value[8] as List<Any?>?)?.let(InnertubeBrowseEndpointSaver::restore),
|
||||
singles = (value[9] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
|
||||
singlesEndpoint = (value[10] as List<Any?>?)?.let(InnertubeBrowseEndpointSaver::restore),
|
||||
)
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
|
||||
object InnertubeBrowseEndpointSaver : Saver<NavigationEndpoint.Endpoint.Browse, List<Any?>> {
|
||||
override fun SaverScope.save(value: NavigationEndpoint.Endpoint.Browse) = listOf(
|
||||
value.browseId,
|
||||
value.params
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>) = NavigationEndpoint.Endpoint.Browse(
|
||||
browseId = value[0] as String,
|
||||
params = value[1] as String?,
|
||||
browseEndpointContextSupportedConfigs = null
|
||||
)
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
|
||||
object InnertubeBrowseInfoSaver : Saver<Innertube.Info<NavigationEndpoint.Endpoint.Browse>, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.Info<NavigationEndpoint.Endpoint.Browse>) = listOf(
|
||||
value.name,
|
||||
value.endpoint?.let { with(InnertubeBrowseEndpointSaver) { save(it) } }
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>) = Innertube.Info(
|
||||
name = value[0] as String?,
|
||||
endpoint = (value[1] as List<Any?>?)?.let(InnertubeBrowseEndpointSaver::restore)
|
||||
)
|
||||
}
|
||||
|
||||
val InnertubeBrowseInfoListSaver = listSaver(InnertubeBrowseInfoSaver)
|
|
@ -1,4 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
val InnertubeSongsPageSaver = innertubeItemsPageSaver(InnertubeSongItemListSaver)
|
||||
val InnertubeAlbumsPageSaver = innertubeItemsPageSaver(InnertubeAlbumItemListSaver)
|
|
@ -1,23 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
object InnertubePlaylistItemSaver : Saver<Innertube.PlaylistItem, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.PlaylistItem): List<Any?> = listOf(
|
||||
value.info?.let { with(InnertubeBrowseInfoSaver) { save(it) } },
|
||||
value.channel?.let { with(InnertubeBrowseInfoSaver) { save(it) } },
|
||||
value.songCount,
|
||||
value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } }
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>) = Innertube.PlaylistItem(
|
||||
info = (value[0] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
|
||||
channel = (value[1] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
|
||||
songCount = value[2] as Int?,
|
||||
thumbnail = (value[3] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
|
||||
)
|
||||
}
|
||||
|
||||
val InnertubePlaylistItemListSaver = listSaver(InnertubePlaylistItemSaver)
|
|
@ -1,28 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
object InnertubePlaylistOrAlbumPageSaver : Saver<Innertube.PlaylistOrAlbumPage, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.PlaylistOrAlbumPage): List<Any?> = listOf(
|
||||
value.title,
|
||||
value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } },
|
||||
value.year,
|
||||
value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } } ,
|
||||
value.url,
|
||||
value.songsPage?.let { with(InnertubeSongsPageSaver) { save(it) } },
|
||||
value.otherVersions?.let { with(InnertubeAlbumItemListSaver) { save(it) } },
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any?>) = Innertube.PlaylistOrAlbumPage(
|
||||
title = value[0] as String?,
|
||||
authors = (value[1] as List<List<Any?>>?)?.let(InnertubeBrowseInfoListSaver::restore),
|
||||
year = value[2] as String?,
|
||||
thumbnail = (value[3] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore),
|
||||
url = value[4] as String?,
|
||||
songsPage = (value[5] as List<Any?>?)?.let(InnertubeSongsPageSaver::restore),
|
||||
otherVersions = (value[6] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
|
||||
)
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
object InnertubeRelatedPageSaver : Saver<Innertube.RelatedPage, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.RelatedPage): List<Any?> = listOf(
|
||||
value.songs?.let { with(InnertubeSongItemListSaver) { save(it) } },
|
||||
value.playlists?.let { with(InnertubePlaylistItemListSaver) { save(it) } },
|
||||
value.albums?.let { with(InnertubeAlbumItemListSaver) { save(it) } },
|
||||
value.artists?.let { with(InnertubeArtistItemListSaver) { save(it) } },
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any?>) = Innertube.RelatedPage(
|
||||
songs = (value[0] as List<List<Any?>>?)?.let(InnertubeSongItemListSaver::restore),
|
||||
playlists = (value[1] as List<List<Any?>>?)?.let(InnertubePlaylistItemListSaver::restore),
|
||||
albums = (value[2] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
|
||||
artists = (value[3] as List<List<Any?>>?)?.let(InnertubeArtistItemListSaver::restore),
|
||||
)
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
object InnertubeSongItemSaver : Saver<Innertube.SongItem, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.SongItem): List<Any?> = listOf(
|
||||
value.info?.let { with(InnertubeWatchInfoSaver) { save(it) } },
|
||||
value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } },
|
||||
value.album?.let { with(InnertubeBrowseInfoSaver) { save(it) } },
|
||||
value.durationText,
|
||||
value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } }
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any?>) = Innertube.SongItem(
|
||||
info = (value[0] as List<Any?>?)?.let(InnertubeWatchInfoSaver::restore),
|
||||
authors = (value[1] as List<List<Any?>>?)?.let(InnertubeBrowseInfoListSaver::restore),
|
||||
album = (value[2] as List<Any?>?)?.let(InnertubeBrowseInfoSaver::restore),
|
||||
durationText = value[3] as String?,
|
||||
thumbnail = (value[4] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
|
||||
)
|
||||
}
|
||||
|
||||
val InnertubeSongItemListSaver = listSaver(InnertubeSongItemSaver)
|
|
@ -1,19 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.models.Thumbnail
|
||||
|
||||
object InnertubeThumbnailSaver : Saver<Thumbnail, List<Any?>> {
|
||||
override fun SaverScope.save(value: Thumbnail) = listOf(
|
||||
value.url,
|
||||
value.width,
|
||||
value.height
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>) = Thumbnail(
|
||||
url = value[0] as String,
|
||||
width = value[1] as Int,
|
||||
height = value[2] as Int?,
|
||||
)
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
object InnertubeVideoItemSaver : Saver<Innertube.VideoItem, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.VideoItem): List<Any?> = listOf(
|
||||
value.info?.let { with(InnertubeWatchInfoSaver) { save(it) } },
|
||||
value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } },
|
||||
value.viewsText,
|
||||
value.durationText,
|
||||
value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } }
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any?>) = Innertube.VideoItem(
|
||||
info = (value[0] as List<Any?>?)?.let(InnertubeWatchInfoSaver::restore),
|
||||
authors = (value[1] as List<List<Any?>>?)?.let(InnertubeBrowseInfoListSaver::restore),
|
||||
viewsText = value[2] as String?,
|
||||
durationText = value[3] as String?,
|
||||
thumbnail = (value[4] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore)
|
||||
)
|
||||
}
|
||||
|
||||
val InnertubeVideoItemListSaver = listSaver(InnertubeVideoItemSaver)
|
|
@ -1,24 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
|
||||
object InnertubeWatchEndpointSaver : Saver<NavigationEndpoint.Endpoint.Watch, List<Any?>> {
|
||||
override fun SaverScope.save(value: NavigationEndpoint.Endpoint.Watch) = listOf(
|
||||
value.params,
|
||||
value.playlistId,
|
||||
value.videoId,
|
||||
value.index,
|
||||
value.playlistSetVideoId,
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>) = NavigationEndpoint.Endpoint.Watch(
|
||||
params = value[0] as String?,
|
||||
playlistId = value[1] as String?,
|
||||
videoId = value[2] as String?,
|
||||
index = value[3] as Int?,
|
||||
playlistSetVideoId = value[4] as String?,
|
||||
watchEndpointMusicSupportedConfigs = null
|
||||
)
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
|
||||
object InnertubeWatchInfoSaver : Saver<Innertube.Info<NavigationEndpoint.Endpoint.Watch>, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.Info<NavigationEndpoint.Endpoint.Watch>) = listOf(
|
||||
value.name,
|
||||
value.endpoint?.let { with(InnertubeWatchEndpointSaver) { save(it) } },
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>) = Innertube.Info(
|
||||
name = value[0] as String?,
|
||||
endpoint = (value[1] as List<Any?>?)?.let(InnertubeWatchEndpointSaver::restore)
|
||||
)
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistPreview
|
||||
|
||||
object PlaylistPreviewSaver : Saver<PlaylistPreview, List<Any>> {
|
||||
override fun SaverScope.save(value: PlaylistPreview) = listOf(
|
||||
with(PlaylistSaver) { save(value.playlist) },
|
||||
value.songCount,
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any>) = PlaylistPreview(
|
||||
playlist = PlaylistSaver.restore(value[0] as List<Any?>),
|
||||
songCount = value[1] as Int,
|
||||
)
|
||||
}
|
||||
|
||||
val PlaylistPreviewListSaver = listSaver(PlaylistPreviewSaver)
|
|
@ -1,19 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
|
||||
object PlaylistSaver : Saver<Playlist, List<Any?>> {
|
||||
override fun SaverScope.save(value: Playlist): List<Any?> = listOf(
|
||||
value.id,
|
||||
value.name,
|
||||
value.browseId,
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>): Playlist = Playlist(
|
||||
id = value[0] as Long,
|
||||
name = value[1] as String,
|
||||
browseId = value[2] as String?,
|
||||
)
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
|
||||
|
||||
object PlaylistWithSongsSaver : Saver<PlaylistWithSongs, List<Any>> {
|
||||
override fun SaverScope.save(value: PlaylistWithSongs) = listOf(
|
||||
with(PlaylistSaver) { save(value.playlist) },
|
||||
with(DetailedSongListSaver) { save(value.songs) },
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any>): PlaylistWithSongs = PlaylistWithSongs(
|
||||
playlist = PlaylistSaver.restore(value[0] as List<Any?>),
|
||||
songs = DetailedSongListSaver.restore(value[1] as List<List<Any?>>)
|
||||
)
|
||||
}
|
|
@ -1,52 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
|
||||
interface ListSaver<Original, Saveable : Any> : Saver<List<Original>, List<Saveable>> {
|
||||
override fun SaverScope.save(value: List<Original>): List<Saveable>
|
||||
override fun restore(value: List<Saveable>): List<Original>
|
||||
}
|
||||
|
||||
fun <Original, Saveable : Any> resultSaver(saver: Saver<Original, Saveable>) =
|
||||
object : Saver<Result<Original>?, Pair<Saveable?, Throwable?>> {
|
||||
override fun restore(value: Pair<Saveable?, Throwable?>) =
|
||||
value.first?.let(saver::restore)?.let(Result.Companion::success)
|
||||
?: value.second?.let(Result.Companion::failure)
|
||||
|
||||
override fun SaverScope.save(value: Result<Original>?) =
|
||||
with(saver) { value?.getOrNull()?.let { save(it) } } to value?.exceptionOrNull()
|
||||
}
|
||||
|
||||
fun <Original, Saveable : Any> listSaver(saver: Saver<Original, Saveable>) =
|
||||
object : ListSaver<Original, Saveable> {
|
||||
override fun restore(value: List<Saveable>) =
|
||||
value.mapNotNull(saver::restore)
|
||||
|
||||
override fun SaverScope.save(value: List<Original>) =
|
||||
with(saver) { value.mapNotNull { save(it) } }
|
||||
}
|
||||
|
||||
fun <Original, Saveable : Any> nullableSaver(saver: Saver<Original, Saveable>) =
|
||||
object : Saver<Original?, Saveable> {
|
||||
override fun SaverScope.save(value: Original?): Saveable? =
|
||||
value?.let { with(saver) { save(it) } }
|
||||
|
||||
override fun restore(value: Saveable): Original? =
|
||||
saver.restore(value)
|
||||
}
|
||||
|
||||
fun <Original : Innertube.Item> innertubeItemsPageSaver(saver: ListSaver<Original, List<Any?>>) =
|
||||
object : Saver<Innertube.ItemsPage<Original>, List<Any?>> {
|
||||
override fun SaverScope.save(value: Innertube.ItemsPage<Original>) = listOf(
|
||||
value.items?.let { with(saver) { save(it) } },
|
||||
value.continuation
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any?>) = Innertube.ItemsPage(
|
||||
items = (value[0] as List<List<Any?>>?)?.let(saver::restore),
|
||||
continuation = value[1] as String?
|
||||
)
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import it.vfsfitvnm.vimusic.models.SearchQuery
|
||||
|
||||
object SearchQuerySaver : Saver<SearchQuery, List<Any?>> {
|
||||
override fun SaverScope.save(value: SearchQuery): List<Any?> = listOf(
|
||||
value.id,
|
||||
value.query,
|
||||
)
|
||||
|
||||
override fun restore(value: List<Any?>) = SearchQuery(
|
||||
id = value[0] as Long,
|
||||
query = value[1] as String
|
||||
)
|
||||
}
|
|
@ -7,3 +7,5 @@ class PlayableFormatNotFoundException : PlaybackException(null, null, ERROR_CODE
|
|||
class UnplayableException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR)
|
||||
|
||||
class LoginRequiredException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR)
|
||||
|
||||
class VideoIdMismatchException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR)
|
||||
|
|
|
@ -0,0 +1,301 @@
|
|||
package it.vfsfitvnm.vimusic.service
|
||||
|
||||
import android.media.MediaDescription as BrowserMediaDescription
|
||||
import android.media.browse.MediaBrowser.MediaItem as BrowserMediaItem
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.media.session.MediaSession
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.Process
|
||||
import android.service.media.MediaBrowserService
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.Album
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistPreview
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.models.SongWithContentLength
|
||||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
|
||||
import it.vfsfitvnm.vimusic.utils.forceSeekToNext
|
||||
import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious
|
||||
import it.vfsfitvnm.vimusic.utils.intent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
private var lastSongs = emptyList<Song>()
|
||||
|
||||
private var bound = false
|
||||
|
||||
override fun onDestroy() {
|
||||
if (bound) {
|
||||
unbindService(this)
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
if (service is PlayerService.Binder) {
|
||||
bound = true
|
||||
sessionToken = service.mediaSession.sessionToken
|
||||
service.mediaSession.setCallback(SessionCallback(service.player, service.cache))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) = Unit
|
||||
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot? {
|
||||
return if (clientUid == Process.myUid()
|
||||
|| clientUid == Process.SYSTEM_UID
|
||||
|| clientPackageName == "com.google.android.projection.gearhead"
|
||||
) {
|
||||
bindService(intent<PlayerService>(), this, Context.BIND_AUTO_CREATE)
|
||||
BrowserRoot(
|
||||
MediaId.root,
|
||||
bundleOf("android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" to 1)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadChildren(parentId: String, result: Result<MutableList<BrowserMediaItem>>) {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
result.sendResult(
|
||||
when (parentId) {
|
||||
MediaId.root -> mutableListOf(
|
||||
songsBrowserMediaItem,
|
||||
playlistsBrowserMediaItem,
|
||||
albumsBrowserMediaItem
|
||||
)
|
||||
|
||||
MediaId.songs -> Database
|
||||
.songsByPlayTimeDesc()
|
||||
.first()
|
||||
.take(30)
|
||||
.also { lastSongs = it }
|
||||
.map { it.asBrowserMediaItem }
|
||||
.toMutableList()
|
||||
.apply {
|
||||
if (isNotEmpty()) add(0, shuffleBrowserMediaItem)
|
||||
}
|
||||
|
||||
MediaId.playlists -> Database
|
||||
.playlistPreviewsByDateAddedDesc()
|
||||
.first()
|
||||
.map { it.asBrowserMediaItem }
|
||||
.toMutableList()
|
||||
.apply {
|
||||
add(0, favoritesBrowserMediaItem)
|
||||
add(1, offlineBrowserMediaItem)
|
||||
}
|
||||
|
||||
MediaId.albums -> Database
|
||||
.albumsByRowIdDesc()
|
||||
.first()
|
||||
.map { it.asBrowserMediaItem }
|
||||
.toMutableList()
|
||||
|
||||
else -> mutableListOf()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun uriFor(@DrawableRes id: Int) = Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(resources.getResourcePackageName(id))
|
||||
.appendPath(resources.getResourceTypeName(id))
|
||||
.appendPath(resources.getResourceEntryName(id))
|
||||
.build()
|
||||
|
||||
private val shuffleBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.shuffle)
|
||||
.setTitle("Shuffle")
|
||||
.setIconUri(uriFor(R.drawable.shuffle))
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
|
||||
private val songsBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.songs)
|
||||
.setTitle("Songs")
|
||||
.setIconUri(uriFor(R.drawable.musical_notes))
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
|
||||
|
||||
private val playlistsBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.playlists)
|
||||
.setTitle("Playlists")
|
||||
.setIconUri(uriFor(R.drawable.playlist))
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
|
||||
private val albumsBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.albums)
|
||||
.setTitle("Albums")
|
||||
.setIconUri(uriFor(R.drawable.disc))
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
|
||||
private val favoritesBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.favorites)
|
||||
.setTitle("Favorites")
|
||||
.setIconUri(uriFor(R.drawable.heart))
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
|
||||
private val offlineBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.offline)
|
||||
.setTitle("Offline")
|
||||
.setIconUri(uriFor(R.drawable.airplane))
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
|
||||
private val Song.asBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.forSong(id))
|
||||
.setTitle(title)
|
||||
.setSubtitle(artistsText)
|
||||
.setIconUri(thumbnailUrl?.toUri())
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
|
||||
private val PlaylistPreview.asBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.forPlaylist(playlist.id))
|
||||
.setTitle(playlist.name)
|
||||
.setSubtitle("$songCount songs")
|
||||
.setIconUri(uriFor(R.drawable.playlist))
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
|
||||
private val Album.asBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.forAlbum(id))
|
||||
.setTitle(title)
|
||||
.setSubtitle(authorsText)
|
||||
.setIconUri(thumbnailUrl?.toUri())
|
||||
.build(),
|
||||
BrowserMediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
|
||||
private inner class SessionCallback(private val player: Player, private val cache: Cache) :
|
||||
MediaSession.Callback() {
|
||||
override fun onPlay() = player.play()
|
||||
override fun onPause() = player.pause()
|
||||
override fun onSkipToPrevious() = player.forceSeekToPrevious()
|
||||
override fun onSkipToNext() = player.forceSeekToNext()
|
||||
override fun onSeekTo(pos: Long) = player.seekTo(pos)
|
||||
override fun onSkipToQueueItem(id: Long) = player.seekToDefaultPosition(id.toInt())
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
val data = mediaId?.split('/') ?: return
|
||||
var index = 0
|
||||
|
||||
coroutineScope.launch {
|
||||
val mediaItems = when (data.getOrNull(0)) {
|
||||
MediaId.shuffle -> lastSongs
|
||||
|
||||
MediaId.songs -> data
|
||||
.getOrNull(1)
|
||||
?.let { songId ->
|
||||
index = lastSongs.indexOfFirst { it.id == songId }
|
||||
lastSongs
|
||||
}
|
||||
|
||||
MediaId.favorites -> Database
|
||||
.favorites()
|
||||
.first()
|
||||
.shuffled()
|
||||
|
||||
MediaId.offline -> Database
|
||||
.songsWithContentLength()
|
||||
.first()
|
||||
.filter { song ->
|
||||
song.contentLength?.let {
|
||||
cache.isCached(song.song.id, 0, it)
|
||||
} ?: false
|
||||
}
|
||||
.map(SongWithContentLength::song)
|
||||
.shuffled()
|
||||
|
||||
MediaId.playlists -> data
|
||||
.getOrNull(1)
|
||||
?.toLongOrNull()
|
||||
?.let(Database::playlistWithSongs)
|
||||
?.first()
|
||||
?.songs
|
||||
?.shuffled()
|
||||
|
||||
MediaId.albums -> data
|
||||
.getOrNull(1)
|
||||
?.let(Database::albumSongs)
|
||||
?.first()
|
||||
|
||||
else -> emptyList()
|
||||
}?.map(Song::asMediaItem) ?: return@launch
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
player.forcePlayAtIndex(mediaItems, index.coerceIn(0, mediaItems.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object MediaId {
|
||||
const val root = "root"
|
||||
const val songs = "songs"
|
||||
const val playlists = "playlists"
|
||||
const val albums = "albums"
|
||||
|
||||
const val favorites = "favorites"
|
||||
const val offline = "offline"
|
||||
const val shuffle = "shuffle"
|
||||
|
||||
fun forSong(id: String) = "songs/$id"
|
||||
fun forPlaylist(id: Long) = "playlists/$id"
|
||||
fun forAlbum(id: String) = "albums/$id"
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package it.vfsfitvnm.vimusic.service
|
||||
|
||||
import android.os.Binder as AndroidBinder
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
|
@ -11,14 +12,19 @@ import android.content.Intent
|
|||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.database.SQLException
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaDescription
|
||||
import android.media.MediaMetadata
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.media.audiofx.LoudnessEnhancer
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.runtime.getValue
|
||||
|
@ -26,7 +32,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat.startForegroundService
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.isDigitsOnly
|
||||
|
@ -62,6 +67,10 @@ import androidx.media3.exoplayer.source.MediaSource
|
|||
import androidx.media3.extractor.ExtractorsFactory
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.innertube.models.bodies.PlayerBody
|
||||
import it.vfsfitvnm.innertube.requests.player
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.MainActivity
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
|
@ -82,20 +91,21 @@ import it.vfsfitvnm.vimusic.utils.forceSeekToNext
|
|||
import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious
|
||||
import it.vfsfitvnm.vimusic.utils.getEnum
|
||||
import it.vfsfitvnm.vimusic.utils.intent
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid13
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid8
|
||||
import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey
|
||||
import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey
|
||||
import it.vfsfitvnm.vimusic.utils.mediaItems
|
||||
import it.vfsfitvnm.vimusic.utils.persistentQueueKey
|
||||
import it.vfsfitvnm.vimusic.utils.preferences
|
||||
import it.vfsfitvnm.vimusic.utils.repeatModeKey
|
||||
import it.vfsfitvnm.vimusic.utils.queueLoopEnabledKey
|
||||
import it.vfsfitvnm.vimusic.utils.resumePlaybackWhenDeviceConnectedKey
|
||||
import it.vfsfitvnm.vimusic.utils.shouldBePlaying
|
||||
import it.vfsfitvnm.vimusic.utils.skipSilenceKey
|
||||
import it.vfsfitvnm.vimusic.utils.timer
|
||||
import it.vfsfitvnm.vimusic.utils.trackLoopEnabledKey
|
||||
import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.PlayerBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.player
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.system.exitProcess
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
@ -103,12 +113,10 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.cancellable
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListener.Callback,
|
||||
|
@ -121,10 +129,13 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
.setActions(
|
||||
PlaybackState.ACTION_PLAY
|
||||
or PlaybackState.ACTION_PAUSE
|
||||
or PlaybackState.ACTION_PLAY_PAUSE
|
||||
or PlaybackState.ACTION_STOP
|
||||
or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||
or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||
or PlaybackState.ACTION_PLAY_PAUSE
|
||||
or PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM
|
||||
or PlaybackState.ACTION_SEEK_TO
|
||||
or PlaybackState.ACTION_REWIND
|
||||
)
|
||||
|
||||
private val metadataBuilder = MediaMetadata.Builder()
|
||||
|
@ -141,11 +152,15 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
|
||||
private var volumeNormalizationJob: Job? = null
|
||||
|
||||
private var isVolumeNormalizationEnabled = false
|
||||
private var isPersistentQueueEnabled = false
|
||||
private var isShowingThumbnailInLockscreen = true
|
||||
override var isInvincibilityEnabled = false
|
||||
|
||||
private var audioManager: AudioManager? = null
|
||||
private var audioDeviceCallback: AudioDeviceCallback? = null
|
||||
|
||||
private var loudnessEnhancer: LoudnessEnhancer? = null
|
||||
|
||||
private val binder = Binder()
|
||||
|
||||
private var isNotificationStarted = false
|
||||
|
@ -176,7 +191,6 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
|
||||
val preferences = preferences
|
||||
isPersistentQueueEnabled = preferences.getBoolean(persistentQueueKey, false)
|
||||
isVolumeNormalizationEnabled = preferences.getBoolean(volumeNormalizationKey, false)
|
||||
isInvincibilityEnabled = preferences.getBoolean(isInvincibilityEnabledKey, false)
|
||||
isShowingThumbnailInLockscreen =
|
||||
preferences.getBoolean(isShowingThumbnailInLockscreenKey, false)
|
||||
|
@ -218,10 +232,12 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
.setUsePlatformDiagnostics(false)
|
||||
.build()
|
||||
|
||||
player.repeatMode = when (preferences.getInt(repeatModeKey, Player.REPEAT_MODE_ALL)) {
|
||||
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_ALL
|
||||
player.repeatMode = when {
|
||||
preferences.getBoolean(trackLoopEnabledKey, false) -> Player.REPEAT_MODE_ONE
|
||||
preferences.getBoolean(queueLoopEnabledKey, true) -> Player.REPEAT_MODE_ALL
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
|
||||
player.skipSilenceEnabled = preferences.getBoolean(skipSilenceKey, false)
|
||||
player.addListener(this)
|
||||
player.addAnalyticsListener(PlaybackStatsListener(false, this))
|
||||
|
@ -229,6 +245,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
maybeRestorePlayerQueue()
|
||||
|
||||
mediaSession = MediaSession(baseContext, "PlayerService")
|
||||
mediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||
mediaSession.setCallback(SessionCallback(player))
|
||||
mediaSession.setPlaybackState(stateBuilder.build())
|
||||
mediaSession.isActive = true
|
||||
|
@ -243,6 +260,8 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
}
|
||||
|
||||
registerReceiver(notificationActionReceiver, filter)
|
||||
|
||||
maybeResumePlaybackWhenDeviceConnected()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
|
@ -267,6 +286,8 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
mediaSession.release()
|
||||
cache.release()
|
||||
|
||||
loudnessEnhancer?.release()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
@ -298,8 +319,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
|
||||
if (totalPlayTimeMs > 30000) {
|
||||
query {
|
||||
// THANKS, EXOPLAYER
|
||||
if (runBlocking { Database.song(mediaItem.mediaId).first() } != null) {
|
||||
try {
|
||||
Database.insert(
|
||||
Event(
|
||||
songId = mediaItem.mediaId,
|
||||
|
@ -307,6 +327,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
playTime = totalPlayTimeMs
|
||||
)
|
||||
)
|
||||
} catch (_: SQLException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -322,6 +343,51 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
} else if (mediaItem.mediaMetadata.artworkUri == bitmapProvider.lastUri) {
|
||||
bitmapProvider.listener?.invoke(bitmapProvider.lastBitmap)
|
||||
}
|
||||
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) {
|
||||
updateMediaSessionQueue(player.currentTimeline)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
|
||||
updateMediaSessionQueue(timeline)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMediaSessionQueue(timeline: Timeline) {
|
||||
val builder = MediaDescription.Builder()
|
||||
|
||||
val currentMediaItemIndex = player.currentMediaItemIndex
|
||||
val lastIndex = timeline.windowCount - 1
|
||||
var startIndex = currentMediaItemIndex - 7
|
||||
var endIndex = currentMediaItemIndex + 7
|
||||
|
||||
if (startIndex < 0) {
|
||||
endIndex -= startIndex
|
||||
}
|
||||
|
||||
if (endIndex > lastIndex) {
|
||||
startIndex -= (endIndex - lastIndex)
|
||||
endIndex = lastIndex
|
||||
}
|
||||
|
||||
startIndex = startIndex.coerceAtLeast(0)
|
||||
|
||||
mediaSession.setQueue(
|
||||
List(endIndex - startIndex + 1) { index ->
|
||||
val mediaItem = timeline.getWindow(index + startIndex, Timeline.Window()).mediaItem
|
||||
MediaSession.QueueItem(
|
||||
builder
|
||||
.setMediaId(mediaItem.mediaId)
|
||||
.setTitle(mediaItem.mediaMetadata.title)
|
||||
.setSubtitle(mediaItem.mediaMetadata.artist)
|
||||
.setIconUri(mediaItem.mediaMetadata.artworkUri)
|
||||
.build(),
|
||||
(index + startIndex).toLong()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun maybeRecoverPlaybackError() {
|
||||
|
@ -377,11 +443,13 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
mediaItem.mediaItem.buildUpon()
|
||||
.setUri(mediaItem.mediaItem.mediaId)
|
||||
.setCustomCacheKey(mediaItem.mediaItem.mediaId)
|
||||
.build()
|
||||
.build().apply {
|
||||
mediaMetadata.extras?.putBoolean("isFromPersistentQueue", true)
|
||||
}
|
||||
},
|
||||
true
|
||||
index,
|
||||
queuedSong[index].position ?: C.TIME_UNSET
|
||||
)
|
||||
player.seekTo(index, queuedSong[index].position ?: 0)
|
||||
player.prepare()
|
||||
|
||||
isNotificationStarted = true
|
||||
|
@ -392,35 +460,84 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
}
|
||||
|
||||
private fun maybeNormalizeVolume() {
|
||||
if (!isVolumeNormalizationEnabled) {
|
||||
if (!preferences.getBoolean(volumeNormalizationKey, false)) {
|
||||
loudnessEnhancer?.enabled = false
|
||||
loudnessEnhancer?.release()
|
||||
loudnessEnhancer = null
|
||||
volumeNormalizationJob?.cancel()
|
||||
player.volume = 1f
|
||||
return
|
||||
}
|
||||
|
||||
if (loudnessEnhancer == null) {
|
||||
loudnessEnhancer = LoudnessEnhancer(player.audioSessionId)
|
||||
}
|
||||
|
||||
player.currentMediaItem?.mediaId?.let { songId ->
|
||||
volumeNormalizationJob?.cancel()
|
||||
volumeNormalizationJob = coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.loudnessDb(songId).cancellable().distinctUntilChanged()
|
||||
.collect { loudnessDb ->
|
||||
withContext(Dispatchers.Main) {
|
||||
player.volume = if (loudnessDb != null && loudnessDb > 0) {
|
||||
(1f - (0.01f + loudnessDb / 14)).coerceIn(0.1f, 1f)
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
}
|
||||
volumeNormalizationJob = coroutineScope.launch(Dispatchers.Main) {
|
||||
Database.loudnessDb(songId).cancellable().collectLatest { loudnessDb ->
|
||||
try {
|
||||
loudnessEnhancer?.setTargetGain(-((loudnessDb ?: 0f) * 100).toInt() + 500)
|
||||
loudnessEnhancer?.enabled = true
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeShowSongCoverInLockScreen() {
|
||||
val bitmap = if (isShowingThumbnailInLockscreen) bitmapProvider.bitmap else null
|
||||
metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
val bitmap =
|
||||
if (isAtLeastAndroid13 || isShowingThumbnailInLockscreen) bitmapProvider.bitmap else null
|
||||
|
||||
metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmap)
|
||||
|
||||
if (isAtLeastAndroid13 && player.currentMediaItemIndex == 0) {
|
||||
metadataBuilder.putText(
|
||||
MediaMetadata.METADATA_KEY_TITLE,
|
||||
"${player.mediaMetadata.title} "
|
||||
)
|
||||
}
|
||||
|
||||
mediaSession.setMetadata(metadataBuilder.build())
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun maybeResumePlaybackWhenDeviceConnected() {
|
||||
if (!isAtLeastAndroid6) return
|
||||
|
||||
if (preferences.getBoolean(resumePlaybackWhenDeviceConnectedKey, false)) {
|
||||
if (audioManager == null) {
|
||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager?
|
||||
}
|
||||
|
||||
audioDeviceCallback = object : AudioDeviceCallback() {
|
||||
private fun canPlayMusic(audioDeviceInfo: AudioDeviceInfo): Boolean {
|
||||
if (!audioDeviceInfo.isSink) return false
|
||||
|
||||
return audioDeviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
|
||||
audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
|
||||
audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
|
||||
audioDeviceInfo.type == AudioDeviceInfo.TYPE_USB_HEADSET
|
||||
}
|
||||
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<AudioDeviceInfo>) {
|
||||
if (!player.isPlaying && addedDevices.any(::canPlayMusic)) {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<AudioDeviceInfo>) = Unit
|
||||
}
|
||||
|
||||
audioManager?.registerAudioDeviceCallback(audioDeviceCallback, handler)
|
||||
|
||||
} else {
|
||||
audioManager?.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||
audioDeviceCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendOpenEqualizerIntent() {
|
||||
sendBroadcast(
|
||||
Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply {
|
||||
|
@ -448,27 +565,16 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
else -> PlaybackState.STATE_NONE
|
||||
}
|
||||
|
||||
override fun onRepeatModeChanged(repeatMode: Int) {
|
||||
preferences.edit { putInt(repeatModeKey, repeatMode) }
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
if (player.duration != C.TIME_UNSET) {
|
||||
metadataBuilder
|
||||
.putText(
|
||||
MediaMetadata.METADATA_KEY_TITLE,
|
||||
player.currentMediaItem?.mediaMetadata?.title
|
||||
)
|
||||
.putText(
|
||||
MediaMetadata.METADATA_KEY_ARTIST,
|
||||
player.currentMediaItem?.mediaMetadata?.artist
|
||||
)
|
||||
.putText(
|
||||
MediaMetadata.METADATA_KEY_ALBUM,
|
||||
player.currentMediaItem?.mediaMetadata?.albumTitle
|
||||
)
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration)
|
||||
.build().let(mediaSession::setMetadata)
|
||||
mediaSession.setMetadata(
|
||||
metadataBuilder
|
||||
.putText(MediaMetadata.METADATA_KEY_TITLE, player.mediaMetadata.title)
|
||||
.putText(MediaMetadata.METADATA_KEY_ARTIST, player.mediaMetadata.artist)
|
||||
.putText(MediaMetadata.METADATA_KEY_ALBUM, player.mediaMetadata.albumTitle)
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
stateBuilder
|
||||
|
@ -517,18 +623,27 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
when (key) {
|
||||
persistentQueueKey -> isPersistentQueueEnabled =
|
||||
sharedPreferences.getBoolean(key, isPersistentQueueEnabled)
|
||||
volumeNormalizationKey -> {
|
||||
isVolumeNormalizationEnabled =
|
||||
sharedPreferences.getBoolean(key, isVolumeNormalizationEnabled)
|
||||
maybeNormalizeVolume()
|
||||
}
|
||||
|
||||
volumeNormalizationKey -> maybeNormalizeVolume()
|
||||
|
||||
resumePlaybackWhenDeviceConnectedKey -> maybeResumePlaybackWhenDeviceConnected()
|
||||
|
||||
isInvincibilityEnabledKey -> isInvincibilityEnabled =
|
||||
sharedPreferences.getBoolean(key, isInvincibilityEnabled)
|
||||
|
||||
skipSilenceKey -> player.skipSilenceEnabled = sharedPreferences.getBoolean(key, false)
|
||||
isShowingThumbnailInLockscreenKey -> {
|
||||
isShowingThumbnailInLockscreen = sharedPreferences.getBoolean(key, true)
|
||||
maybeShowSongCoverInLockScreen()
|
||||
}
|
||||
|
||||
trackLoopEnabledKey, queueLoopEnabledKey -> {
|
||||
player.repeatMode = when {
|
||||
preferences.getBoolean(trackLoopEnabledKey, false) -> Player.REPEAT_MODE_ONE
|
||||
preferences.getBoolean(queueLoopEnabledKey, true) -> Player.REPEAT_MODE_ALL
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -542,7 +657,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
|
||||
val mediaMetadata = player.mediaMetadata
|
||||
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val builder = if (isAtLeastAndroid8) {
|
||||
Notification.Builder(applicationContext, NotificationChannelId)
|
||||
} else {
|
||||
Notification.Builder(applicationContext)
|
||||
|
@ -589,7 +704,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
private fun createNotificationChannel() {
|
||||
notificationManager = getSystemService()
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
if (!isAtLeastAndroid8) return
|
||||
|
||||
notificationManager?.run {
|
||||
if (getNotificationChannel(NotificationChannelId) == null) {
|
||||
|
@ -650,19 +765,26 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
val urlResult = runBlocking(Dispatchers.IO) {
|
||||
Innertube.player(PlayerBody(videoId = videoId))
|
||||
}?.mapCatching { body ->
|
||||
if (body.videoDetails?.videoId != videoId) {
|
||||
throw VideoIdMismatchException()
|
||||
}
|
||||
|
||||
when (val status = body.playabilityStatus?.status) {
|
||||
"OK" -> body.streamingData?.adaptiveFormats?.findLast { format ->
|
||||
format.itag == 251 || format.itag == 140
|
||||
}?.let { format ->
|
||||
"OK" -> body.streamingData?.highestQualityFormat?.let { format ->
|
||||
val mediaItem = runBlocking(Dispatchers.Main) {
|
||||
player.findNextMediaItemById(videoId)
|
||||
}
|
||||
|
||||
if (mediaItem?.mediaMetadata?.extras?.getString("durationText") == null) {
|
||||
format.approxDurationMs?.div(1000)?.let(DateUtils::formatElapsedTime)?.removePrefix("0")?.let { durationText ->
|
||||
mediaItem?.mediaMetadata?.extras?.putString("durationText", durationText)
|
||||
Database.updateDurationText(videoId, durationText)
|
||||
}
|
||||
format.approxDurationMs?.div(1000)
|
||||
?.let(DateUtils::formatElapsedTime)?.removePrefix("0")
|
||||
?.let { durationText ->
|
||||
mediaItem?.mediaMetadata?.extras?.putString(
|
||||
"durationText",
|
||||
durationText
|
||||
)
|
||||
Database.updateDurationText(videoId, durationText)
|
||||
}
|
||||
}
|
||||
|
||||
query {
|
||||
|
@ -674,7 +796,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
itag = format.itag,
|
||||
mimeType = format.mimeType,
|
||||
bitrate = format.bitrate,
|
||||
loudnessDb = body.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
|
||||
loudnessDb = body.playerConfig?.audioConfig?.normalizedLoudnessDb,
|
||||
contentLength = format.contentLength,
|
||||
lastModified = format.lastModified
|
||||
)
|
||||
|
@ -683,6 +805,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
|
||||
format.url
|
||||
} ?: throw PlayableFormatNotFoundException()
|
||||
|
||||
"UNPLAYABLE" -> throw UnplayableException()
|
||||
"LOGIN_REQUIRED" -> throw LoginRequiredException()
|
||||
else -> throw PlaybackException(
|
||||
|
@ -752,6 +875,9 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
val cache: Cache
|
||||
get() = this@PlayerService.cache
|
||||
|
||||
val mediaSession
|
||||
get() = this@PlayerService.mediaSession
|
||||
|
||||
val sleepTimerMillisLeft: StateFlow<Long?>?
|
||||
get() = timerJob?.millisLeft
|
||||
|
||||
|
@ -828,9 +954,12 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
private class SessionCallback(private val player: Player) : MediaSession.Callback() {
|
||||
override fun onPlay() = player.play()
|
||||
override fun onPause() = player.pause()
|
||||
override fun onSkipToPrevious() = player.forceSeekToPrevious()
|
||||
override fun onSkipToNext() = player.forceSeekToNext()
|
||||
override fun onSkipToPrevious() = runCatching(player::forceSeekToPrevious).let { }
|
||||
override fun onSkipToNext() = runCatching(player::forceSeekToNext).let { }
|
||||
override fun onSeekTo(pos: Long) = player.seekTo(pos)
|
||||
override fun onStop() = player.pause()
|
||||
override fun onRewind() = player.seekToDefaultPosition()
|
||||
override fun onSkipToQueueItem(id: Long) = runCatching { player.seekToDefaultPosition(id.toInt()) }.let { }
|
||||
}
|
||||
|
||||
private class NotificationActionReceiver(private val player: Player) : BroadcastReceiver() {
|
||||
|
@ -858,7 +987,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
this@Context,
|
||||
100,
|
||||
Intent(value).setPackage(packageName),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT.or(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
|
||||
PendingIntent.FLAG_UPDATE_CURRENT.or(if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0)
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -129,7 +129,7 @@ class BottomSheetState(
|
|||
1f - (animatable.upperBound!! - animatable.value) / (animatable.upperBound!! - collapsedBound)
|
||||
}
|
||||
|
||||
private fun collapse(animationSpec: AnimationSpec<Dp>) {
|
||||
fun collapse(animationSpec: AnimationSpec<Dp>) {
|
||||
onAnchorChanged(collapsedAnchor)
|
||||
coroutineScope.launch {
|
||||
animatable.animateTo(collapsedBound, animationSpec)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.runtime.Composable
|
||||
|
@ -16,10 +17,12 @@ import com.valentinilk.shimmer.shimmer
|
|||
fun ShimmerHost(
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
verticalArrangement = verticalArrangement,
|
||||
modifier = modifier
|
||||
.shimmer()
|
||||
.graphicsLayer(alpha = 0.99f)
|
||||
|
|
|
@ -3,13 +3,11 @@ package it.vfsfitvnm.vimusic.ui.components.themed
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
|
@ -49,28 +47,24 @@ fun Header(
|
|||
@Composable
|
||||
fun Header(
|
||||
modifier: Modifier = Modifier,
|
||||
titleContent: @Composable ColumnScope.() -> Unit,
|
||||
titleContent: @Composable () -> Unit,
|
||||
actionsContent: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(Dimensions.headerHeight)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.height(48.dp),
|
||||
)
|
||||
|
||||
titleContent()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.height(48.dp),
|
||||
.align(Alignment.BottomEnd)
|
||||
.heightIn(min = 48.dp),
|
||||
content = actionsContent,
|
||||
)
|
||||
}
|
||||
|
@ -82,12 +76,11 @@ fun HeaderPlaceholder(
|
|||
) {
|
||||
val (colorPalette, typography) = LocalAppearance.current
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(128.dp)
|
||||
.height(Dimensions.headerHeight)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
package it.vfsfitvnm.vimusic.ui.components.themed
|
||||
|
||||
import android.content.Intent
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.with
|
||||
import androidx.compose.foundation.Image
|
||||
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
@ -21,8 +22,10 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.requiredHeight
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
@ -40,12 +43,13 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import it.vfsfitvnm.innertube.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.PlaylistSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.SortOrder
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.models.Info
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
|
||||
|
@ -62,17 +66,20 @@ import it.vfsfitvnm.vimusic.utils.addNext
|
|||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.enqueue
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.vimusic.utils.formatAsDuration
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun InHistoryMediaItemMenu(
|
||||
onDismiss: () -> Unit,
|
||||
song: DetailedSong,
|
||||
song: Song,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val binder = LocalPlayerServiceBinder.current
|
||||
|
@ -83,7 +90,7 @@ fun InHistoryMediaItemMenu(
|
|||
|
||||
if (isHiding) {
|
||||
ConfirmationDialog(
|
||||
text = "Do you really hide this song? Its playback time and cache will be wiped.\nThis action is irreversible.",
|
||||
text = "Do you really want to hide this song? Its playback time and cache will be wiped.\nThis action is irreversible.",
|
||||
onDismiss = { isHiding = false },
|
||||
onConfirm = {
|
||||
onDismiss()
|
||||
|
@ -110,7 +117,7 @@ fun InPlaylistMediaItemMenu(
|
|||
onDismiss: () -> Unit,
|
||||
playlistId: Long,
|
||||
positionInPlaylist: Int,
|
||||
song: DetailedSong,
|
||||
song: Song,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
NonQueuedMediaItemMenu(
|
||||
|
@ -134,6 +141,7 @@ fun NonQueuedMediaItemMenu(
|
|||
modifier: Modifier = Modifier,
|
||||
onRemoveFromPlaylist: (() -> Unit)? = null,
|
||||
onHideFromDatabase: (() -> Unit)? = null,
|
||||
onRemoveFromQuickPicks: (() -> Unit)? = null,
|
||||
) {
|
||||
val binder = LocalPlayerServiceBinder.current
|
||||
|
||||
|
@ -154,6 +162,7 @@ fun NonQueuedMediaItemMenu(
|
|||
onEnqueue = { binder?.player?.enqueue(mediaItem) },
|
||||
onRemoveFromPlaylist = onRemoveFromPlaylist,
|
||||
onHideFromDatabase = onHideFromDatabase,
|
||||
onRemoveFromQuickPicks = onRemoveFromQuickPicks,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
@ -192,6 +201,7 @@ fun BaseMediaItemMenu(
|
|||
onRemoveFromQueue: (() -> Unit)? = null,
|
||||
onRemoveFromPlaylist: (() -> Unit)? = null,
|
||||
onHideFromDatabase: (() -> Unit)? = null,
|
||||
onRemoveFromQuickPicks: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
@ -232,6 +242,7 @@ fun BaseMediaItemMenu(
|
|||
|
||||
context.startActivity(Intent.createChooser(sendIntent, null))
|
||||
},
|
||||
onRemoveFromQuickPicks = onRemoveFromQuickPicks,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
@ -253,6 +264,7 @@ fun MediaItemMenu(
|
|||
onAddToPlaylist: ((Playlist, Int) -> Unit)? = null,
|
||||
onGoToAlbum: ((String) -> Unit)? = null,
|
||||
onGoToArtist: ((String) -> Unit)? = null,
|
||||
onRemoveFromQuickPicks: (() -> Unit)? = null,
|
||||
onShare: () -> Unit
|
||||
) {
|
||||
val (colorPalette) = LocalAppearance.current
|
||||
|
@ -266,15 +278,43 @@ fun MediaItemMenu(
|
|||
mutableStateOf(0.dp)
|
||||
}
|
||||
|
||||
val likedAt by remember(mediaItem.mediaId) {
|
||||
Database.likedAt(mediaItem.mediaId).distinctUntilChanged()
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
var albumInfo by remember {
|
||||
mutableStateOf(mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId ->
|
||||
Info(albumId, null)
|
||||
})
|
||||
}
|
||||
|
||||
var artistsInfo by remember {
|
||||
mutableStateOf(
|
||||
mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames ->
|
||||
mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")?.let { artistIds ->
|
||||
artistNames.zip(artistIds).map { (authorName, authorId) ->
|
||||
Info(authorId, authorName)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var likedAt by remember {
|
||||
mutableStateOf<Long?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (albumInfo == null) albumInfo = Database.songAlbumInfo(mediaItem.mediaId)
|
||||
if (artistsInfo == null) artistsInfo = Database.songArtistInfo(mediaItem.mediaId)
|
||||
|
||||
Database.likedAt(mediaItem.mediaId).collect { likedAt = it }
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = isViewingPlaylists,
|
||||
transitionSpec = {
|
||||
val animationSpec = tween<IntOffset>(400)
|
||||
val slideDirection = if (targetState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
||||
val slideDirection =
|
||||
if (targetState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
||||
|
||||
slideIntoContainer(slideDirection, animationSpec) with
|
||||
slideOutOfContainer(slideDirection, animationSpec)
|
||||
|
@ -341,10 +381,7 @@ fun MediaItemMenu(
|
|||
secondaryText = "${playlistPreview.songCount} songs",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onAddToPlaylist(
|
||||
playlistPreview.playlist,
|
||||
playlistPreview.songCount
|
||||
)
|
||||
onAddToPlaylist(playlistPreview.playlist, playlistPreview.songCount)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -358,11 +395,23 @@ fun MediaItemMenu(
|
|||
val thumbnailSizeDp = Dimensions.thumbnails.song
|
||||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
SongItem(
|
||||
song = mediaItem,
|
||||
thumbnailSizeDp = thumbnailSizeDp,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
trailingContent = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailUrl = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx)
|
||||
?.toString(),
|
||||
title = mediaItem.mediaMetadata.title.toString(),
|
||||
authors = mediaItem.mediaMetadata.artist.toString(),
|
||||
duration = null,
|
||||
thumbnailSizeDp = thumbnailSizeDp,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
IconButton(
|
||||
icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart,
|
||||
color = colorPalette.favoritesIcon,
|
||||
|
@ -381,10 +430,17 @@ fun MediaItemMenu(
|
|||
.padding(all = 4.dp)
|
||||
.size(18.dp)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onShare)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
icon = R.drawable.share_social,
|
||||
color = colorPalette.text,
|
||||
onClick = onShare,
|
||||
modifier = Modifier
|
||||
.padding(all = 4.dp)
|
||||
.size(17.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
|
@ -468,19 +524,16 @@ fun MediaItemMenu(
|
|||
text = "Do you want to stop the sleep timer?",
|
||||
cancelText = "No",
|
||||
confirmText = "Stop",
|
||||
onDismiss = {
|
||||
isShowingSleepTimerDialog = false
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = { isShowingSleepTimerDialog = false },
|
||||
onConfirm = {
|
||||
binder?.cancelSleepTimer()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DefaultDialog(onDismiss = {
|
||||
isShowingSleepTimerDialog = false
|
||||
}) {
|
||||
DefaultDialog(
|
||||
onDismiss = { isShowingSleepTimerDialog = false }
|
||||
) {
|
||||
var amount by remember {
|
||||
mutableStateOf(1)
|
||||
}
|
||||
|
@ -552,10 +605,7 @@ fun MediaItemMenu(
|
|||
) {
|
||||
DialogTextButton(
|
||||
text = "Cancel",
|
||||
onClick = {
|
||||
isShowingSleepTimerDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
onClick = { isShowingSleepTimerDialog = false }
|
||||
)
|
||||
|
||||
DialogTextButton(
|
||||
|
@ -565,7 +615,6 @@ fun MediaItemMenu(
|
|||
onClick = {
|
||||
binder?.startSleepTimer(amount * 10 * 60 * 1000L)
|
||||
isShowingSleepTimerDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -576,15 +625,21 @@ fun MediaItemMenu(
|
|||
MenuEntry(
|
||||
icon = R.drawable.alarm,
|
||||
text = "Sleep timer",
|
||||
secondaryText = sleepTimerMillisLeft?.let {
|
||||
"${
|
||||
DateUtils.formatElapsedTime(
|
||||
it / 1000
|
||||
onClick = { isShowingSleepTimerDialog = true },
|
||||
trailingContent = sleepTimerMillisLeft?.let {
|
||||
{
|
||||
BasicText(
|
||||
text = "${formatAsDuration(it)} left",
|
||||
style = typography.xxs.medium,
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = colorPalette.background0,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.animateContentSize()
|
||||
)
|
||||
} left"
|
||||
},
|
||||
onClick = {
|
||||
isShowingSleepTimerDialog = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -598,7 +653,9 @@ fun MediaItemMenu(
|
|||
Image(
|
||||
painter = painterResource(R.drawable.chevron_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(colorPalette.textSecondary),
|
||||
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(
|
||||
colorPalette.textSecondary
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
)
|
||||
|
@ -607,7 +664,7 @@ fun MediaItemMenu(
|
|||
}
|
||||
|
||||
onGoToAlbum?.let { onGoToAlbum ->
|
||||
mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId ->
|
||||
albumInfo?.let { (albumId) ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.disc,
|
||||
text = "Go to album",
|
||||
|
@ -620,25 +677,16 @@ fun MediaItemMenu(
|
|||
}
|
||||
|
||||
onGoToArtist?.let { onGoToArtist ->
|
||||
mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")
|
||||
?.let { artistNames ->
|
||||
mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")
|
||||
?.let { artistIds ->
|
||||
artistNames.zip(artistIds)
|
||||
.forEach { (authorName, authorId) ->
|
||||
if (authorId != null) {
|
||||
MenuEntry(
|
||||
icon = R.drawable.person,
|
||||
text = "More of $authorName",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onGoToArtist(authorId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
artistsInfo?.forEach { (authorId, authorName) ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.person,
|
||||
text = "More from $authorName",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onGoToArtist(authorId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onRemoveFromQueue?.let { onRemoveFromQueue ->
|
||||
|
@ -670,6 +718,17 @@ fun MediaItemMenu(
|
|||
onClick = onHideFromDatabase
|
||||
)
|
||||
}
|
||||
|
||||
onRemoveFromQuickPicks?.let {
|
||||
MenuEntry(
|
||||
icon = R.drawable.trash,
|
||||
text = "Hide from \"Quick picks\"",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onRemoveFromQuickPicks()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package it.vfsfitvnm.vimusic.ui.components.themed
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
|
@ -65,7 +64,7 @@ fun MenuEntry(
|
|||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
.alpha(if (enabled) 1f else 0.4f)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
|
@ -77,6 +76,7 @@ fun MenuEntry(
|
|||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
BasicText(
|
||||
|
|
|
@ -59,8 +59,8 @@ inline fun NavigationRail(
|
|||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
|
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.primaryButton
|
||||
|
||||
@Composable
|
||||
fun PrimaryButton(
|
||||
|
@ -29,7 +30,7 @@ fun PrimaryButton(
|
|||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.background(colorPalette.background2)
|
||||
.background(colorPalette.primaryButton)
|
||||
.size(62.dp)
|
||||
) {
|
||||
Image(
|
||||
|
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.primaryButton
|
||||
|
||||
@Composable
|
||||
fun SecondaryButton(
|
||||
|
@ -29,7 +30,7 @@ fun SecondaryButton(
|
|||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.background(colorPalette.background2)
|
||||
.background(colorPalette.primaryButton)
|
||||
.size(48.dp)
|
||||
) {
|
||||
Image(
|
||||
|
|
|
@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.primaryButton
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
|
||||
@Composable
|
||||
|
@ -28,7 +29,7 @@ fun SecondaryTextButton(
|
|||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.background(if (alternative) colorPalette.background0 else colorPalette.background2)
|
||||
.background(if (alternative) colorPalette.background0 else colorPalette.primaryButton)
|
||||
.padding(all = 8.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
|
|
@ -20,7 +20,7 @@ import it.vfsfitvnm.vimusic.ui.styling.shimmer
|
|||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
|
||||
@Composable
|
||||
fun AlbumItem(
|
||||
|
|
|
@ -22,7 +22,7 @@ import it.vfsfitvnm.vimusic.ui.styling.shimmer
|
|||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
|
||||
@Composable
|
||||
fun ArtistItem(
|
||||
|
|
|
@ -39,7 +39,7 @@ import it.vfsfitvnm.vimusic.utils.medium
|
|||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
|
@ -19,7 +19,6 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.shimmer
|
||||
|
@ -27,7 +26,8 @@ import it.vfsfitvnm.vimusic.utils.medium
|
|||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
|
||||
@Composable
|
||||
fun SongItem(
|
||||
|
@ -69,7 +69,7 @@ fun SongItem(
|
|||
|
||||
@Composable
|
||||
fun SongItem(
|
||||
song: DetailedSong,
|
||||
song: Song,
|
||||
thumbnailSizePx: Int,
|
||||
thumbnailSizeDp: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
|
@ -25,7 +25,7 @@ import it.vfsfitvnm.vimusic.utils.color
|
|||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
|
||||
@Composable
|
||||
fun VideoItem(
|
||||
|
|
|
@ -4,9 +4,9 @@ import android.annotation.SuppressLint
|
|||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import it.vfsfitvnm.route.Route0
|
||||
import it.vfsfitvnm.route.Route1
|
||||
import it.vfsfitvnm.route.RouteHandlerScope
|
||||
import it.vfsfitvnm.compose.routing.Route0
|
||||
import it.vfsfitvnm.compose.routing.Route1
|
||||
import it.vfsfitvnm.compose.routing.RouteHandlerScope
|
||||
import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist
|
||||
import it.vfsfitvnm.vimusic.ui.screens.album.AlbumScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.artist.ArtistScreen
|
||||
|
|
|
@ -6,25 +6,28 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.compose.persist.PersistMapCleanup
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.innertube.requests.albumPage
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.Album
|
||||
import it.vfsfitvnm.vimusic.models.SongAlbumMap
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.savers.AlbumSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeAlbumItemListSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubePlaylistOrAlbumPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.innertubeItemsPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.nullableSaver
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder
|
||||
|
@ -38,12 +41,8 @@ import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage
|
|||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.albumPage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
|
@ -52,63 +51,60 @@ import kotlinx.coroutines.withContext
|
|||
fun AlbumScreen(browseId: String) {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
|
||||
val (tabIndex, onTabChanged) = rememberSaveable {
|
||||
var tabIndex by rememberSaveable {
|
||||
mutableStateOf(0)
|
||||
}
|
||||
|
||||
val album by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(AlbumSaver),
|
||||
) {
|
||||
var album by persist<Album?>("album/$browseId/album")
|
||||
var albumPage by persist<Innertube.PlaylistOrAlbumPage?>("album/$browseId/albumPage")
|
||||
|
||||
PersistMapCleanup(tagPrefix = "album/$browseId/")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Database
|
||||
.album(browseId)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
}
|
||||
.combine(snapshotFlow { tabIndex }) { album, tabIndex -> album to tabIndex }
|
||||
.collect { (currentAlbum, tabIndex) ->
|
||||
album = currentAlbum
|
||||
|
||||
val innertubeAlbum by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(InnertubePlaylistOrAlbumPageSaver),
|
||||
tabIndex > 0
|
||||
) {
|
||||
if (value != null || (tabIndex == 0 && withContext(Dispatchers.IO) {
|
||||
Database.albumTimestamp(
|
||||
browseId
|
||||
)
|
||||
} != null)) return@produceSaveableState
|
||||
if (albumPage == null && (currentAlbum?.timestamp == null || tabIndex == 1)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Innertube.albumPage(BrowseBody(browseId = browseId))
|
||||
?.onSuccess { currentAlbumPage ->
|
||||
albumPage = currentAlbumPage
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
Innertube.albumPage(BrowseBody(browseId = browseId))
|
||||
}?.onSuccess { albumPage ->
|
||||
value = albumPage
|
||||
Database.clearAlbum(browseId)
|
||||
|
||||
query {
|
||||
Database.upsert(
|
||||
Album(
|
||||
id = browseId,
|
||||
title = albumPage.title,
|
||||
thumbnailUrl = albumPage.thumbnail?.url,
|
||||
year = albumPage.year,
|
||||
authorsText = albumPage.authors?.joinToString("") { it.name ?: "" },
|
||||
shareUrl = albumPage.url,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
bookmarkedAt = album?.bookmarkedAt
|
||||
),
|
||||
albumPage
|
||||
.songsPage
|
||||
?.items
|
||||
?.map(Innertube.SongItem::asMediaItem)
|
||||
?.onEach(Database::insert)
|
||||
?.mapIndexed { position, mediaItem ->
|
||||
SongAlbumMap(
|
||||
songId = mediaItem.mediaId,
|
||||
albumId = browseId,
|
||||
position = position
|
||||
)
|
||||
} ?: emptyList()
|
||||
)
|
||||
Database.upsert(
|
||||
Album(
|
||||
id = browseId,
|
||||
title = currentAlbumPage.title,
|
||||
thumbnailUrl = currentAlbumPage.thumbnail?.url,
|
||||
year = currentAlbumPage.year,
|
||||
authorsText = currentAlbumPage.authors
|
||||
?.joinToString("") { it.name ?: "" },
|
||||
shareUrl = currentAlbumPage.url,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
bookmarkedAt = album?.bookmarkedAt
|
||||
),
|
||||
currentAlbumPage
|
||||
.songsPage
|
||||
?.items
|
||||
?.map(Innertube.SongItem::asMediaItem)
|
||||
?.onEach(Database::insert)
|
||||
?.mapIndexed { position, mediaItem ->
|
||||
SongAlbumMap(
|
||||
songId = mediaItem.mediaId,
|
||||
albumId = browseId,
|
||||
position = position
|
||||
)
|
||||
} ?: emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
|
@ -184,7 +180,7 @@ fun AlbumScreen(browseId: String) {
|
|||
topIconButtonId = R.drawable.chevron_back,
|
||||
onTopIconButtonClick = pop,
|
||||
tabIndex = tabIndex,
|
||||
onTabChanged = onTabChanged,
|
||||
onTabChanged = { tabIndex = it },
|
||||
tabColumnContent = { Item ->
|
||||
Item(0, "Songs", R.drawable.musical_notes)
|
||||
Item(1, "Other versions", R.drawable.disc)
|
||||
|
@ -203,16 +199,16 @@ fun AlbumScreen(browseId: String) {
|
|||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = innertubeItemsPageSaver(InnertubeAlbumItemListSaver),
|
||||
tag = "album/$browseId/alternatives",
|
||||
headerContent = headerContent,
|
||||
initialPlaceholderCount = 1,
|
||||
continuationPlaceholderCount = 1,
|
||||
emptyItemsText = "This album doesn't have any alternative version",
|
||||
itemsPageProvider = innertubeAlbum?.let {
|
||||
itemsPageProvider = albumPage?.let {
|
||||
({
|
||||
Result.success(
|
||||
Innertube.ItemsPage(
|
||||
items = innertubeAlbum?.otherVersions,
|
||||
items = albumPage?.otherVersions,
|
||||
continuation = null
|
||||
)
|
||||
)
|
||||
|
|
|
@ -6,26 +6,28 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import it.vfsfitvnm.compose.persist.persistList
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
|
@ -43,10 +45,7 @@ import it.vfsfitvnm.vimusic.utils.enqueue
|
|||
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
|
||||
import it.vfsfitvnm.vimusic.utils.isLandscape
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalFoundationApi
|
||||
|
@ -60,14 +59,10 @@ fun AlbumSongs(
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val songs by produceSaveableState(
|
||||
initialValue = emptyList(),
|
||||
stateSaver = DetailedSongListSaver
|
||||
) {
|
||||
Database
|
||||
.albumSongs(browseId)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
var songs by persistList<Song>("album/$browseId/songs")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Database.albumSongs(browseId).collect { songs = it }
|
||||
}
|
||||
|
||||
val thumbnailSizeDp = Dimensions.thumbnails.song
|
||||
|
@ -94,7 +89,7 @@ fun AlbumSongs(
|
|||
text = "Enqueue",
|
||||
enabled = songs.isNotEmpty(),
|
||||
onClick = {
|
||||
binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem))
|
||||
binder?.player?.enqueue(songs.map(Song::asMediaItem))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -138,7 +133,7 @@ fun AlbumSongs(
|
|||
onClick = {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(
|
||||
songs.map(DetailedSong::asMediaItem),
|
||||
songs.map(Song::asMediaItem),
|
||||
index
|
||||
)
|
||||
}
|
||||
|
@ -167,7 +162,7 @@ fun AlbumSongs(
|
|||
if (songs.isNotEmpty()) {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.shuffled().map(DetailedSong::asMediaItem)
|
||||
songs.shuffled().map(Song::asMediaItem)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,23 +6,25 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
|
||||
import it.vfsfitvnm.vimusic.savers.nullableSaver
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
|
@ -38,9 +40,6 @@ import it.vfsfitvnm.vimusic.utils.asMediaItem
|
|||
import it.vfsfitvnm.vimusic.utils.enqueue
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -54,14 +53,10 @@ fun ArtistLocalSongs(
|
|||
val (colorPalette) = LocalAppearance.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val songs by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(DetailedSongListSaver)
|
||||
) {
|
||||
Database
|
||||
.artistSongs(browseId)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
var songs by persist<List<Song>?>("artist/$browseId/localSongs")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Database.artistSongs(browseId).collect { songs = it }
|
||||
}
|
||||
|
||||
val songThumbnailSizeDp = Dimensions.thumbnails.song
|
||||
|
@ -83,13 +78,13 @@ fun ArtistLocalSongs(
|
|||
key = "header",
|
||||
contentType = 0
|
||||
) {
|
||||
Column {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
headerContent {
|
||||
SecondaryTextButton(
|
||||
text = "Enqueue",
|
||||
enabled = !songs.isNullOrEmpty(),
|
||||
onClick = {
|
||||
binder?.player?.enqueue(songs!!.map(DetailedSong::asMediaItem))
|
||||
binder?.player?.enqueue(songs!!.map(Song::asMediaItem))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -120,7 +115,7 @@ fun ArtistLocalSongs(
|
|||
onClick = {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(
|
||||
songs.map(DetailedSong::asMediaItem),
|
||||
songs.map(Song::asMediaItem),
|
||||
index
|
||||
)
|
||||
}
|
||||
|
@ -144,7 +139,7 @@ fun ArtistLocalSongs(
|
|||
if (songs.isNotEmpty()) {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.shuffled().map(DetailedSong::asMediaItem)
|
||||
songs.shuffled().map(Song::asMediaItem)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.WindowInsetsSides
|
|||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
|
@ -23,7 +24,10 @@ import androidx.compose.foundation.verticalScroll
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
|
@ -41,12 +45,12 @@ import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder
|
|||
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.align
|
||||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -86,7 +90,11 @@ fun ArtistOverview(
|
|||
.background(colorPalette.background0)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(windowInsets.only(WindowInsetsSides.Vertical).asPaddingValues())
|
||||
.padding(
|
||||
windowInsets
|
||||
.only(WindowInsetsSides.Vertical)
|
||||
.asPaddingValues()
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
@ -250,6 +258,56 @@ fun ArtistOverview(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
youtubeArtistPage.description?.let { description ->
|
||||
val attributionsIndex = description.lastIndexOf("\n\nFrom Wikipedia")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.padding(vertical = 16.dp, horizontal = 8.dp)
|
||||
.padding(endPaddingValues)
|
||||
) {
|
||||
BasicText(
|
||||
text = "“",
|
||||
style = typography.xxl.semiBold,
|
||||
modifier = Modifier
|
||||
.offset(y = (-8).dp)
|
||||
.align(Alignment.Top)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = if (attributionsIndex == -1) {
|
||||
description
|
||||
} else {
|
||||
description.substring(0, attributionsIndex)
|
||||
},
|
||||
style = typography.xxs.secondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = "„",
|
||||
style = typography.xxl.semiBold,
|
||||
modifier = Modifier
|
||||
.offset(y = 4.dp)
|
||||
.align(Alignment.Bottom)
|
||||
)
|
||||
}
|
||||
|
||||
if (attributionsIndex != -1) {
|
||||
BasicText(
|
||||
text = "From Wikipedia under Creative Commons Attribution CC-BY-SA 3.0",
|
||||
style = typography.xxs.color(colorPalette.textDisabled).align(TextAlign.End),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
.padding(endPaddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ShimmerHost {
|
||||
TextPlaceholder(modifier = sectionTextModifier)
|
||||
|
|
|
@ -8,23 +8,29 @@ import androidx.compose.foundation.combinedClickable
|
|||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.compose.persist.PersistMapCleanup
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.innertube.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.innertube.requests.artistPage
|
||||
import it.vfsfitvnm.innertube.requests.itemsPage
|
||||
import it.vfsfitvnm.innertube.utils.from
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.Artist
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.savers.ArtistSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeAlbumsPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeArtistPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeSongsPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.nullableSaver
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
|
||||
|
@ -45,16 +51,11 @@ import it.vfsfitvnm.vimusic.ui.styling.px
|
|||
import it.vfsfitvnm.vimusic.utils.artistScreenTabIndexKey
|
||||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.artistPage
|
||||
import it.vfsfitvnm.youtubemusic.requests.itemsPage
|
||||
import it.vfsfitvnm.youtubemusic.utils.from
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
|
@ -63,50 +64,41 @@ import kotlinx.coroutines.withContext
|
|||
fun ArtistScreen(browseId: String) {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
|
||||
val (tabIndex, onTabIndexChanged) = rememberPreference(
|
||||
artistScreenTabIndexKey,
|
||||
defaultValue = 0
|
||||
)
|
||||
var tabIndex by rememberPreference(artistScreenTabIndexKey, defaultValue = 0)
|
||||
|
||||
val artist by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(ArtistSaver),
|
||||
) {
|
||||
PersistMapCleanup(tagPrefix = "artist/$browseId/")
|
||||
|
||||
var artist by persist<Artist?>("artist/$browseId/artist")
|
||||
|
||||
var artistPage by persist<Innertube.ArtistPage?>("artist/$browseId/artistPage")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Database
|
||||
.artist(browseId)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
}
|
||||
.combine(snapshotFlow { tabIndex }.map { it != 4 }) { artist, mustFetch -> artist to mustFetch }
|
||||
.distinctUntilChanged()
|
||||
.collect { (currentArtist, mustFetch) ->
|
||||
artist = currentArtist
|
||||
|
||||
val youtubeArtist by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(InnertubeArtistPageSaver),
|
||||
tabIndex < 4
|
||||
) {
|
||||
if (value != null || (tabIndex == 4 && withContext(Dispatchers.IO) {
|
||||
Database.artistTimestamp(
|
||||
browseId
|
||||
)
|
||||
} != null)) return@produceSaveableState
|
||||
if (artistPage == null && (currentArtist?.timestamp == null || mustFetch)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Innertube.artistPage(BrowseBody(browseId = browseId))
|
||||
?.onSuccess { currentArtistPage ->
|
||||
artistPage = currentArtistPage
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
Innertube.artistPage(BrowseBody(browseId = browseId))
|
||||
}?.onSuccess { artistPage ->
|
||||
value = artistPage
|
||||
|
||||
query {
|
||||
Database.upsert(
|
||||
Artist(
|
||||
id = browseId,
|
||||
name = artistPage.name,
|
||||
thumbnailUrl = artistPage.thumbnail?.url,
|
||||
info = artistPage.description,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
bookmarkedAt = artist?.bookmarkedAt
|
||||
)
|
||||
)
|
||||
Database.upsert(
|
||||
Artist(
|
||||
id = browseId,
|
||||
name = currentArtistPage.name,
|
||||
thumbnailUrl = currentArtistPage.thumbnail?.url,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
bookmarkedAt = currentArtist?.bookmarkedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
|
@ -182,7 +174,7 @@ fun ArtistScreen(browseId: String) {
|
|||
topIconButtonId = R.drawable.chevron_back,
|
||||
onTopIconButtonClick = pop,
|
||||
tabIndex = tabIndex,
|
||||
onTabChanged = onTabIndexChanged,
|
||||
onTabChanged = { tabIndex = it },
|
||||
tabColumnContent = { Item ->
|
||||
Item(0, "Overview", R.drawable.sparkles)
|
||||
Item(1, "Songs", R.drawable.musical_notes)
|
||||
|
@ -194,13 +186,13 @@ fun ArtistScreen(browseId: String) {
|
|||
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
|
||||
when (currentTabIndex) {
|
||||
0 -> ArtistOverview(
|
||||
youtubeArtistPage = youtubeArtist,
|
||||
youtubeArtistPage = artistPage,
|
||||
thumbnailContent = thumbnailContent,
|
||||
headerContent = headerContent,
|
||||
onAlbumClick = { albumRoute(it) },
|
||||
onViewAllSongsClick = { onTabIndexChanged(1) },
|
||||
onViewAllAlbumsClick = { onTabIndexChanged(2) },
|
||||
onViewAllSinglesClick = { onTabIndexChanged(3) },
|
||||
onViewAllSongsClick = { tabIndex = 1 },
|
||||
onViewAllAlbumsClick = { tabIndex = 2 },
|
||||
onViewAllSinglesClick = { tabIndex = 3 },
|
||||
)
|
||||
|
||||
1 -> {
|
||||
|
@ -210,16 +202,16 @@ fun ArtistScreen(browseId: String) {
|
|||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = InnertubeSongsPageSaver,
|
||||
tag = "artist/$browseId/songs",
|
||||
headerContent = headerContent,
|
||||
itemsPageProvider = youtubeArtist?.let {
|
||||
itemsPageProvider = artistPage?.let {
|
||||
({ continuation ->
|
||||
continuation?.let {
|
||||
Innertube.itemsPage(
|
||||
body = ContinuationBody(continuation = continuation),
|
||||
fromMusicResponsiveListItemRenderer = Innertube.SongItem::from,
|
||||
)
|
||||
} ?: youtubeArtist
|
||||
} ?: artistPage
|
||||
?.songsEndpoint
|
||||
?.takeIf { it.browseId != null }
|
||||
?.let { endpoint ->
|
||||
|
@ -233,7 +225,7 @@ fun ArtistScreen(browseId: String) {
|
|||
}
|
||||
?: Result.success(
|
||||
Innertube.ItemsPage(
|
||||
items = youtubeArtist?.songs,
|
||||
items = artistPage?.songs,
|
||||
continuation = null
|
||||
)
|
||||
)
|
||||
|
@ -273,17 +265,17 @@ fun ArtistScreen(browseId: String) {
|
|||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = InnertubeAlbumsPageSaver,
|
||||
tag = "artist/$browseId/albums",
|
||||
headerContent = headerContent,
|
||||
emptyItemsText = "This artist didn't release any album",
|
||||
itemsPageProvider = youtubeArtist?.let {
|
||||
itemsPageProvider = artistPage?.let {
|
||||
({ continuation ->
|
||||
continuation?.let {
|
||||
Innertube.itemsPage(
|
||||
body = ContinuationBody(continuation = continuation),
|
||||
fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from,
|
||||
)
|
||||
} ?: youtubeArtist
|
||||
} ?: artistPage
|
||||
?.albumsEndpoint
|
||||
?.takeIf { it.browseId != null }
|
||||
?.let { endpoint ->
|
||||
|
@ -297,7 +289,7 @@ fun ArtistScreen(browseId: String) {
|
|||
}
|
||||
?: Result.success(
|
||||
Innertube.ItemsPage(
|
||||
items = youtubeArtist?.albums,
|
||||
items = artistPage?.albums,
|
||||
continuation = null
|
||||
)
|
||||
)
|
||||
|
@ -323,17 +315,17 @@ fun ArtistScreen(browseId: String) {
|
|||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = InnertubeAlbumsPageSaver,
|
||||
tag = "artist/$browseId/singles",
|
||||
headerContent = headerContent,
|
||||
emptyItemsText = "This artist didn't release any single",
|
||||
itemsPageProvider = youtubeArtist?.let {
|
||||
itemsPageProvider = artistPage?.let {
|
||||
({ continuation ->
|
||||
continuation?.let {
|
||||
Innertube.itemsPage(
|
||||
body = ContinuationBody(continuation = continuation),
|
||||
fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from,
|
||||
)
|
||||
} ?: youtubeArtist
|
||||
} ?: artistPage
|
||||
?.singlesEndpoint
|
||||
?.takeIf { it.browseId != null }
|
||||
?.let { endpoint ->
|
||||
|
@ -347,7 +339,7 @@ fun ArtistScreen(browseId: String) {
|
|||
}
|
||||
?: Result.success(
|
||||
Innertube.ItemsPage(
|
||||
items = youtubeArtist?.singles,
|
||||
items = artistPage?.singles,
|
||||
continuation = null
|
||||
)
|
||||
)
|
||||
|
|
|
@ -6,7 +6,8 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.compose.persist.PersistMapCleanup
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
|
||||
|
@ -25,6 +26,8 @@ fun BuiltInPlaylistScreen(builtInPlaylist: BuiltInPlaylist) {
|
|||
})
|
||||
}
|
||||
|
||||
PersistMapCleanup(tagPrefix = "${builtInPlaylist.name}/")
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
globalRoutes()
|
||||
|
||||
|
|
|
@ -15,16 +15,19 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.compose.persist.persistList
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.models.SongWithContentLength
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
|
@ -39,7 +42,6 @@ import it.vfsfitvnm.vimusic.utils.asMediaItem
|
|||
import it.vfsfitvnm.vimusic.utils.enqueue
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
@ -52,25 +54,24 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val songs by produceSaveableState(
|
||||
initialValue = emptyList(),
|
||||
stateSaver = DetailedSongListSaver
|
||||
) {
|
||||
var songs by persistList<Song>("${builtInPlaylist.name}/songs")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
when (builtInPlaylist) {
|
||||
BuiltInPlaylist.Favorites -> Database
|
||||
.favorites()
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
BuiltInPlaylist.Offline -> Database
|
||||
.songsWithContentLength()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.map { songs ->
|
||||
songs.filter { song ->
|
||||
song.contentLength?.let {
|
||||
binder?.cache?.isCached(song.id, 0, song.contentLength)
|
||||
} ?: false
|
||||
songs.filter { song ->
|
||||
song.contentLength?.let {
|
||||
binder?.cache?.isCached(song.song.id, 0, song.contentLength)
|
||||
} ?: false
|
||||
}.map(SongWithContentLength::song)
|
||||
}
|
||||
}
|
||||
}.collect { value = it }
|
||||
}.collect { songs = it }
|
||||
}
|
||||
|
||||
val thumbnailSizeDp = Dimensions.thumbnails.song
|
||||
|
@ -103,7 +104,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
|
|||
text = "Enqueue",
|
||||
enabled = songs.isNotEmpty(),
|
||||
onClick = {
|
||||
binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem))
|
||||
binder?.player?.enqueue(songs.map(Song::asMediaItem))
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -143,7 +144,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
|
|||
onClick = {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(
|
||||
songs.map(DetailedSong::asMediaItem),
|
||||
songs.map(Song::asMediaItem),
|
||||
index
|
||||
)
|
||||
}
|
||||
|
@ -160,7 +161,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
|
|||
if (songs.isNotEmpty()) {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.shuffled().map(DetailedSong::asMediaItem)
|
||||
songs.shuffled().map(Song::asMediaItem)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,21 +15,24 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.AlbumSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.SortOrder
|
||||
import it.vfsfitvnm.vimusic.models.Album
|
||||
import it.vfsfitvnm.vimusic.savers.AlbumListSaver
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
|
||||
|
@ -39,10 +42,7 @@ import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
|||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.albumSortByKey
|
||||
import it.vfsfitvnm.vimusic.utils.albumSortOrderKey
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -56,15 +56,10 @@ fun HomeAlbums(
|
|||
var sortBy by rememberPreference(albumSortByKey, AlbumSortBy.DateAdded)
|
||||
var sortOrder by rememberPreference(albumSortOrderKey, SortOrder.Descending)
|
||||
|
||||
val items by produceSaveableState(
|
||||
initialValue = emptyList(),
|
||||
stateSaver = AlbumListSaver,
|
||||
sortBy, sortOrder,
|
||||
) {
|
||||
Database
|
||||
.albums(sortBy, sortOrder)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
var items by persist<List<Album>>(tag = "home/albums", emptyList())
|
||||
|
||||
LaunchedEffect(sortBy, sortOrder) {
|
||||
Database.albums(sortBy, sortOrder).collect { items = it }
|
||||
}
|
||||
|
||||
val thumbnailSizeDp = Dimensions.thumbnails.song * 2
|
||||
|
|
|
@ -10,7 +10,10 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
|
@ -18,22 +21,20 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.compose.persist.persistList
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.ArtistSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.SortOrder
|
||||
import it.vfsfitvnm.vimusic.models.Artist
|
||||
import it.vfsfitvnm.vimusic.savers.ArtistListSaver
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
|
||||
|
@ -43,10 +44,7 @@ import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
|||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.artistSortByKey
|
||||
import it.vfsfitvnm.vimusic.utils.artistSortOrderKey
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -60,15 +58,10 @@ fun HomeArtistList(
|
|||
var sortBy by rememberPreference(artistSortByKey, ArtistSortBy.DateAdded)
|
||||
var sortOrder by rememberPreference(artistSortOrderKey, SortOrder.Descending)
|
||||
|
||||
val items by produceSaveableState(
|
||||
initialValue = emptyList(),
|
||||
stateSaver = ArtistListSaver,
|
||||
sortBy, sortOrder,
|
||||
) {
|
||||
Database
|
||||
.artists(sortBy, sortOrder)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
var items by persistList<Artist>("home/artists")
|
||||
|
||||
LaunchedEffect(sortBy, sortOrder) {
|
||||
Database.artists(sortBy, sortOrder).collect { items = it }
|
||||
}
|
||||
|
||||
val thumbnailSizeDp = Dimensions.thumbnails.song * 2
|
||||
|
|
|
@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
|
@ -29,6 +30,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.compose.persist.persistList
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
|
@ -36,8 +38,8 @@ import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist
|
|||
import it.vfsfitvnm.vimusic.enums.PlaylistSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.SortOrder
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistPreview
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.savers.PlaylistPreviewListSaver
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
|
||||
|
@ -49,10 +51,7 @@ import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
|||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.playlistSortByKey
|
||||
import it.vfsfitvnm.vimusic.utils.playlistSortOrderKey
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalFoundationApi
|
||||
|
@ -85,15 +84,10 @@ fun HomePlaylists(
|
|||
var sortBy by rememberPreference(playlistSortByKey, PlaylistSortBy.DateAdded)
|
||||
var sortOrder by rememberPreference(playlistSortOrderKey, SortOrder.Descending)
|
||||
|
||||
val items by produceSaveableState(
|
||||
initialValue = emptyList(),
|
||||
stateSaver = PlaylistPreviewListSaver,
|
||||
sortBy, sortOrder,
|
||||
) {
|
||||
Database
|
||||
.playlistPreviews(sortBy, sortOrder)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
var items by persistList<PlaylistPreview>("home/playlists")
|
||||
|
||||
LaunchedEffect(sortBy, sortOrder) {
|
||||
Database.playlistPreviews(sortBy, sortOrder).collect { items = it }
|
||||
}
|
||||
|
||||
val sortOrderIconRotation by animateFloatAsState(
|
||||
|
|
|
@ -4,13 +4,15 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.route.defaultStacking
|
||||
import it.vfsfitvnm.route.defaultStill
|
||||
import it.vfsfitvnm.route.defaultUnstacking
|
||||
import it.vfsfitvnm.route.isStacking
|
||||
import it.vfsfitvnm.route.isUnknown
|
||||
import it.vfsfitvnm.route.isUnstacking
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import it.vfsfitvnm.compose.persist.PersistMapCleanup
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.compose.routing.defaultStacking
|
||||
import it.vfsfitvnm.compose.routing.defaultStill
|
||||
import it.vfsfitvnm.compose.routing.defaultUnstacking
|
||||
import it.vfsfitvnm.compose.routing.isStacking
|
||||
import it.vfsfitvnm.compose.routing.isUnknown
|
||||
import it.vfsfitvnm.compose.routing.isUnstacking
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.SearchQuery
|
||||
|
@ -31,6 +33,8 @@ import it.vfsfitvnm.vimusic.ui.screens.searchresult.SearchResultScreen
|
|||
import it.vfsfitvnm.vimusic.ui.screens.settings.SettingsScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.settingsRoute
|
||||
import it.vfsfitvnm.vimusic.utils.homeScreenTabIndexKey
|
||||
import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey
|
||||
import it.vfsfitvnm.vimusic.utils.preferences
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
|
@ -39,6 +43,8 @@ import it.vfsfitvnm.vimusic.utils.rememberPreference
|
|||
fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
|
||||
PersistMapCleanup("home/")
|
||||
|
||||
RouteHandler(
|
||||
listenToGlobalEmitter = true,
|
||||
transitionSpec = {
|
||||
|
@ -50,6 +56,7 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
|
|||
initialState.route == searchResultRoute && targetState.route == searchRoute -> defaultUnstacking
|
||||
else -> defaultStill
|
||||
}
|
||||
|
||||
else -> defaultStill
|
||||
}
|
||||
}
|
||||
|
@ -82,14 +89,18 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
|
|||
}
|
||||
|
||||
searchRoute { initialTextInput ->
|
||||
val context = LocalContext.current
|
||||
|
||||
SearchScreen(
|
||||
initialTextInput = initialTextInput,
|
||||
onSearch = { query ->
|
||||
pop()
|
||||
searchResultRoute(query)
|
||||
|
||||
query {
|
||||
Database.insert(SearchQuery(query = query))
|
||||
if (!context.preferences.getBoolean(pauseSearchHistoryKey, false)) {
|
||||
query {
|
||||
Database.insert(SearchQuery(query = query))
|
||||
}
|
||||
}
|
||||
},
|
||||
onViewPlaylist = onPlaylistUrl
|
||||
|
@ -113,7 +124,7 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
|
|||
Item(2, "Playlists", R.drawable.playlist)
|
||||
Item(3, "Artists", R.drawable.person)
|
||||
Item(4, "Albums", R.drawable.disc)
|
||||
},
|
||||
}
|
||||
) { currentTabIndex ->
|
||||
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
|
||||
when (currentTabIndex) {
|
||||
|
@ -123,18 +134,22 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
|
|||
onPlaylistClick = { playlistRoute(it) },
|
||||
onSearchClick = { searchRoute("") }
|
||||
)
|
||||
|
||||
1 -> HomeSongs(
|
||||
onSearchClick = { searchRoute("") }
|
||||
)
|
||||
|
||||
2 -> HomePlaylists(
|
||||
onBuiltInPlaylist = { builtInPlaylistRoute(it) },
|
||||
onPlaylistClick = { localPlaylistRoute(it.id) },
|
||||
onSearchClick = { searchRoute("") }
|
||||
)
|
||||
|
||||
3 -> HomeArtistList(
|
||||
onArtistClick = { artistRoute(it.id) },
|
||||
onSearchClick = { searchRoute("") }
|
||||
)
|
||||
|
||||
4 -> HomeAlbums(
|
||||
onAlbumClick = { albumRoute(it.id) },
|
||||
onSearchClick = { searchRoute("") }
|
||||
|
|
|
@ -9,8 +9,11 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
@ -18,6 +21,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
@ -27,19 +31,14 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.compose.persist.persistList
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.SongSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.SortOrder
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
|
@ -55,13 +54,10 @@ import it.vfsfitvnm.vimusic.utils.asMediaItem
|
|||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.vimusic.utils.songSortByKey
|
||||
import it.vfsfitvnm.vimusic.utils.songSortOrderKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -79,15 +75,10 @@ fun HomeSongs(
|
|||
var sortBy by rememberPreference(songSortByKey, SongSortBy.DateAdded)
|
||||
var sortOrder by rememberPreference(songSortOrderKey, SortOrder.Descending)
|
||||
|
||||
val items by produceSaveableState(
|
||||
initialValue = emptyList(),
|
||||
stateSaver = DetailedSongListSaver,
|
||||
sortBy, sortOrder,
|
||||
) {
|
||||
Database
|
||||
.songs(sortBy, sortOrder)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
var items by persistList<Song>("home/songs")
|
||||
|
||||
LaunchedEffect(sortBy, sortOrder) {
|
||||
Database.songs(sortBy, sortOrder).collect { items = it }
|
||||
}
|
||||
|
||||
val sortOrderIconRotation by animateFloatAsState(
|
||||
|
@ -184,7 +175,7 @@ fun HomeSongs(
|
|||
onClick = {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(
|
||||
items.map(DetailedSong::asMediaItem),
|
||||
items.map(Song::asMediaItem),
|
||||
index
|
||||
)
|
||||
}
|
||||
|
|
|
@ -29,21 +29,26 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.innertube.models.bodies.NextBody
|
||||
import it.vfsfitvnm.innertube.requests.relatedPage
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.savers.DetailedSongSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeRelatedPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.nullableSaver
|
||||
import it.vfsfitvnm.vimusic.savers.resultSaver
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
|
@ -65,18 +70,10 @@ import it.vfsfitvnm.vimusic.utils.SnapLayoutInfoProvider
|
|||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableOneShotState
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.isLandscape
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.NextBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.relatedPage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -92,23 +89,18 @@ fun QuickPicks(
|
|||
val menuState = LocalMenuState.current
|
||||
val windowInsets = LocalPlayerAwareWindowInsets.current
|
||||
|
||||
val trending by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(DetailedSongSaver),
|
||||
) {
|
||||
Database.trending()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect { value = it }
|
||||
}
|
||||
var trending by persist<Song?>("home/trending")
|
||||
|
||||
val relatedPageResult by produceSaveableOneShotState(
|
||||
initialValue = null,
|
||||
stateSaver = resultSaver(nullableSaver(InnertubeRelatedPageSaver)),
|
||||
trending?.id
|
||||
) {
|
||||
value = Innertube.relatedPage(NextBody(videoId = (trending?.id ?: "J7p4bzqLvCw")))
|
||||
var relatedPageResult by persist<Result<Innertube.RelatedPage?>?>(tag = "home/relatedPageResult")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Database.trending().distinctUntilChanged().collect { song ->
|
||||
if ((song == null && relatedPageResult == null) || trending?.id != song?.id) {
|
||||
relatedPageResult =
|
||||
Innertube.relatedPage(NextBody(videoId = (song?.id ?: "J7p4bzqLvCw")))
|
||||
}
|
||||
trending = song
|
||||
}
|
||||
}
|
||||
|
||||
val songThumbnailSizeDp = Dimensions.thumbnails.song
|
||||
|
@ -120,18 +112,8 @@ fun QuickPicks(
|
|||
val playlistThumbnailSizeDp = 108.dp
|
||||
val playlistThumbnailSizePx = playlistThumbnailSizeDp.px
|
||||
|
||||
val quickPicksLazyGridItemWidthFactor = 0.9f
|
||||
val quickPicksLazyGridState = rememberLazyGridState()
|
||||
val snapLayoutInfoProvider = remember(quickPicksLazyGridState) {
|
||||
SnapLayoutInfoProvider(
|
||||
lazyGridState = quickPicksLazyGridState,
|
||||
positionInLayout = {layoutSize, itemSize ->
|
||||
(layoutSize * quickPicksLazyGridItemWidthFactor / 2f - itemSize / 2f)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val quickPicksLazyGridState = rememberLazyGridState()
|
||||
|
||||
val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues()
|
||||
|
||||
|
@ -141,6 +123,21 @@ fun QuickPicks(
|
|||
.padding(endPaddingValues)
|
||||
|
||||
BoxWithConstraints {
|
||||
val quickPicksLazyGridItemWidthFactor = if (isLandscape && maxWidth * 0.475f >= 320.dp) {
|
||||
0.475f
|
||||
} else {
|
||||
0.9f
|
||||
}
|
||||
|
||||
val snapLayoutInfoProvider = remember(quickPicksLazyGridState) {
|
||||
SnapLayoutInfoProvider(
|
||||
lazyGridState = quickPicksLazyGridState,
|
||||
positionInLayout = { layoutSize, itemSize ->
|
||||
(layoutSize * quickPicksLazyGridItemWidthFactor / 2f - itemSize / 2f)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val itemInHorizontalGridWidth = maxWidth * quickPicksLazyGridItemWidthFactor
|
||||
|
||||
Column(
|
||||
|
@ -148,7 +145,11 @@ fun QuickPicks(
|
|||
.background(colorPalette.background0)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(windowInsets.only(WindowInsetsSides.Vertical).asPaddingValues())
|
||||
.padding(
|
||||
windowInsets
|
||||
.only(WindowInsetsSides.Vertical)
|
||||
.asPaddingValues()
|
||||
)
|
||||
) {
|
||||
Header(
|
||||
title = "Quick picks",
|
||||
|
@ -173,13 +174,13 @@ fun QuickPicks(
|
|||
thumbnailSizePx = songThumbnailSizePx,
|
||||
thumbnailSizeDp = songThumbnailSizeDp,
|
||||
trailingContent = {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.star),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.accent),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(R.drawable.star),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.accent),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
|
@ -188,6 +189,11 @@ fun QuickPicks(
|
|||
NonQueuedMediaItemMenu(
|
||||
onDismiss = menuState::hide,
|
||||
mediaItem = song.asMediaItem,
|
||||
onRemoveFromQuickPicks = {
|
||||
query {
|
||||
Database.clearEventsFor(song.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
@ -207,7 +213,8 @@ fun QuickPicks(
|
|||
}
|
||||
|
||||
items(
|
||||
items = related.songs?.dropLast(if (trending == null) 0 else 1) ?: emptyList(),
|
||||
items = related.songs?.dropLast(if (trending == null) 0 else 1)
|
||||
?: emptyList(),
|
||||
key = Innertube.SongItem::key
|
||||
) { song ->
|
||||
SongItem(
|
||||
|
@ -220,7 +227,7 @@ fun QuickPicks(
|
|||
menuState.display {
|
||||
NonQueuedMediaItemMenu(
|
||||
onDismiss = menuState::hide,
|
||||
mediaItem = song.asMediaItem,
|
||||
mediaItem = song.asMediaItem
|
||||
)
|
||||
}
|
||||
},
|
||||
|
@ -312,6 +319,8 @@ fun QuickPicks(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Unit
|
||||
} ?: relatedPageResult?.exceptionOrNull()?.let {
|
||||
BasicText(
|
||||
text = "An error has occurred",
|
||||
|
|
|
@ -4,7 +4,8 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.compose.persist.PersistMapCleanup
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
|
||||
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
|
||||
|
@ -15,6 +16,8 @@ import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
|
|||
fun LocalPlaylistScreen(playlistId: Long) {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
|
||||
PersistMapCleanup(tagPrefix = "localPlaylist/$playlistId/")
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
globalRoutes()
|
||||
|
||||
|
@ -28,11 +31,13 @@ fun LocalPlaylistScreen(playlistId: Long) {
|
|||
Item(0, "Songs", R.drawable.musical_notes)
|
||||
}
|
||||
) { currentTabIndex ->
|
||||
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
|
||||
LocalPlaylistSongs(
|
||||
playlistId = playlistId,
|
||||
onDelete = pop
|
||||
)
|
||||
saveableStateHolder.SaveableStateProvider(currentTabIndex) {
|
||||
when (currentTabIndex) {
|
||||
0 -> LocalPlaylistSongs(
|
||||
playlistId = playlistId,
|
||||
onDelete = pop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,36 +6,40 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.reordering.ReorderingLazyColumn
|
||||
import it.vfsfitvnm.reordering.animateItemPlacement
|
||||
import it.vfsfitvnm.reordering.draggedItem
|
||||
import it.vfsfitvnm.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.reordering.reorder
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.innertube.requests.playlistPage
|
||||
import it.vfsfitvnm.compose.reordering.ReorderingLazyColumn
|
||||
import it.vfsfitvnm.compose.reordering.animateItemPlacement
|
||||
import it.vfsfitvnm.compose.reordering.draggedItem
|
||||
import it.vfsfitvnm.compose.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.compose.reordering.reorder
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.savers.PlaylistWithSongsSaver
|
||||
import it.vfsfitvnm.vimusic.savers.nullableSaver
|
||||
import it.vfsfitvnm.vimusic.transaction
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog
|
||||
|
@ -57,12 +61,8 @@ import it.vfsfitvnm.vimusic.utils.completed
|
|||
import it.vfsfitvnm.vimusic.utils.enqueue
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.playlistPage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
@ -77,14 +77,10 @@ fun LocalPlaylistSongs(
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val playlistWithSongs by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(PlaylistWithSongsSaver)
|
||||
) {
|
||||
Database
|
||||
.playlistWithSongs(playlistId)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
var playlistWithSongs by persist<PlaylistWithSongs?>("localPlaylist/$playlistId/playlistWithSongs")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Database.playlistWithSongs(playlistId).filterNotNull().collect { playlistWithSongs = it }
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
@ -162,7 +158,7 @@ fun LocalPlaylistSongs(
|
|||
enabled = playlistWithSongs?.songs?.isNotEmpty() == true,
|
||||
onClick = {
|
||||
playlistWithSongs?.songs
|
||||
?.map(DetailedSong::asMediaItem)
|
||||
?.map(Song::asMediaItem)
|
||||
?.let { mediaItems ->
|
||||
binder?.player?.enqueue(mediaItems)
|
||||
}
|
||||
|
@ -270,7 +266,7 @@ fun LocalPlaylistSongs(
|
|||
},
|
||||
onClick = {
|
||||
playlistWithSongs?.songs
|
||||
?.map(DetailedSong::asMediaItem)
|
||||
?.map(Song::asMediaItem)
|
||||
?.let { mediaItems ->
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(mediaItems, index)
|
||||
|
@ -292,7 +288,7 @@ fun LocalPlaylistSongs(
|
|||
if (songs.isNotEmpty()) {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.shuffled().map(DetailedSong::asMediaItem)
|
||||
songs.shuffled().map(Song::asMediaItem)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.tween
|
||||
|
@ -21,10 +20,11 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.autoSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
@ -47,13 +47,12 @@ import it.vfsfitvnm.vimusic.ui.styling.favoritesIcon
|
|||
import it.vfsfitvnm.vimusic.utils.bold
|
||||
import it.vfsfitvnm.vimusic.utils.forceSeekToNext
|
||||
import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.rememberRepeatMode
|
||||
import it.vfsfitvnm.vimusic.utils.formatAsDuration
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import it.vfsfitvnm.vimusic.utils.trackLoopEnabledKey
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@Composable
|
||||
fun Controls(
|
||||
|
@ -70,22 +69,18 @@ fun Controls(
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
binder?.player ?: return
|
||||
|
||||
val repeatMode by rememberRepeatMode(binder.player)
|
||||
var trackLoopEnabled by rememberPreference(trackLoopEnabledKey, defaultValue = false)
|
||||
|
||||
var scrubbingPosition by remember(mediaId) {
|
||||
mutableStateOf<Long?>(null)
|
||||
}
|
||||
|
||||
val likedAt by produceSaveableState<Long?>(
|
||||
initialValue = null,
|
||||
stateSaver = autoSaver(),
|
||||
mediaId
|
||||
) {
|
||||
Database
|
||||
.likedAt(mediaId)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.distinctUntilChanged()
|
||||
.collect { value = it }
|
||||
var likedAt by rememberSaveable {
|
||||
mutableStateOf<Long?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(mediaId) {
|
||||
Database.likedAt(mediaId).distinctUntilChanged().collect { likedAt = it }
|
||||
}
|
||||
|
||||
val shouldBePlayingTransition = updateTransition(shouldBePlaying, label = "shouldBePlaying")
|
||||
|
@ -161,8 +156,7 @@ fun Controls(
|
|||
.fillMaxWidth()
|
||||
) {
|
||||
BasicText(
|
||||
text = DateUtils.formatElapsedTime((scrubbingPosition ?: position) / 1000)
|
||||
.removePrefix("0"),
|
||||
text = formatAsDuration(scrubbingPosition ?: position),
|
||||
style = typography.xxs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
@ -170,7 +164,7 @@ fun Controls(
|
|||
|
||||
if (duration != C.TIME_UNSET) {
|
||||
BasicText(
|
||||
text = DateUtils.formatElapsedTime(duration / 1000).removePrefix("0"),
|
||||
text = formatAsDuration(duration),
|
||||
style = typography.xxs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
@ -268,17 +262,8 @@ fun Controls(
|
|||
|
||||
IconButton(
|
||||
icon = R.drawable.infinite,
|
||||
color = if (repeatMode == Player.REPEAT_MODE_ONE) {
|
||||
colorPalette.text
|
||||
} else {
|
||||
colorPalette.textDisabled
|
||||
},
|
||||
onClick = {
|
||||
binder.player.repeatMode = when (binder.player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
|
||||
else -> Player.REPEAT_MODE_ONE
|
||||
}
|
||||
},
|
||||
color = if (trackLoopEnabled) colorPalette.text else colorPalette.textDisabled,
|
||||
onClick = { trackLoopEnabled = !trackLoopEnabled },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.size(24.dp)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import android.app.SearchManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
@ -27,31 +28,37 @@ import androidx.compose.foundation.text.BasicText
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.autoSaver
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.NextBody
|
||||
import it.vfsfitvnm.innertube.requests.lyrics
|
||||
import it.vfsfitvnm.kugou.KuGou
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.Lyrics
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Menu
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
|
||||
|
@ -65,16 +72,11 @@ import it.vfsfitvnm.vimusic.utils.center
|
|||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.isShowingSynchronizedLyricsKey
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.toast
|
||||
import it.vfsfitvnm.vimusic.utils.verticalFadingEdge
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.NextBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.lyrics
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
@ -86,7 +88,7 @@ fun Lyrics(
|
|||
size: Dp,
|
||||
mediaMetadataProvider: () -> MediaMetadata,
|
||||
durationProvider: () -> Long,
|
||||
onLyricsUpdate: (Boolean, String, String) -> Unit,
|
||||
ensureSongInserted: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
|
@ -97,6 +99,7 @@ fun Lyrics(
|
|||
val (colorPalette, typography) = LocalAppearance.current
|
||||
val context = LocalContext.current
|
||||
val menuState = LocalMenuState.current
|
||||
val currentView = LocalView.current
|
||||
|
||||
var isShowingSynchronizedLyrics by rememberPreference(isShowingSynchronizedLyricsKey, false)
|
||||
|
||||
|
@ -104,75 +107,98 @@ fun Lyrics(
|
|||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val lyrics by produceSaveableState(
|
||||
initialValue = ".",
|
||||
stateSaver = autoSaver<String?>(),
|
||||
mediaId, isShowingSynchronizedLyrics
|
||||
) {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.synchronizedLyrics(mediaId)
|
||||
} else {
|
||||
Database.lyrics(mediaId)
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.distinctUntilChanged()
|
||||
.collect { value = it }
|
||||
var lyrics by remember {
|
||||
mutableStateOf<Lyrics?>(null)
|
||||
}
|
||||
|
||||
var isError by remember(lyrics) {
|
||||
val text = if (isShowingSynchronizedLyrics) lyrics?.synced else lyrics?.fixed
|
||||
|
||||
var isError by remember(mediaId, isShowingSynchronizedLyrics) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
LaunchedEffect(lyrics == null) {
|
||||
if (lyrics != null) return@LaunchedEffect
|
||||
LaunchedEffect(mediaId, isShowingSynchronizedLyrics) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Database.lyrics(mediaId).collect {
|
||||
if (isShowingSynchronizedLyrics && it?.synced == null) {
|
||||
val mediaMetadata = mediaMetadataProvider()
|
||||
var duration = withContext(Dispatchers.Main) {
|
||||
durationProvider()
|
||||
}
|
||||
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
val mediaMetadata = mediaMetadataProvider()
|
||||
var duration = withContext(Dispatchers.Main) {
|
||||
durationProvider()
|
||||
}
|
||||
while (duration == C.TIME_UNSET) {
|
||||
delay(100)
|
||||
duration = withContext(Dispatchers.Main) {
|
||||
durationProvider()
|
||||
}
|
||||
}
|
||||
|
||||
while (duration == C.TIME_UNSET) {
|
||||
delay(100)
|
||||
duration = withContext(Dispatchers.Main) {
|
||||
durationProvider()
|
||||
KuGou.lyrics(
|
||||
artist = mediaMetadata.artist?.toString() ?: "",
|
||||
title = mediaMetadata.title?.toString() ?: "",
|
||||
duration = duration / 1000
|
||||
)?.onSuccess { syncedLyrics ->
|
||||
Database.upsert(
|
||||
Lyrics(
|
||||
songId = mediaId,
|
||||
fixed = it?.fixed,
|
||||
synced = syncedLyrics?.value ?: ""
|
||||
)
|
||||
)
|
||||
}?.onFailure {
|
||||
isError = true
|
||||
}
|
||||
} else if (!isShowingSynchronizedLyrics && it?.fixed == null) {
|
||||
Innertube.lyrics(NextBody(videoId = mediaId))?.onSuccess { fixedLyrics ->
|
||||
Database.upsert(
|
||||
Lyrics(
|
||||
songId = mediaId,
|
||||
fixed = fixedLyrics ?: "",
|
||||
synced = it?.synced
|
||||
)
|
||||
)
|
||||
}?.onFailure {
|
||||
isError = true
|
||||
}
|
||||
} else {
|
||||
lyrics = it
|
||||
}
|
||||
}
|
||||
|
||||
KuGou.lyrics(
|
||||
artist = mediaMetadata.artist?.toString() ?: "",
|
||||
title = mediaMetadata.title?.toString() ?: "",
|
||||
duration = duration / 1000
|
||||
)?.map { it?.value }
|
||||
} else {
|
||||
Innertube.lyrics(NextBody(videoId = mediaId))
|
||||
}?.onSuccess { newLyrics ->
|
||||
onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "")
|
||||
}?.onFailure {
|
||||
isError = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
TextFieldDialog(
|
||||
hintText = "Enter the lyrics",
|
||||
initialTextInput = lyrics ?: "",
|
||||
initialTextInput = text ?: "",
|
||||
singleLine = false,
|
||||
maxLines = 10,
|
||||
isTextInputValid = { true },
|
||||
onDismiss = { isEditing = false },
|
||||
onDone = {
|
||||
query {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.updateSynchronizedLyrics(mediaId, it)
|
||||
} else {
|
||||
Database.updateLyrics(mediaId, it)
|
||||
}
|
||||
ensureSongInserted()
|
||||
Database.upsert(
|
||||
Lyrics(
|
||||
songId = mediaId,
|
||||
fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else it,
|
||||
synced = if (isShowingSynchronizedLyrics) it else lyrics?.synced,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
DisposableEffect(Unit) {
|
||||
currentView.keepScreenOn = true
|
||||
onDispose {
|
||||
currentView.keepScreenOn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
|
@ -185,7 +211,7 @@ fun Lyrics(
|
|||
.background(Color.Black.copy(0.8f))
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isError && lyrics == null,
|
||||
visible = isError && text == null,
|
||||
enter = slideInVertically { -it },
|
||||
exit = slideOutVertically { -it },
|
||||
modifier = Modifier
|
||||
|
@ -202,7 +228,7 @@ fun Lyrics(
|
|||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = lyrics?.let(String::isEmpty) ?: false,
|
||||
visible = text?.let(String::isEmpty) ?: false,
|
||||
enter = slideInVertically { -it },
|
||||
exit = slideOutVertically { -it },
|
||||
modifier = Modifier
|
||||
|
@ -218,72 +244,78 @@ fun Lyrics(
|
|||
)
|
||||
}
|
||||
|
||||
lyrics?.let { lyrics ->
|
||||
if (lyrics.isNotEmpty() && lyrics != ".") {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
val density = LocalDensity.current
|
||||
val player = LocalPlayerServiceBinder.current?.player
|
||||
?: return@AnimatedVisibility
|
||||
if (text?.isNotEmpty() == true) {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
val density = LocalDensity.current
|
||||
val player = LocalPlayerServiceBinder.current?.player
|
||||
?: return@AnimatedVisibility
|
||||
|
||||
val synchronizedLyrics = remember(lyrics) {
|
||||
SynchronizedLyrics(KuGou.Lyrics(lyrics).sentences) {
|
||||
player.currentPosition + 50
|
||||
}
|
||||
val synchronizedLyrics = remember(text) {
|
||||
SynchronizedLyrics(KuGou.Lyrics(text).sentences) {
|
||||
player.currentPosition + 50
|
||||
}
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState(
|
||||
synchronizedLyrics.index,
|
||||
with(density) { size.roundToPx() } / 6)
|
||||
val lazyListState = rememberLazyListState(
|
||||
synchronizedLyrics.index,
|
||||
with(density) { size.roundToPx() } / 6)
|
||||
|
||||
LaunchedEffect(synchronizedLyrics) {
|
||||
val center = with(density) { size.roundToPx() } / 6
|
||||
LaunchedEffect(synchronizedLyrics) {
|
||||
val center = with(density) { size.roundToPx() } / 6
|
||||
|
||||
while (isActive) {
|
||||
delay(50)
|
||||
if (synchronizedLyrics.update()) {
|
||||
lazyListState.animateScrollToItem(
|
||||
synchronizedLyrics.index,
|
||||
center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
userScrollEnabled = false,
|
||||
contentPadding = PaddingValues(vertical = size / 2),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.verticalFadingEdge()
|
||||
) {
|
||||
itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence ->
|
||||
BasicText(
|
||||
text = sentence.second,
|
||||
style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) PureBlackColorPalette.text else PureBlackColorPalette.textDisabled),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp, horizontal = 32.dp)
|
||||
while (isActive) {
|
||||
delay(50)
|
||||
if (synchronizedLyrics.update()) {
|
||||
lazyListState.animateScrollToItem(
|
||||
synchronizedLyrics.index,
|
||||
center
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BasicText(
|
||||
text = lyrics,
|
||||
style = typography.xs.center.medium.color(PureBlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.verticalFadingEdge()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = size / 4, horizontal = 32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
userScrollEnabled = false,
|
||||
contentPadding = PaddingValues(vertical = size / 2),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.verticalFadingEdge()
|
||||
) {
|
||||
itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence ->
|
||||
BasicText(
|
||||
text = sentence.second,
|
||||
style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) PureBlackColorPalette.text else PureBlackColorPalette.textDisabled),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp, horizontal = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BasicText(
|
||||
text = text,
|
||||
style = typography.xs.center.medium.color(PureBlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.verticalFadingEdge()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = size / 4, horizontal = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (lyrics == null && !isError) {
|
||||
ShimmerHost(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (text == null && !isError) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
) {
|
||||
repeat(4) {
|
||||
TextPlaceholder(color = colorPalette.onOverlayShimmer)
|
||||
TextPlaceholder(
|
||||
color = colorPalette.onOverlayShimmer,
|
||||
modifier = Modifier
|
||||
.alpha(1f - it * 0.2f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -327,24 +359,17 @@ fun Lyrics(
|
|||
menuState.hide()
|
||||
val mediaMetadata = mediaMetadataProvider()
|
||||
|
||||
val intent =
|
||||
Intent(Intent.ACTION_WEB_SEARCH).apply {
|
||||
putExtra(
|
||||
SearchManager.QUERY,
|
||||
"${mediaMetadata.title} ${mediaMetadata.artist} lyrics"
|
||||
)
|
||||
}
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"No browser app found!",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
try {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_WEB_SEARCH).apply {
|
||||
putExtra(
|
||||
SearchManager.QUERY,
|
||||
"${mediaMetadata.title} ${mediaMetadata.artist} lyrics"
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
context.toast("Couldn't find an application to browse the Internet")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -356,11 +381,13 @@ fun Lyrics(
|
|||
onClick = {
|
||||
menuState.hide()
|
||||
query {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.updateSynchronizedLyrics(mediaId, null)
|
||||
} else {
|
||||
Database.updateLyrics(mediaId, null)
|
||||
}
|
||||
Database.upsert(
|
||||
Lyrics(
|
||||
songId = mediaId,
|
||||
fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else null,
|
||||
synced = if (isShowingSynchronizedLyrics) null else lyrics?.synced,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
|
@ -29,6 +29,8 @@ import androidx.compose.foundation.text.BasicText
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.neverEqualPolicy
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
@ -44,7 +46,8 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.route.OnGlobalRoute
|
||||
import it.vfsfitvnm.innertube.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.compose.routing.OnGlobalRoute
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.service.PlayerService
|
||||
|
@ -58,15 +61,16 @@ import it.vfsfitvnm.vimusic.ui.styling.Dimensions
|
|||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.collapsedPlayerProgressBar
|
||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.DisposableListener
|
||||
import it.vfsfitvnm.vimusic.utils.forceSeekToNext
|
||||
import it.vfsfitvnm.vimusic.utils.isLandscape
|
||||
import it.vfsfitvnm.vimusic.utils.rememberMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPositionAndDuration
|
||||
import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying
|
||||
import it.vfsfitvnm.vimusic.utils.positionAndDurationState
|
||||
import it.vfsfitvnm.vimusic.utils.seamlessPlay
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.vimusic.utils.shouldBePlaying
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.vimusic.utils.toast
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
|
@ -83,12 +87,33 @@ fun Player(
|
|||
|
||||
binder?.player ?: return
|
||||
|
||||
val nullableMediaItem by rememberMediaItem(binder.player)
|
||||
var nullableMediaItem by remember {
|
||||
mutableStateOf(binder.player.currentMediaItem, neverEqualPolicy())
|
||||
}
|
||||
|
||||
var shouldBePlaying by remember {
|
||||
mutableStateOf(binder.player.shouldBePlaying)
|
||||
}
|
||||
|
||||
binder.player.DisposableListener {
|
||||
object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
nullableMediaItem = mediaItem
|
||||
}
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
shouldBePlaying = binder.player.shouldBePlaying
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
shouldBePlaying = binder.player.shouldBePlaying
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mediaItem = nullableMediaItem ?: return
|
||||
|
||||
val shouldBePlaying by rememberShouldBePlaying(binder.player)
|
||||
val positionAndDuration by rememberPositionAndDuration(binder.player)
|
||||
val positionAndDuration by binder.player.positionAndDurationState()
|
||||
|
||||
val windowInsets = WindowInsets.systemBars
|
||||
|
||||
|
@ -198,7 +223,7 @@ fun Player(
|
|||
IconButton(
|
||||
icon = R.drawable.play_skip_forward,
|
||||
color = colorPalette.text,
|
||||
onClick = binder.player::seekToNext,
|
||||
onClick = binder.player::forceSeekToNext,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
.size(20.dp)
|
||||
|
@ -358,7 +383,9 @@ private fun PlayerMenu(
|
|||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resultRegistryOwner = LocalActivityResultRegistryOwner.current
|
||||
|
||||
val activityResultLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
|
||||
|
||||
BaseMediaItemMenu(
|
||||
mediaItem = mediaItem,
|
||||
|
@ -368,19 +395,16 @@ private fun PlayerMenu(
|
|||
binder.setupRadio(NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId))
|
||||
},
|
||||
onGoToEqualizer = {
|
||||
val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
|
||||
putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder.player.audioSessionId)
|
||||
putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
||||
putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||
}
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
val contract = ActivityResultContracts.StartActivityForResult()
|
||||
|
||||
resultRegistryOwner?.activityResultRegistry
|
||||
?.register("", contract) {}?.launch(intent)
|
||||
} else {
|
||||
Toast.makeText(context, "No equalizer app found!", Toast.LENGTH_SHORT).show()
|
||||
try {
|
||||
activityResultLauncher.launch(
|
||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
|
||||
putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder.player.audioSessionId)
|
||||
putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
||||
putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||
}
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
context.toast("Couldn't find an application to equalize audio")
|
||||
}
|
||||
},
|
||||
onShowSleepTimer = {},
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
|
@ -11,6 +18,7 @@ import androidx.compose.foundation.combinedClickable
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
|
@ -28,22 +36,28 @@ import androidx.compose.foundation.text.BasicText
|
|||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.reordering.ReorderingLazyColumn
|
||||
import it.vfsfitvnm.reordering.animateItemPlacement
|
||||
import it.vfsfitvnm.reordering.draggedItem
|
||||
import it.vfsfitvnm.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.reordering.reorder
|
||||
import it.vfsfitvnm.compose.reordering.ReorderingLazyColumn
|
||||
import it.vfsfitvnm.compose.reordering.animateItemPlacement
|
||||
import it.vfsfitvnm.compose.reordering.draggedItem
|
||||
import it.vfsfitvnm.compose.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.compose.reordering.reorder
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheet
|
||||
|
@ -59,12 +73,14 @@ import it.vfsfitvnm.vimusic.ui.styling.Dimensions
|
|||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.onOverlay
|
||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.DisposableListener
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
|
||||
import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying
|
||||
import it.vfsfitvnm.vimusic.utils.rememberWindows
|
||||
import it.vfsfitvnm.vimusic.utils.queueLoopEnabledKey
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.shouldBePlaying
|
||||
import it.vfsfitvnm.vimusic.utils.shuffleQueue
|
||||
import it.vfsfitvnm.vimusic.utils.smoothScrollToTop
|
||||
import it.vfsfitvnm.vimusic.utils.windows
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
|
@ -110,24 +126,61 @@ fun Queue(
|
|||
|
||||
binder?.player ?: return@BottomSheet
|
||||
|
||||
val player = binder.player
|
||||
|
||||
var queueLoopEnabled by rememberPreference(queueLoopEnabledKey, defaultValue = true)
|
||||
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val thumbnailSizeDp = Dimensions.thumbnails.song
|
||||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
val mediaItemIndex by rememberMediaItemIndex(binder.player)
|
||||
val windows by rememberWindows(binder.player)
|
||||
val shouldBePlaying by rememberShouldBePlaying(binder.player)
|
||||
var mediaItemIndex by remember {
|
||||
mutableStateOf(if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex)
|
||||
}
|
||||
|
||||
var windows by remember {
|
||||
mutableStateOf(player.currentTimeline.windows)
|
||||
}
|
||||
|
||||
var shouldBePlaying by remember {
|
||||
mutableStateOf(binder.player.shouldBePlaying)
|
||||
}
|
||||
|
||||
player.DisposableListener {
|
||||
object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
mediaItemIndex =
|
||||
if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
windows = timeline.windows
|
||||
mediaItemIndex =
|
||||
if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex
|
||||
}
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
shouldBePlaying = binder.player.shouldBePlaying
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
shouldBePlaying = binder.player.shouldBePlaying
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val reorderingState = rememberReorderingState(
|
||||
lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex),
|
||||
key = windows,
|
||||
onDragEnd = binder.player::moveMediaItem,
|
||||
onDragEnd = player::moveMediaItem,
|
||||
extraItemCount = 0
|
||||
)
|
||||
|
||||
val rippleIndication = rememberRipple(bounded = false)
|
||||
|
||||
val musicBarsTransition = updateTransition(targetState = mediaItemIndex, label = "")
|
||||
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
@ -137,7 +190,8 @@ fun Queue(
|
|||
ReorderingLazyColumn(
|
||||
reorderingState = reorderingState,
|
||||
contentPadding = windowInsets
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top).asPaddingValues(),
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
|
||||
.asPaddingValues(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.nestedScroll(layoutState.preUpPostDownNestedScrollConnection)
|
||||
|
@ -154,10 +208,10 @@ fun Queue(
|
|||
thumbnailSizePx = thumbnailSizePx,
|
||||
thumbnailSizeDp = thumbnailSizeDp,
|
||||
onThumbnailContent = {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isPlayingThisMediaItem,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
musicBarsTransition.AnimatedVisibility(
|
||||
visible = { it == window.firstPeriodIndex },
|
||||
enter = fadeIn(tween(800)),
|
||||
exit = fadeOut(tween(800)),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
|
@ -214,13 +268,13 @@ fun Queue(
|
|||
onClick = {
|
||||
if (isPlayingThisMediaItem) {
|
||||
if (shouldBePlaying) {
|
||||
binder.player.pause()
|
||||
player.pause()
|
||||
} else {
|
||||
binder.player.play()
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
binder.player.playWhenReady = true
|
||||
binder.player.seekToDefaultPosition(window.firstPeriodIndex)
|
||||
player.seekToDefaultPosition(window.firstPeriodIndex)
|
||||
player.playWhenReady = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -240,11 +294,10 @@ fun Queue(
|
|||
) {
|
||||
repeat(3) { index ->
|
||||
SongItemPlaceholder(
|
||||
thumbnailSizeDp = Dimensions.thumbnails.song,
|
||||
thumbnailSizeDp = thumbnailSizeDp,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.125f)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -261,7 +314,7 @@ fun Queue(
|
|||
reorderingState.coroutineScope.launch {
|
||||
reorderingState.lazyListState.smoothScrollToTop()
|
||||
}.invokeOnCompletion {
|
||||
binder.player.shuffleQueue()
|
||||
player.shuffleQueue()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -297,6 +350,38 @@ fun Queue(
|
|||
.align(Alignment.Center)
|
||||
.size(18.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable { queueLoopEnabled = !queueLoopEnabled }
|
||||
.background(colorPalette.background1)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.align(Alignment.CenterEnd)
|
||||
.animateContentSize()
|
||||
) {
|
||||
BasicText(
|
||||
text = "Queue loop ",
|
||||
style = typography.xxs.medium,
|
||||
)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = queueLoopEnabled,
|
||||
transitionSpec = {
|
||||
val slideDirection = if (targetState) AnimatedContentScope.SlideDirection.Up else AnimatedContentScope.SlideDirection.Down
|
||||
|
||||
ContentTransform(
|
||||
targetContentEnter = slideIntoContainer(slideDirection) + fadeIn(),
|
||||
initialContentExit = slideOutOfContainer(slideDirection) + fadeOut(),
|
||||
)
|
||||
}
|
||||
) {
|
||||
BasicText(
|
||||
text = if (it) "on" else "off",
|
||||
style = typography.xxs.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import androidx.compose.animation.AnimatedVisibility
|
|||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
@ -16,7 +15,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
@ -28,23 +27,23 @@ import androidx.compose.ui.platform.LocalContext
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import androidx.media3.datasource.cache.CacheSpan
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.PlayerBody
|
||||
import it.vfsfitvnm.innertube.requests.player
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.models.Format
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.onOverlay
|
||||
import it.vfsfitvnm.vimusic.ui.styling.overlay
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.rememberVolume
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.PlayerBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.player
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun StatsForNerds(
|
||||
|
@ -66,11 +65,39 @@ fun StatsForNerds(
|
|||
mutableStateOf(binder.cache.getCachedBytes(mediaId, 0, -1))
|
||||
}
|
||||
|
||||
val format by remember(mediaId) {
|
||||
Database.format(mediaId).distinctUntilChanged()
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
var format by remember {
|
||||
mutableStateOf<Format?>(null)
|
||||
}
|
||||
|
||||
val volume by rememberVolume(binder.player)
|
||||
LaunchedEffect(mediaId) {
|
||||
Database.format(mediaId).distinctUntilChanged().collectLatest { currentFormat ->
|
||||
if (currentFormat?.itag == null) {
|
||||
binder.player.currentMediaItem?.takeIf { it.mediaId == mediaId }?.let { mediaItem ->
|
||||
withContext(Dispatchers.IO) {
|
||||
delay(2000)
|
||||
Innertube.player(PlayerBody(videoId = mediaId))?.onSuccess { response ->
|
||||
response.streamingData?.highestQualityFormat?.let { format ->
|
||||
Database.insert(mediaItem)
|
||||
Database.insert(
|
||||
Format(
|
||||
songId = mediaId,
|
||||
itag = format.itag,
|
||||
mimeType = format.mimeType,
|
||||
bitrate = format.bitrate,
|
||||
loudnessDb = response.playerConfig?.audioConfig?.normalizedLoudnessDb,
|
||||
contentLength = format.contentLength,
|
||||
lastModified = format.lastModified
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
format = currentFormat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(mediaId) {
|
||||
val listener = object : Cache.Listener {
|
||||
|
@ -82,11 +109,8 @@ fun StatsForNerds(
|
|||
cachedBytes -= span.length
|
||||
}
|
||||
|
||||
override fun onSpanTouched(
|
||||
cache: Cache,
|
||||
oldSpan: CacheSpan,
|
||||
newSpan: CacheSpan
|
||||
) = Unit
|
||||
override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) =
|
||||
Unit
|
||||
}
|
||||
|
||||
binder.cache.addListener(mediaId, listener)
|
||||
|
@ -120,11 +144,7 @@ fun StatsForNerds(
|
|||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "Volume",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "Loudness",
|
||||
text = "Itag",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
|
@ -139,88 +159,52 @@ fun StatsForNerds(
|
|||
text = "Cached",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "Loudness",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = mediaId,
|
||||
maxLines = 1,
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "${volume.times(100).roundToInt()}%",
|
||||
text = format?.itag?.toString() ?: "Unknown",
|
||||
maxLines = 1,
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = format?.loudnessDb?.let { loudnessDb ->
|
||||
"%.2f dB".format(loudnessDb)
|
||||
} ?: "Unknown",
|
||||
text = format?.bitrate?.let { "${it / 1000} kbps" } ?: "Unknown",
|
||||
maxLines = 1,
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = format?.bitrate?.let { bitrate ->
|
||||
"${bitrate / 1000} kbps"
|
||||
} ?: "Unknown",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = format?.contentLength?.let { contentLength ->
|
||||
Formatter.formatShortFileSize(
|
||||
context,
|
||||
contentLength
|
||||
)
|
||||
} ?: "Unknown",
|
||||
text = format?.contentLength
|
||||
?.let { Formatter.formatShortFileSize(context, it) } ?: "Unknown",
|
||||
maxLines = 1,
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = buildString {
|
||||
append(Formatter.formatShortFileSize(context, cachedBytes))
|
||||
|
||||
format?.contentLength?.let { contentLength ->
|
||||
append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
|
||||
format?.contentLength?.let {
|
||||
append(" (${(cachedBytes.toFloat() / it * 100).roundToInt()}%)")
|
||||
}
|
||||
},
|
||||
maxLines = 1,
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = format?.loudnessDb?.let { "%.2f dB".format(it) } ?: "Unknown",
|
||||
maxLines = 1,
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (format != null && format?.itag == null) {
|
||||
BasicText(
|
||||
text = "FETCH MISSING DATA",
|
||||
style = typography.xxs.medium.color(colorPalette.onOverlay),
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
onClick = {
|
||||
query {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
Innertube.player(PlayerBody(videoId = mediaId))
|
||||
?.map { response ->
|
||||
response.streamingData?.adaptiveFormats
|
||||
?.findLast { format ->
|
||||
format.itag == 251 || format.itag == 140
|
||||
}
|
||||
?.let { format ->
|
||||
Format(
|
||||
songId = mediaId,
|
||||
itag = format.itag,
|
||||
mimeType = format.mimeType,
|
||||
bitrate = format.bitrate,
|
||||
loudnessDb = response.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
|
||||
contentLength = format.contentLength,
|
||||
lastModified = format.lastModified
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
?.getOrNull()
|
||||
?.let(Database::insert)
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,25 +17,30 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.service.LoginRequiredException
|
||||
import it.vfsfitvnm.vimusic.service.PlayableFormatNotFoundException
|
||||
import it.vfsfitvnm.vimusic.service.UnplayableException
|
||||
import it.vfsfitvnm.vimusic.service.VideoIdMismatchException
|
||||
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.rememberError
|
||||
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
|
||||
import it.vfsfitvnm.vimusic.utils.currentWindow
|
||||
import it.vfsfitvnm.vimusic.utils.DisposableListener
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import java.net.UnknownHostException
|
||||
import java.nio.channels.UnresolvedAddressException
|
||||
|
@ -56,16 +61,38 @@ fun Thumbnail(
|
|||
it to (it - 64.dp).px
|
||||
}
|
||||
|
||||
val mediaItemIndex by rememberMediaItemIndex(player)
|
||||
var nullableWindow by remember {
|
||||
mutableStateOf(player.currentWindow)
|
||||
}
|
||||
|
||||
val error by rememberError(player)
|
||||
var error by remember {
|
||||
mutableStateOf<PlaybackException?>(player.playerError)
|
||||
}
|
||||
|
||||
player.DisposableListener {
|
||||
object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
nullableWindow = player.currentWindow
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
error = player.playerError
|
||||
}
|
||||
|
||||
override fun onPlayerError(playbackException: PlaybackException) {
|
||||
error = playbackException
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val window = nullableWindow ?: return
|
||||
|
||||
AnimatedContent(
|
||||
targetState = mediaItemIndex,
|
||||
targetState = window,
|
||||
transitionSpec = {
|
||||
val duration = 500
|
||||
val slideDirection =
|
||||
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
||||
if (targetState.firstPeriodIndex > initialState.firstPeriodIndex) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
||||
|
||||
ContentTransform(
|
||||
targetContentEnter = slideIntoContainer(
|
||||
|
@ -90,11 +117,7 @@ fun Thumbnail(
|
|||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) { currentMediaItemIndex ->
|
||||
val mediaItem = remember(currentMediaItemIndex) {
|
||||
player.getMediaItemAt(currentMediaItemIndex)
|
||||
}
|
||||
|
||||
) {currentWindow ->
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
|
@ -102,7 +125,7 @@ fun Thumbnail(
|
|||
.size(thumbnailSizeDp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
|
||||
model = currentWindow.mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
|
@ -116,37 +139,17 @@ fun Thumbnail(
|
|||
)
|
||||
|
||||
Lyrics(
|
||||
mediaId = mediaItem.mediaId,
|
||||
mediaId = currentWindow.mediaItem.mediaId,
|
||||
isDisplayed = isShowingLyrics && error == null,
|
||||
onDismiss = { onShowLyrics(false) },
|
||||
onLyricsUpdate = { areSynchronized, mediaId, lyrics ->
|
||||
query {
|
||||
if (areSynchronized) {
|
||||
if (Database.updateSynchronizedLyrics(mediaId, lyrics) == 0) {
|
||||
if (mediaId == mediaItem.mediaId) {
|
||||
Database.insert(mediaItem) { song ->
|
||||
song.copy(synchronizedLyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Database.updateLyrics(mediaId, lyrics) == 0) {
|
||||
if (mediaId == mediaItem.mediaId) {
|
||||
Database.insert(mediaItem) { song ->
|
||||
song.copy(lyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ensureSongInserted = { Database.insert(currentWindow.mediaItem) },
|
||||
size = thumbnailSizeDp,
|
||||
mediaMetadataProvider = mediaItem::mediaMetadata,
|
||||
mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata,
|
||||
durationProvider = player::getDuration,
|
||||
)
|
||||
|
||||
StatsForNerds(
|
||||
mediaId = mediaItem.mediaId,
|
||||
mediaId = currentWindow.mediaItem.mediaId,
|
||||
isDisplayed = isShowingStatsForNerds && error == null,
|
||||
onDismiss = { onShowStatsForNerds(false) }
|
||||
)
|
||||
|
@ -159,6 +162,7 @@ fun Thumbnail(
|
|||
is PlayableFormatNotFoundException -> "Couldn't find a playable audio format"
|
||||
is UnplayableException -> "The original video source of this song has been deleted"
|
||||
is LoginRequiredException -> "This song cannot be played due to server restrictions"
|
||||
is VideoIdMismatchException -> "The returned video id doesn't match the requested one"
|
||||
else -> "An unknown playback error has occurred"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -4,7 +4,8 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.compose.persist.PersistMapCleanup
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
|
||||
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
|
||||
|
@ -14,6 +15,7 @@ import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
|
|||
@Composable
|
||||
fun PlaylistScreen(browseId: String) {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
PersistMapCleanup(tagPrefix = "playlist/$browseId")
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
globalRoutes()
|
||||
|
|
|
@ -8,11 +8,15 @@ import androidx.compose.foundation.combinedClickable
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
|
@ -21,18 +25,17 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.innertube.requests.playlistPage
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubePlaylistOrAlbumPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.nullableSaver
|
||||
import it.vfsfitvnm.vimusic.transaction
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
|
||||
|
@ -56,10 +59,6 @@ import it.vfsfitvnm.vimusic.utils.enqueue
|
|||
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
|
||||
import it.vfsfitvnm.vimusic.utils.isLandscape
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.playlistPage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
@ -74,13 +73,12 @@ fun PlaylistSongList(
|
|||
val context = LocalContext.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val playlistPage by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(InnertubePlaylistOrAlbumPageSaver),
|
||||
) {
|
||||
if (value != null && value?.songsPage?.continuation == null) return@produceSaveableState
|
||||
var playlistPage by persist<Innertube.PlaylistOrAlbumPage?>("playlist/$browseId/playlistPage")
|
||||
|
||||
value = withContext(Dispatchers.IO) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (playlistPage != null && playlistPage?.songsPage?.continuation == null) return@LaunchedEffect
|
||||
|
||||
playlistPage = withContext(Dispatchers.IO) {
|
||||
Innertube.playlistPage(BrowseBody(browseId = browseId))?.completed()?.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,27 +4,30 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import it.vfsfitvnm.compose.persist.persistList
|
||||
import it.vfsfitvnm.innertube.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
|
@ -38,10 +41,6 @@ import it.vfsfitvnm.vimusic.utils.align
|
|||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -55,16 +54,11 @@ fun LocalSongSearch(
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val items by produceSaveableState(
|
||||
initialValue = emptyList(),
|
||||
stateSaver = DetailedSongListSaver,
|
||||
key1 = textFieldValue.text
|
||||
) {
|
||||
var items by persistList<Song>("search/local/songs")
|
||||
|
||||
LaunchedEffect(textFieldValue.text) {
|
||||
if (textFieldValue.text.length > 1) {
|
||||
Database
|
||||
.search("%${textFieldValue.text}%")
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
Database.search("%${textFieldValue.text}%").collect { items = it }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,7 +105,7 @@ fun LocalSongSearch(
|
|||
|
||||
items(
|
||||
items = items,
|
||||
key = DetailedSong::id,
|
||||
key = Song::id,
|
||||
) { song ->
|
||||
SongItem(
|
||||
song = song,
|
||||
|
|
|
@ -7,8 +7,11 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
@ -23,7 +26,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.autoSaver
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.paint
|
||||
|
@ -32,6 +35,7 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
|
@ -39,17 +43,16 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.compose.persist.persistList
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.SearchSuggestionsBody
|
||||
import it.vfsfitvnm.innertube.requests.searchSuggestions
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.SearchQuery
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.savers.SearchQuerySaver
|
||||
import it.vfsfitvnm.vimusic.savers.listSaver
|
||||
import it.vfsfitvnm.vimusic.savers.resultSaver
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
|
||||
|
@ -57,16 +60,11 @@ import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
|||
import it.vfsfitvnm.vimusic.utils.align
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableOneShotState
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey
|
||||
import it.vfsfitvnm.vimusic.utils.preferences
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.SearchSuggestionsBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.searchSuggestions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
|
@ -77,34 +75,36 @@ fun OnlineSearch(
|
|||
onViewPlaylist: (String) -> Unit,
|
||||
decorationBox: @Composable (@Composable () -> Unit) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val (colorPalette, typography) = LocalAppearance.current
|
||||
|
||||
val history by produceSaveableState(
|
||||
initialValue = emptyList(),
|
||||
stateSaver = listSaver(SearchQuerySaver),
|
||||
key1 = textFieldValue.text
|
||||
) {
|
||||
Database.queries("%${textFieldValue.text}%")
|
||||
.flowOn(Dispatchers.IO)
|
||||
.distinctUntilChanged { old, new -> old.size == new.size }
|
||||
.collect { value = it }
|
||||
var history by persistList<SearchQuery>("search/online/history")
|
||||
|
||||
LaunchedEffect(textFieldValue.text) {
|
||||
if (!context.preferences.getBoolean(pauseSearchHistoryKey, false)) {
|
||||
Database.queries("%${textFieldValue.text}%")
|
||||
.distinctUntilChanged { old, new -> old.size == new.size }
|
||||
.collect { history = it }
|
||||
}
|
||||
}
|
||||
|
||||
val suggestionsResult by produceSaveableOneShotState(
|
||||
initialValue = null,
|
||||
stateSaver = resultSaver(autoSaver<List<String>?>()),
|
||||
textFieldValue.text
|
||||
) {
|
||||
var suggestionsResult by persist<Result<List<String>?>?>("search/online/suggestionsResult")
|
||||
|
||||
LaunchedEffect(textFieldValue.text) {
|
||||
if (textFieldValue.text.isNotEmpty()) {
|
||||
value = Innertube.searchSuggestions(SearchSuggestionsBody(input = textFieldValue.text))
|
||||
delay(200)
|
||||
suggestionsResult =
|
||||
Innertube.searchSuggestions(SearchSuggestionsBody(input = textFieldValue.text))
|
||||
}
|
||||
}
|
||||
|
||||
val playlistId = remember(textFieldValue.text) {
|
||||
val isPlaylistUrl = listOf(
|
||||
"https://www.youtube.com/playlist?",
|
||||
"https://youtube.com/playlist?",
|
||||
"https://music.youtube.com/playlist?",
|
||||
"https://m.youtube.com/playlist?",
|
||||
"https://m.youtube.com/playlist?"
|
||||
).any(textFieldValue.text::startsWith)
|
||||
|
||||
if (isPlaylistUrl) textFieldValue.text.toUri().getQueryParameter("list") else null
|
||||
|
@ -160,7 +160,7 @@ fun OnlineSearch(
|
|||
val isAlbum = playlistId.startsWith("OLAK5uy_")
|
||||
|
||||
SecondaryTextButton(
|
||||
text = "View ${if (isAlbum) "album" else "playlist"}",
|
||||
text = "View ${if (isAlbum) "album" else "playlist"}",
|
||||
onClick = { onViewPlaylist(textFieldValue.text) }
|
||||
)
|
||||
}
|
||||
|
@ -172,7 +172,7 @@ fun OnlineSearch(
|
|||
|
||||
if (textFieldValue.text.isNotEmpty()) {
|
||||
SecondaryTextButton(
|
||||
text = "Clear",
|
||||
text = "Clear",
|
||||
onClick = { onTextFieldValueChanged(TextFieldValue()) }
|
||||
)
|
||||
}
|
||||
|
|
|
@ -16,7 +16,8 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.compose.persist.PersistMapCleanup
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
|
||||
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
|
||||
|
@ -49,6 +50,8 @@ fun SearchScreen(
|
|||
)
|
||||
}
|
||||
|
||||
PersistMapCleanup(tagPrefix = "search/")
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
globalRoutes()
|
||||
|
||||
|
|
|
@ -2,8 +2,11 @@ package it.vfsfitvnm.vimusic.ui.screens.searchresult
|
|||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
|
@ -11,32 +14,29 @@ import androidx.compose.foundation.lazy.items
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.compose.persist.persist
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.utils.plus
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.savers.nullableSaver
|
||||
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.utils.plus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
inline fun <T : Innertube.Item> ItemsPage(
|
||||
stateSaver: Saver<Innertube.ItemsPage<T>, List<Any?>>,
|
||||
tag: String,
|
||||
crossinline headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit,
|
||||
crossinline itemContent: @Composable LazyItemScope.(T) -> Unit,
|
||||
noinline itemPlaceholderContent: @Composable () -> Unit,
|
||||
|
@ -47,29 +47,29 @@ inline fun <T : Innertube.Item> ItemsPage(
|
|||
noinline itemsPageProvider: (suspend (String?) -> Result<Innertube.ItemsPage<T>?>?)? = null,
|
||||
) {
|
||||
val (_, typography) = LocalAppearance.current
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
val updatedItemsPageProvider by rememberUpdatedState(itemsPageProvider)
|
||||
|
||||
val itemsPage by produceSaveableState(
|
||||
initialValue = null,
|
||||
stateSaver = nullableSaver(stateSaver),
|
||||
lazyListState, updatedItemsPageProvider
|
||||
) {
|
||||
val currentItemsPageProvider = updatedItemsPageProvider ?: return@produceSaveableState
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
var itemsPage by persist<Innertube.ItemsPage<T>?>(tag)
|
||||
|
||||
LaunchedEffect(lazyListState, updatedItemsPageProvider) {
|
||||
val currentItemsPageProvider = updatedItemsPageProvider ?: return@LaunchedEffect
|
||||
|
||||
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } }
|
||||
.collect { shouldLoadMore ->
|
||||
if (!shouldLoadMore) return@collect
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
currentItemsPageProvider(value?.continuation)
|
||||
currentItemsPageProvider(itemsPage?.continuation)
|
||||
}?.onSuccess {
|
||||
if (it == null) {
|
||||
if (value == null) {
|
||||
value = Innertube.ItemsPage(null, null)
|
||||
if (itemsPage == null) {
|
||||
itemsPage = Innertube.ItemsPage(null, null)
|
||||
}
|
||||
} else {
|
||||
value += it
|
||||
itemsPage += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,16 +9,18 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.compose.persist.PersistMapCleanup
|
||||
import it.vfsfitvnm.compose.persist.persistMap
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.innertube.models.bodies.SearchBody
|
||||
import it.vfsfitvnm.innertube.requests.searchPage
|
||||
import it.vfsfitvnm.innertube.utils.from
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeAlbumsPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeArtistItemListSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubePlaylistItemListSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeSongsPageSaver
|
||||
import it.vfsfitvnm.vimusic.savers.InnertubeVideoItemListSaver
|
||||
import it.vfsfitvnm.vimusic.savers.innertubeItemsPageSaver
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
|
||||
|
@ -43,19 +45,17 @@ import it.vfsfitvnm.vimusic.utils.asMediaItem
|
|||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.searchResultScreenTabIndexKey
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.SearchBody
|
||||
import it.vfsfitvnm.youtubemusic.requests.searchPage
|
||||
import it.vfsfitvnm.youtubemusic.utils.from
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
val (tabIndex, onTabIndexChanges) = rememberPreference(searchResultScreenTabIndexKey, 0)
|
||||
|
||||
PersistMapCleanup(tagPrefix = "searchResults/$query/")
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
globalRoutes()
|
||||
|
||||
|
@ -66,6 +66,9 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
|
|||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
context.persistMap?.keys?.removeAll {
|
||||
it.startsWith("searchResults/$query/")
|
||||
}
|
||||
onSearchAgain()
|
||||
}
|
||||
}
|
||||
|
@ -74,8 +77,6 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
|
|||
|
||||
val emptyItemsText = "No results found. Please try a different query or category"
|
||||
|
||||
|
||||
|
||||
Scaffold(
|
||||
topIconButtonId = R.drawable.chevron_back,
|
||||
onTopIconButtonClick = pop,
|
||||
|
@ -99,7 +100,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
|
|||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = InnertubeSongsPageSaver,
|
||||
tag = "searchResults/$query/songs",
|
||||
itemsPageProvider = { continuation ->
|
||||
if (continuation == null) {
|
||||
Innertube.searchPage(
|
||||
|
@ -149,7 +150,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
|
|||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = InnertubeAlbumsPageSaver,
|
||||
tag = "searchResults/$query/albums",
|
||||
itemsPageProvider = { continuation ->
|
||||
if (continuation == null) {
|
||||
Innertube.searchPage(
|
||||
|
@ -186,7 +187,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
|
|||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = innertubeItemsPageSaver(InnertubeArtistItemListSaver),
|
||||
tag = "searchResults/$query/artists",
|
||||
itemsPageProvider = { continuation ->
|
||||
if (continuation == null) {
|
||||
Innertube.searchPage(
|
||||
|
@ -224,7 +225,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
|
|||
val thumbnailWidthDp = 128.dp
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = innertubeItemsPageSaver(InnertubeVideoItemListSaver),
|
||||
tag = "searchResults/$query/videos",
|
||||
itemsPageProvider = { continuation ->
|
||||
if (continuation == null) {
|
||||
Innertube.searchPage(
|
||||
|
@ -277,7 +278,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
|
|||
val thumbnailSizePx = thumbnailSizeDp.px
|
||||
|
||||
ItemsPage(
|
||||
stateSaver = innertubeItemsPageSaver(InnertubePlaylistItemListSaver),
|
||||
tag = "searchResults/$query/${if (tabIndex == 4) "playlists" else "featured"}",
|
||||
itemsPageProvider = { continuation ->
|
||||
if (continuation == null) {
|
||||
val filter = if (tabIndex == 4) {
|
||||
|
|
|
@ -5,7 +5,10 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
@ -16,19 +19,19 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.enums.ColorPaletteMode
|
||||
import it.vfsfitvnm.vimusic.enums.ColorPaletteName
|
||||
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.utils.applyFontPaddingKey
|
||||
import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey
|
||||
import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid13
|
||||
import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey
|
||||
import it.vfsfitvnm.vimusic.utils.useSystemFontKey
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
|
@ -41,6 +44,8 @@ fun AppearanceSettings() {
|
|||
thumbnailRoundnessKey,
|
||||
ThumbnailRoundness.Light
|
||||
)
|
||||
var useSystemFont by rememberPreference(useSystemFontKey, false)
|
||||
var applyFontPadding by rememberPreference(applyFontPaddingKey, false)
|
||||
var isShowingThumbnailInLockscreen by rememberPreference(
|
||||
isShowingThumbnailInLockscreenKey,
|
||||
false
|
||||
|
@ -94,13 +99,33 @@ fun AppearanceSettings() {
|
|||
|
||||
SettingsGroupSpacer()
|
||||
|
||||
SettingsEntryGroupText(title = "LOCKSCREEN")
|
||||
SettingsEntryGroupText(title = "TEXT")
|
||||
|
||||
SwitchSettingEntry(
|
||||
title = "Show song cover",
|
||||
text = "Use the playing song cover as the lockscreen wallpaper",
|
||||
isChecked = isShowingThumbnailInLockscreen,
|
||||
onCheckedChange = { isShowingThumbnailInLockscreen = it }
|
||||
title = "Use system font",
|
||||
text = "Use the font applied by the system",
|
||||
isChecked = useSystemFont,
|
||||
onCheckedChange = { useSystemFont = it }
|
||||
)
|
||||
|
||||
SwitchSettingEntry(
|
||||
title = "Apply font padding",
|
||||
text = "Add spacing around texts",
|
||||
isChecked = applyFontPadding,
|
||||
onCheckedChange = { applyFontPadding = it }
|
||||
)
|
||||
|
||||
if (!isAtLeastAndroid13) {
|
||||
SettingsGroupSpacer()
|
||||
|
||||
SettingsEntryGroupText(title = "LOCKSCREEN")
|
||||
|
||||
SwitchSettingEntry(
|
||||
title = "Show song cover",
|
||||
text = "Use the playing song cover as the lockscreen wallpaper",
|
||||
isChecked = isShowingThumbnailInLockscreen,
|
||||
onCheckedChange = { isShowingThumbnailInLockscreen = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,26 +1,27 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.settings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.saveable.autoSaver
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.checkpoint
|
||||
import it.vfsfitvnm.vimusic.internal
|
||||
import it.vfsfitvnm.vimusic.path
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
|
@ -28,15 +29,13 @@ import it.vfsfitvnm.vimusic.service.PlayerService
|
|||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.utils.intent
|
||||
import it.vfsfitvnm.vimusic.utils.produceSaveableState
|
||||
import it.vfsfitvnm.vimusic.utils.toast
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlin.system.exitProcess
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
|
@ -44,19 +43,16 @@ fun DatabaseSettings() {
|
|||
val context = LocalContext.current
|
||||
val (colorPalette) = LocalAppearance.current
|
||||
|
||||
val queriesCount by produceSaveableState(initialValue = 0, stateSaver = autoSaver()) {
|
||||
Database.queriesCount()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.distinctUntilChanged()
|
||||
.collect { value = it }
|
||||
}
|
||||
val eventsCount by remember {
|
||||
Database.eventsCount().distinctUntilChanged()
|
||||
}.collectAsState(initial = 0)
|
||||
|
||||
val backupLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.sqlite3")) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
|
||||
query {
|
||||
Database.internal.checkpoint()
|
||||
Database.checkpoint()
|
||||
|
||||
context.applicationContext.contentResolver.openOutputStream(uri)
|
||||
?.use { outputStream ->
|
||||
|
@ -72,7 +68,7 @@ fun DatabaseSettings() {
|
|||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
|
||||
query {
|
||||
Database.internal.checkpoint()
|
||||
Database.checkpoint()
|
||||
Database.internal.close()
|
||||
|
||||
context.applicationContext.contentResolver.openInputStream(uri)
|
||||
|
@ -87,7 +83,6 @@ fun DatabaseSettings() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background0)
|
||||
|
@ -101,21 +96,17 @@ fun DatabaseSettings() {
|
|||
) {
|
||||
Header(title = "Database")
|
||||
|
||||
SettingsEntryGroupText(title = "SEARCH HISTORY")
|
||||
SettingsEntryGroupText(title = "CLEANUP")
|
||||
|
||||
SettingsEntry(
|
||||
title = "Clear search history",
|
||||
text = if (queriesCount > 0) {
|
||||
"Delete $queriesCount search queries"
|
||||
title = "Reset quick picks",
|
||||
text = if (eventsCount > 0) {
|
||||
"Delete $eventsCount playback events"
|
||||
} else {
|
||||
"History is empty"
|
||||
"Quick picks are cleared"
|
||||
},
|
||||
isEnabled = queriesCount > 0,
|
||||
onClick = {
|
||||
query {
|
||||
Database.clearQueries()
|
||||
}
|
||||
}
|
||||
isEnabled = eventsCount > 0,
|
||||
onClick = { query(Database::clearEvents) }
|
||||
)
|
||||
|
||||
SettingsGroupSpacer()
|
||||
|
@ -130,7 +121,12 @@ fun DatabaseSettings() {
|
|||
onClick = {
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
val dateFormat = SimpleDateFormat("yyyyMMddHHmmss")
|
||||
backupLauncher.launch("vimusic_${dateFormat.format(Date())}.db")
|
||||
|
||||
try {
|
||||
backupLauncher.launch("vimusic_${dateFormat.format(Date())}.db")
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
context.toast("Couldn't find an application to create documents")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -144,13 +140,17 @@ fun DatabaseSettings() {
|
|||
title = "Restore",
|
||||
text = "Import the database from the external storage",
|
||||
onClick = {
|
||||
restoreLauncher.launch(
|
||||
arrayOf(
|
||||
"application/x-sqlite3",
|
||||
"application/vnd.sqlite3",
|
||||
"application/octet-stream"
|
||||
try {
|
||||
restoreLauncher.launch(
|
||||
arrayOf(
|
||||
"application/vnd.sqlite3",
|
||||
"application/x-sqlite3",
|
||||
"application/octet-stream"
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
context.toast("Couldn't find an application to open documents")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,43 +1,75 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.settings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SnapshotMutationPolicy
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.service.PlayerMediaBrowserService
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid12
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6
|
||||
import it.vfsfitvnm.vimusic.utils.isIgnoringBatteryOptimizations
|
||||
import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey
|
||||
import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.toast
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun OtherSettings() {
|
||||
val context = LocalContext.current
|
||||
val (colorPalette) = LocalAppearance.current
|
||||
|
||||
var isAndroidAutoEnabled by remember {
|
||||
val component = ComponentName(context, PlayerMediaBrowserService::class.java)
|
||||
val disabledFlag = PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
val enabledFlag = PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
|
||||
mutableStateOf(
|
||||
value = context.packageManager.getComponentEnabledSetting(component) == enabledFlag,
|
||||
policy = object : SnapshotMutationPolicy<Boolean> {
|
||||
override fun equivalent(a: Boolean, b: Boolean): Boolean {
|
||||
context.packageManager.setComponentEnabledSetting(
|
||||
component,
|
||||
if (b) enabledFlag else disabledFlag,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
return a == b
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var isInvincibilityEnabled by rememberPreference(isInvincibilityEnabledKey, false)
|
||||
|
||||
var isIgnoringBatteryOptimizations by remember {
|
||||
|
@ -49,6 +81,12 @@ fun OtherSettings() {
|
|||
isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations
|
||||
}
|
||||
|
||||
var pauseSearchHistory by rememberPreference(pauseSearchHistoryKey, false)
|
||||
|
||||
val queriesCount by remember {
|
||||
Database.queriesCount().distinctUntilChanged()
|
||||
}.collectAsState(initial = 0)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background0)
|
||||
|
@ -62,11 +100,46 @@ fun OtherSettings() {
|
|||
) {
|
||||
Header(title = "Other")
|
||||
|
||||
SettingsEntryGroupText(title = "ANDROID AUTO")
|
||||
|
||||
SettingsDescription(text = "Remember to enable \"Unknown sources\" in the Developer Settings of Android Auto.")
|
||||
|
||||
SwitchSettingEntry(
|
||||
title = "Android Auto",
|
||||
text = "Enable Android Auto support",
|
||||
isChecked = isAndroidAutoEnabled,
|
||||
onCheckedChange = { isAndroidAutoEnabled = it }
|
||||
)
|
||||
|
||||
SettingsGroupSpacer()
|
||||
|
||||
SettingsEntryGroupText(title = "SEARCH HISTORY")
|
||||
|
||||
SwitchSettingEntry(
|
||||
title = "Pause search history",
|
||||
text = "Neither save new searched queries nor show history",
|
||||
isChecked = pauseSearchHistory,
|
||||
onCheckedChange = { pauseSearchHistory = it }
|
||||
)
|
||||
|
||||
SettingsEntry(
|
||||
title = "Clear search history",
|
||||
text = if (queriesCount > 0) {
|
||||
"Delete $queriesCount search queries"
|
||||
} else {
|
||||
"History is empty"
|
||||
},
|
||||
isEnabled = queriesCount > 0,
|
||||
onClick = { query(Database::clearQueries) }
|
||||
)
|
||||
|
||||
SettingsGroupSpacer()
|
||||
|
||||
SettingsEntryGroupText(title = "SERVICE LIFETIME")
|
||||
|
||||
SettingsDescription(text = "If battery optimizations are applied, the playback notification can suddenly disappear when paused.")
|
||||
ImportantSettingsDescription(text = "If battery optimizations are applied, the playback notification can suddenly disappear when paused.")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (isAtLeastAndroid12) {
|
||||
SettingsDescription(text = "Since Android 12, disabling battery optimizations is required for the \"Invincible service\" option to take effect.")
|
||||
}
|
||||
|
||||
|
@ -79,28 +152,21 @@ fun OtherSettings() {
|
|||
"Disable background restrictions"
|
||||
},
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return@SettingsEntry
|
||||
if (!isAtLeastAndroid6) return@SettingsEntry
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
val intent =
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
activityResultLauncher.launch(intent)
|
||||
} else {
|
||||
val fallbackIntent =
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
|
||||
if (fallbackIntent.resolveActivity(context.packageManager) != null) {
|
||||
activityResultLauncher.launch(fallbackIntent)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Couldn't find battery optimization settings, please whitelist ViMusic manually",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
try {
|
||||
activityResultLauncher.launch(
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
try {
|
||||
activityResultLauncher.launch(
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
context.toast("Couldn't find battery optimization settings, please whitelist ViMusic manually")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.settings
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
@ -18,15 +21,15 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.only
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6
|
||||
import it.vfsfitvnm.vimusic.utils.persistentQueueKey
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.resumePlaybackWhenDeviceConnectedKey
|
||||
import it.vfsfitvnm.vimusic.utils.skipSilenceKey
|
||||
import it.vfsfitvnm.vimusic.utils.toast
|
||||
import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
|
@ -37,12 +40,15 @@ fun PlayerSettings() {
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
|
||||
var persistentQueue by rememberPreference(persistentQueueKey, false)
|
||||
var resumePlaybackWhenDeviceConnected by rememberPreference(
|
||||
resumePlaybackWhenDeviceConnectedKey,
|
||||
false
|
||||
)
|
||||
var skipSilence by rememberPreference(skipSilenceKey, false)
|
||||
var volumeNormalization by rememberPreference(volumeNormalizationKey, false)
|
||||
|
||||
val activityResultLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
}
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
@ -68,6 +74,17 @@ fun PlayerSettings() {
|
|||
}
|
||||
)
|
||||
|
||||
if (isAtLeastAndroid6) {
|
||||
SwitchSettingEntry(
|
||||
title = "Resume playback",
|
||||
text = "When a wired or bluetooth device is connected",
|
||||
isChecked = resumePlaybackWhenDeviceConnected,
|
||||
onCheckedChange = {
|
||||
resumePlaybackWhenDeviceConnected = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroupSpacer()
|
||||
|
||||
SettingsEntryGroupText(title = "AUDIO")
|
||||
|
@ -83,7 +100,7 @@ fun PlayerSettings() {
|
|||
|
||||
SwitchSettingEntry(
|
||||
title = "Loudness normalization",
|
||||
text = "Lower the volume to a standard level",
|
||||
text = "Adjust the volume to a fixed level",
|
||||
isChecked = volumeNormalization,
|
||||
onCheckedChange = {
|
||||
volumeNormalization = it
|
||||
|
@ -94,24 +111,16 @@ fun PlayerSettings() {
|
|||
title = "Equalizer",
|
||||
text = "Interact with the system equalizer",
|
||||
onClick = {
|
||||
val intent =
|
||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
|
||||
putExtra(
|
||||
AudioEffect.EXTRA_AUDIO_SESSION,
|
||||
binder?.player?.audioSessionId
|
||||
)
|
||||
putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
||||
putExtra(
|
||||
AudioEffect.EXTRA_CONTENT_TYPE,
|
||||
AudioEffect.CONTENT_TYPE_MUSIC
|
||||
)
|
||||
}
|
||||
val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
|
||||
putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder?.player?.audioSessionId)
|
||||
putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
||||
putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||
}
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
try {
|
||||
activityResultLauncher.launch(intent)
|
||||
} else {
|
||||
Toast.makeText(context, "No equalizer app found!", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
context.toast("Couldn't find an application to equalize audio")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,26 +1,37 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.settings
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.route.*
|
||||
import it.vfsfitvnm.compose.routing.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Switch
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.ValueSelectorDialog
|
||||
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
|
||||
import it.vfsfitvnm.vimusic.ui.screens.settings.*
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
|
|
|
@ -4,46 +4,28 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class Appearance(
|
||||
val colorPalette: ColorPalette,
|
||||
val typography: Typography = typographyOf(colorPalette.text),
|
||||
val thumbnailShape: Shape
|
||||
val typography: Typography,
|
||||
val thumbnailShape: Shape,
|
||||
) {
|
||||
companion object : Saver<Appearance, List<Any>> {
|
||||
override fun restore(value: List<Any>) = Appearance(
|
||||
colorPalette = ColorPalette(
|
||||
background0 = Color((value[0] as Long).toULong()),
|
||||
background1 = Color((value[1] as Long).toULong()),
|
||||
background2 = Color((value[2] as Long).toULong()),
|
||||
accent = Color((value[3] as Long).toULong()),
|
||||
onAccent = Color((value[4] as Long).toULong()),
|
||||
red = Color((value[5] as Long).toULong()),
|
||||
blue = Color((value[6] as Long).toULong()),
|
||||
text = Color((value[7] as Long).toULong()),
|
||||
textSecondary = Color((value[8] as Long).toULong()),
|
||||
textDisabled = Color((value[9] as Long).toULong()),
|
||||
isDark = value[10] as Boolean
|
||||
),
|
||||
thumbnailShape = RoundedCornerShape((value[11] as Int).dp)
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun restore(value: List<Any>): Appearance {
|
||||
return Appearance(
|
||||
colorPalette = ColorPalette.restore(value[0] as List<Any>),
|
||||
typography = Typography.restore(value[1] as List<Any>),
|
||||
thumbnailShape = RoundedCornerShape((value[2] as Int).dp)
|
||||
)
|
||||
}
|
||||
|
||||
override fun SaverScope.save(value: Appearance) =
|
||||
listOf(
|
||||
value.colorPalette.background0.value.toLong(),
|
||||
value.colorPalette.background1.value.toLong(),
|
||||
value.colorPalette.background2.value.toLong(),
|
||||
value.colorPalette.accent.value.toLong(),
|
||||
value.colorPalette.onAccent.value.toLong(),
|
||||
value.colorPalette.red.value.toLong(),
|
||||
value.colorPalette.blue.value.toLong(),
|
||||
value.colorPalette.text.value.toLong(),
|
||||
value.colorPalette.textSecondary.value.toLong(),
|
||||
value.colorPalette.textDisabled.value.toLong(),
|
||||
value.colorPalette.isDark,
|
||||
with (ColorPalette.Companion) { save(value.colorPalette) },
|
||||
with (Typography.Companion) { save(value.typography) },
|
||||
when (value.thumbnailShape) {
|
||||
RoundedCornerShape(2.dp) -> 2
|
||||
RoundedCornerShape(4.dp) -> 4
|
||||
|
|
|
@ -2,7 +2,11 @@ package it.vfsfitvnm.vimusic.ui.styling
|
|||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.palette.graphics.Palette
|
||||
import it.vfsfitvnm.vimusic.enums.ColorPaletteMode
|
||||
import it.vfsfitvnm.vimusic.enums.ColorPaletteName
|
||||
|
@ -20,7 +24,30 @@ data class ColorPalette(
|
|||
val textSecondary: Color,
|
||||
val textDisabled: Color,
|
||||
val isDark: Boolean
|
||||
)
|
||||
) {
|
||||
companion object : Saver<ColorPalette, List<Any>> {
|
||||
override fun restore(value: List<Any>) = when (val accent = value[0] as Int) {
|
||||
0 -> DefaultDarkColorPalette
|
||||
1 -> DefaultLightColorPalette
|
||||
2 -> PureBlackColorPalette
|
||||
else -> dynamicColorPaletteOf(
|
||||
FloatArray(3).apply { ColorUtils.colorToHSL(accent, this) },
|
||||
value[1] as Boolean
|
||||
)
|
||||
}
|
||||
|
||||
override fun SaverScope.save(value: ColorPalette) =
|
||||
listOf(
|
||||
when {
|
||||
value === DefaultDarkColorPalette -> 0
|
||||
value === DefaultLightColorPalette -> 1
|
||||
value === PureBlackColorPalette -> 2
|
||||
else -> value.accent.toArgb()
|
||||
},
|
||||
value.isDark
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val DefaultDarkColorPalette = ColorPalette(
|
||||
background0 = Color(0xff16171d),
|
||||
|
@ -100,7 +127,7 @@ fun dynamicColorPaletteOf(bitmap: Bitmap, isDark: Boolean): ColorPalette? {
|
|||
}
|
||||
}
|
||||
|
||||
private fun dynamicColorPaletteOf(hsl: FloatArray, isDark: Boolean): ColorPalette {
|
||||
fun dynamicColorPaletteOf(hsl: FloatArray, isDark: Boolean): ColorPalette {
|
||||
return colorPaletteOf(ColorPaletteName.Dynamic, if (isDark) ColorPaletteMode.Dark else ColorPaletteMode.Light, false).copy(
|
||||
background0 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.1f), if (isDark) 0.10f else 0.925f),
|
||||
background1 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.3f), if (isDark) 0.15f else 0.90f),
|
||||
|
@ -113,26 +140,33 @@ private fun dynamicColorPaletteOf(hsl: FloatArray, isDark: Boolean): ColorPalett
|
|||
}
|
||||
|
||||
inline val ColorPalette.collapsedPlayerProgressBar: Color
|
||||
get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this == PureBlackColorPalette) {
|
||||
get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) {
|
||||
text
|
||||
} else {
|
||||
accent
|
||||
}
|
||||
|
||||
inline val ColorPalette.favoritesIcon: Color
|
||||
get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this == PureBlackColorPalette) {
|
||||
get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) {
|
||||
red
|
||||
} else {
|
||||
accent
|
||||
}
|
||||
|
||||
inline val ColorPalette.shimmer: Color
|
||||
get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this == PureBlackColorPalette) {
|
||||
get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) {
|
||||
Color(0xff838383)
|
||||
} else {
|
||||
accent
|
||||
}
|
||||
|
||||
inline val ColorPalette.primaryButton: Color
|
||||
get() = if (this === PureBlackColorPalette) {
|
||||
Color(0xFF272727)
|
||||
} else {
|
||||
background2
|
||||
}
|
||||
|
||||
inline val ColorPalette.overlay: Color
|
||||
get() = PureBlackColorPalette.background0.copy(alpha = 0.75f)
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ object Dimensions {
|
|||
val navigationRailWidth = 64.dp
|
||||
val navigationRailWidthLandscape = 128.dp
|
||||
val navigationRailIconOffset = 6.dp
|
||||
val headerHeight = 128.dp
|
||||
val headerHeight = 140.dp
|
||||
|
||||
object thumbnails {
|
||||
val album = 128.dp
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package it.vfsfitvnm.vimusic.ui.styling
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
@ -18,35 +20,63 @@ data class Typography(
|
|||
val m: TextStyle,
|
||||
val l: TextStyle,
|
||||
val xxl: TextStyle,
|
||||
)
|
||||
) {
|
||||
fun copy(color: Color) = Typography(
|
||||
xxs = xxs.copy(color = color),
|
||||
xs = xs.copy(color = color),
|
||||
s = s.copy(color = color),
|
||||
m = m.copy(color = color),
|
||||
l = l.copy(color = color),
|
||||
xxl = xxl.copy(color = color)
|
||||
)
|
||||
|
||||
fun typographyOf(color: Color): Typography {
|
||||
companion object : Saver<Typography, List<Any>> {
|
||||
override fun restore(value: List<Any>) = typographyOf(
|
||||
Color((value[0] as Long).toULong()),
|
||||
value[1] as Boolean,
|
||||
value[2] as Boolean
|
||||
)
|
||||
|
||||
override fun SaverScope.save(value: Typography) =
|
||||
listOf(
|
||||
value.xxs.color.value.toLong(),
|
||||
value.xxs.fontFamily == FontFamily.Default,
|
||||
value.xxs.platformStyle?.paragraphStyle?.includeFontPadding ?: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun typographyOf(color: Color, useSystemFont: Boolean, applyFontPadding: Boolean): Typography {
|
||||
val textStyle = TextStyle(
|
||||
fontFamily = FontFamily(
|
||||
Font(
|
||||
resId = R.font.poppins_w300,
|
||||
weight = FontWeight.Light
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w400,
|
||||
weight = FontWeight.Normal
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w500,
|
||||
weight = FontWeight.Medium
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w600,
|
||||
weight = FontWeight.SemiBold
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w700,
|
||||
weight = FontWeight.Bold
|
||||
),
|
||||
),
|
||||
fontFamily = if (useSystemFont) {
|
||||
FontFamily.Default
|
||||
} else {
|
||||
FontFamily(
|
||||
Font(
|
||||
resId = R.font.poppins_w300,
|
||||
weight = FontWeight.Light
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w400,
|
||||
weight = FontWeight.Normal
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w500,
|
||||
weight = FontWeight.Medium
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w600,
|
||||
weight = FontWeight.SemiBold
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w700,
|
||||
weight = FontWeight.Bold
|
||||
),
|
||||
)
|
||||
},
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = color,
|
||||
platformStyle = @Suppress("DEPRECATION") (PlatformTextStyle(includeFontPadding = false))
|
||||
platformStyle = @Suppress("DEPRECATION") (PlatformTextStyle(includeFontPadding = applyFontPadding))
|
||||
)
|
||||
|
||||
return Typography(
|
||||
|
@ -55,6 +85,6 @@ fun typographyOf(color: Color): Typography {
|
|||
s = textStyle.copy(fontSize = 16.sp),
|
||||
m = textStyle.copy(fontSize = 18.sp),
|
||||
l = textStyle.copy(fontSize = 20.sp),
|
||||
xxl = textStyle.copy(fontSize = 32.sp),
|
||||
xxl = textStyle.copy(fontSize = 32.sp)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,8 +5,8 @@ import android.app.PendingIntent
|
|||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
inline fun <reified T> Context.intent(): Intent =
|
||||
|
@ -14,7 +14,7 @@ inline fun <reified T> Context.intent(): Intent =
|
|||
|
||||
inline fun <reified T : BroadcastReceiver> Context.broadCastPendingIntent(
|
||||
requestCode: Int = 0,
|
||||
flags: Int = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0,
|
||||
flags: Int = if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0,
|
||||
): PendingIntent =
|
||||
PendingIntent.getBroadcast(this, requestCode, intent<T>(), flags)
|
||||
|
||||
|
@ -27,12 +27,14 @@ inline fun <reified T : Activity> Context.activityPendingIntent(
|
|||
this,
|
||||
requestCode,
|
||||
intent<T>().apply(block),
|
||||
(if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) or flags
|
||||
(if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) or flags
|
||||
)
|
||||
|
||||
val Context.isIgnoringBatteryOptimizations: Boolean
|
||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
get() = if (isAtLeastAndroid6) {
|
||||
getSystemService<PowerManager>()?.isIgnoringBatteryOptimizations(packageName) ?: true
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
fun Context.toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
|
|
|
@ -7,7 +7,6 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
|
@ -28,7 +27,7 @@ abstract class InvincibleService : Service() {
|
|||
private var invincibility: Invincibility? = null
|
||||
|
||||
private val isAllowedToStartForegroundServices: Boolean
|
||||
get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || isIgnoringBatteryOptimizations
|
||||
get() = !isAtLeastAndroid12 || isIgnoringBatteryOptimizations
|
||||
|
||||
override fun onBind(intent: Intent?): Binder? {
|
||||
invincibility?.stop()
|
||||
|
|
|
@ -5,6 +5,9 @@ import androidx.media3.common.MediaItem
|
|||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
|
||||
val Player.currentWindow: Timeline.Window?
|
||||
get() = if (mediaItemCount == 0) null else currentTimeline.getWindow(currentMediaItemIndex, Timeline.Window())
|
||||
|
||||
val Timeline.mediaItems: List<MediaItem>
|
||||
get() = List(windowCount) {
|
||||
getWindow(it, Timeline.Window()).mediaItem
|
||||
|
@ -43,9 +46,8 @@ fun Player.forcePlay(mediaItem: MediaItem) {
|
|||
fun Player.forcePlayAtIndex(mediaItems: List<MediaItem>, mediaItemIndex: Int) {
|
||||
if (mediaItems.isEmpty()) return
|
||||
|
||||
setMediaItems(mediaItems, true)
|
||||
setMediaItems(mediaItems, mediaItemIndex, C.TIME_UNSET)
|
||||
playWhenReady = true
|
||||
seekToDefaultPosition(mediaItemIndex)
|
||||
prepare()
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue