Compare commits

...

99 commits

Author SHA1 Message Date
vfsfitvnm
6e83b8b83d
Fix grammar mistake in menu
'more of' -> 'more from'
2023-09-23 19:10:02 +02:00
Rohan Temgire
a0cab24483 issue #930 fixed - language not used properly 2023-03-20 23:50:59 +05:30
vfsfitvnm
964fa42a0f Update issue template 2022-12-20 12:46:23 +01:00
vfsfitvnm
ac6a68bb16
Update bug_report.yaml 2022-12-20 11:45:13 +00:00
vfsfitvnm
c5d2209359
Update bug_report.yaml 2022-12-20 11:43:29 +00:00
vfsfitvnm
ad1faa5d36 Update issue template 2022-12-20 12:39:22 +01:00
vfsfitvnm
29e3d00c88 Update media3 2022-12-09 15:00:08 +01:00
vfsfitvnm
0c4ae81406 Fix #724 2022-12-09 14:59:48 +01:00
vfsfitvnm
69e6d52fcf Resume playback only after seeking at the tapped queued song 2022-12-09 14:42:11 +01:00
vfsfitvnm
dcab9c6ad2 Fix #630 2022-11-13 17:00:16 +01:00
vfsfitvnm
3e94e63471 Update fastlane metadata 2022-11-04 12:21:06 +01:00
vfsfitvnm
8769922742 Bump version to 0.5.4 2022-11-04 12:18:15 +01:00
vfsfitvnm
75601215d5 Remove toast error in maybeNormalizeVolume 2022-11-04 12:17:47 +01:00
vfsfitvnm
feceffe314 Fix #499 2022-11-03 07:58:46 +01:00
vfsfitvnm
ac3019ef25 Fix crash that occurred when inserting a Format (foreign key constraint) 2022-11-02 10:59:27 +01:00
vfsfitvnm
fab56dd302 Fix android.yml 2022-11-01 13:23:51 +01:00
vfsfitvnm
11426d6803 Run build action on every branch 2022-11-01 13:21:49 +01:00
vfsfitvnm
da41ae7e45 Tweak media session callbacks code 2022-11-01 10:36:05 +01:00
vfsfitvnm
490d2d686d Remove println 2022-11-01 08:49:32 +01:00
vfsfitvnm
4fbadc7c1e Implement more media session callbacks 2022-11-01 08:19:44 +01:00
vfsfitvnm
5c056a3b84 Show itag instead of volume in stats for nerds 2022-10-29 21:11:58 +02:00
vfsfitvnm
accbfc47d0 Move song lyrics to a separate database entity 2022-10-29 19:47:39 +02:00
vfsfitvnm
33221746fc Drop DetailedSong model 2022-10-29 16:43:17 +02:00
vfsfitvnm
f3995b8c46 Fix #536 2022-10-29 08:27:41 +02:00
vfsfitvnm
a43b6f10e3 Improve loudness normalization (#410) 2022-10-27 18:28:00 +02:00
vfsfitvnm
5878b0ebdd Update fastlane metadata 2022-10-26 14:15:48 +02:00
vfsfitvnm
82b9b9cc08 Bump version to 0.5.3 2022-10-26 14:15:33 +02:00
vfsfitvnm
055ad86f2c Rename internal compose packages 2022-10-26 13:36:09 +02:00
vfsfitvnm
ed8eb7cf8a Attempt fix #401 2022-10-26 13:08:45 +02:00
vfsfitvnm
f4a55ff2ad Clear SongAlbumMaps before upserting new album data 2022-10-26 12:40:13 +02:00
vfsfitvnm
2d632c19de Keep screen on when showing synchronized lyrics (#521) 2022-10-26 11:56:09 +02:00
vfsfitvnm
5de8221f4a Fix #518 2022-10-26 11:44:24 +02:00
vfsfitvnm
1221ddf424 Tweak SDK version check and start activity for result related code 2022-10-23 21:56:14 +02:00
vfsfitvnm
1bdc120430 Add queue items to media session (#499) 2022-10-23 18:10:01 +02:00
vfsfitvnm
14fcd0788c Add TV banner (#379) 2022-10-23 16:57:50 +02:00
vfsfitvnm
fd54a4c483 Add option to resume playback when a device is connected (#497) 2022-10-23 15:36:54 +02:00
vfsfitvnm
bb4c9ada73 Fix #488 2022-10-22 08:11:25 +02:00
vfsfitvnm
9bd227e807 Set targetSdk to 33 (#267) 2022-10-19 18:17:49 +02:00
vfsfitvnm
a8431e13ee Display more LazyHorizontalGrid items in landscape mode (#469) 2022-10-19 15:49:58 +02:00
vfsfitvnm
d1f4aa4577 Add option to use system font (#182) 2022-10-19 15:23:33 +02:00
vfsfitvnm
fc569ea5f9 Reintroduce "loop none" option (#276) 2022-10-19 14:22:34 +02:00
Bnyro
720c73d9fb
Add support for monochrome icon (A13+) (#267) 2022-10-19 11:59:07 +00:00
vfsfitvnm
5d37b831a3 Update fastlane metadata 2022-10-18 10:40:42 +02:00
vfsfitvnm
913f69293a Bump version to 0.5.2 2022-10-18 10:37:29 +02:00
vfsfitvnm
867dfb09d0 Fix song placeholder padding in queue 2022-10-17 17:42:50 +02:00
vfsfitvnm
fb0e8af507 Merge remote-tracking branch 'origin/master' 2022-10-17 17:31:29 +02:00
arfanmrafeek
d20e35a547
Correct link in README 2022-10-17 15:31:04 +00:00
vfsfitvnm
edf82862ea Update README 2022-10-17 17:19:27 +02:00
vfsfitvnm
b8905a04e7 Improve sleep timer menu entry UI 2022-10-17 17:19:27 +02:00
vfsfitvnm
192263d95b Make format automatically update when stats for nerds are displayed 2022-10-17 14:58:48 +02:00
vfsfitvnm
43fda751b6 Add reminder when enabling Android Auto support (#47) 2022-10-17 12:06:13 +02:00
vfsfitvnm
6ebb5dfc65 Tweak player code 2022-10-17 12:06:13 +02:00
vfsfitvnm
7869f1a388 Tweak code 2022-10-16 17:43:29 +02:00
Bnyro
6dd88b796e
Add an option to pause the search history (#324, #455) 2022-10-16 15:15:32 +00:00
vfsfitvnm
9aa051068b Reduce share icon size in MediaItemMenu 2022-10-16 16:50:08 +02:00
vfsfitvnm
58720f8cb9 Rename youtubemusic package to innertube 2022-10-16 15:31:27 +02:00
vfsfitvnm
e1f85b1d6a Avoid UI update when deleting a playlist 2022-10-16 15:12:32 +02:00
vfsfitvnm
8bd4e0e715 Fix #447 2022-10-16 14:36:37 +02:00
vfsfitvnm
1e6a6dc6de Tweak AnimatedVisibility related code in Queue 2022-10-15 20:08:32 +02:00
vfsfitvnm
6c98cc1496 Make share song feature easier to discover (#445) 2022-10-15 18:20:23 +02:00
vfsfitvnm
df36075c3e Tweak code 2022-10-15 17:07:34 +02:00
vfsfitvnm
9a5ea69de4 Fix #438 2022-10-15 13:44:56 +02:00
vfsfitvnm
28e69756d9 Fix typo 2022-10-15 10:06:57 +02:00
vfsfitvnm
149d0a6ce3 Remove println 2022-10-15 09:19:52 +02:00
vfsfitvnm
058b539ae4 Change modifiers order in NavigationRail 2022-10-14 19:17:16 +02:00
vfsfitvnm
0c3c56efd8 Update dependencies 2022-10-14 17:36:59 +02:00
vfsfitvnm
25fd43b98d Drop info field/column from Artist 2022-10-14 16:17:44 +02:00
vfsfitvnm
214136a13e Show artist description (#418) 2022-10-14 15:27:49 +02:00
vfsfitvnm
554dea3fba Add try/catch block when inserting an event to mitigate SQLITE_CONSTRAINT_FOREIGNKEY
Caused by ExoPlayer
2022-10-14 12:12:33 +02:00
vfsfitvnm
0de5330676 Fix #415 2022-10-13 20:10:42 +02:00
vfsfitvnm
92141f4f49 Fix #423 2022-10-13 19:48:27 +02:00
vfsfitvnm
7ed138ea51 Coerce loudnessDb in -10f,+10f 2022-10-13 13:54:38 +02:00
vfsfitvnm
c8d03a9c9d Update HeaderPlaceholder to reflect changes 2022-10-13 12:52:00 +02:00
vfsfitvnm
f318e71d20 Add option to clear trending song (#413) 2022-10-13 12:34:18 +02:00
vfsfitvnm
3194fe3387 Fix #414 2022-10-13 11:13:54 +02:00
vfsfitvnm
29878a6432 Fix #419 2022-10-13 11:08:22 +02:00
vfsfitvnm
83d559830f Fix thumbnail misalignment in ArtistLocalSongs 2022-10-13 10:38:28 +02:00
vfsfitvnm
91ec216679 Update fastlane metadata and README 2022-10-12 14:07:09 +02:00
vfsfitvnm
f63c9b1423 Bump version to 0.5.1 2022-10-12 13:57:16 +02:00
vfsfitvnm
38e46db354 Fix #408 2022-10-12 13:55:57 +02:00
vfsfitvnm
12f8b6fbcb Improve loudness normalization curve 2022-10-12 11:57:29 +02:00
vfsfitvnm
f726d3e934 Tweak PlayerService code 2022-10-11 17:24:56 +02:00
vfsfitvnm
39b2cc2239 Make collapsed player next button force seek 2022-10-11 17:20:43 +02:00
vfsfitvnm
5e5868bd3c Change database checkpoint procedure (#406) 2022-10-11 14:11:48 +02:00
vfsfitvnm
cfe5dc965f Make coil do not respect cache headers (possibly #59) 2022-10-11 12:37:20 +02:00
vfsfitvnm
493fc75345 Change android youtube client 2022-10-11 12:13:58 +02:00
vfsfitvnm
fa4fd276b3 Make PlayerMediaBrowserService be activated/deactivated 2022-10-11 10:00:47 +02:00
vfsfitvnm
34f4404329 Do not shuffle album songs in onPlayFromMediaId 2022-10-11 09:29:39 +02:00
vfsfitvnm
6d6a839c2d Fix player thumbnail not updating when changing playlist from android auto 2022-10-11 09:29:38 +02:00
vfsfitvnm
270986215c Complete android auto support (#47) 2022-10-11 09:29:38 +02:00
vfsfitvnm
6fb8e41a04 Rollback minSdk to 21 2022-10-11 09:29:30 +02:00
Slany
d0cffe466b Support android auto (#47) 2022-10-11 09:29:30 +02:00
vfsfitvnm
fae96d1114 Do not expand player when queue is restored (#396) 2022-10-10 11:48:58 +02:00
vfsfitvnm
ff611d792e Use the correct setMediaItems overload to make onPlaybackStatsReady behave correctly 2022-10-10 11:15:23 +02:00
vfsfitvnm
6d2b075720 Fix #399 2022-10-10 10:51:10 +02:00
vfsfitvnm
b9ecb0c669 Fix #387 2022-10-09 17:30:38 +02:00
vfsfitvnm
6b01fbb008 Add videoDetails field mask 2022-10-09 16:48:31 +02:00
vfsfitvnm
d49ac4fa13 Set userAgent to ANDROID client (#385) 2022-10-09 15:44:59 +02:00
vfsfitvnm
c00a079715 Try fix #385 2022-10-09 15:06:02 +02:00
192 changed files with 4058 additions and 2443 deletions

View file

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

@ -0,0 +1 @@
blank_issues_enabled: false

View file

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

View file

@ -1,10 +1,6 @@
name: CI
on:
push:
branches: ["master"]
pull_request:
branches: ["master"]
on: push
jobs:
build:

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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?,
)

View file

@ -18,5 +18,5 @@ data class PlaylistWithSongs(
entityColumn = "songId"
)
)
val songs: List<DetailedSong>
val songs: List<Song>
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
package it.vfsfitvnm.vimusic.savers
val InnertubeSongsPageSaver = innertubeItemsPageSaver(InnertubeSongItemListSaver)
val InnertubeAlbumsPageSaver = innertubeItemsPageSaver(InnertubeAlbumItemListSaver)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -59,8 +59,8 @@ inline fun NavigationRail(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(paddingValues)
) {
Box(
contentAlignment = Alignment.TopCenter,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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