Compare commits
25 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6e83b8b83d | ||
![]() |
a0cab24483 | ||
![]() |
964fa42a0f | ||
![]() |
ac6a68bb16 | ||
![]() |
c5d2209359 | ||
![]() |
ad1faa5d36 | ||
![]() |
29e3d00c88 | ||
![]() |
0c4ae81406 | ||
![]() |
69e6d52fcf | ||
![]() |
dcab9c6ad2 | ||
![]() |
3e94e63471 | ||
![]() |
8769922742 | ||
![]() |
75601215d5 | ||
![]() |
feceffe314 | ||
![]() |
ac3019ef25 | ||
![]() |
fab56dd302 | ||
![]() |
11426d6803 | ||
![]() |
da41ae7e45 | ||
![]() |
490d2d686d | ||
![]() |
4fbadc7c1e | ||
![]() |
5c056a3b84 | ||
![]() |
accbfc47d0 | ||
![]() |
33221746fc | ||
![]() |
f3995b8c46 | ||
![]() |
a43b6f10e3 |
35 changed files with 1099 additions and 445 deletions
12
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
12
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
@ -1,14 +1,12 @@
|
|||
name: Bug report
|
||||
description: Create a bug report to help us improve
|
||||
name: 🐛 Bug report
|
||||
description: Something isn't working, uh?
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**1- I am able to reproduce the bug with the [latest version](https://github.com/vfsfitvnm/vimusic/releases/latest).**
|
||||
**2- I've checked that there is no issue about this bug.**
|
||||
**3- This issue contains only one bug.**
|
||||
**4- The title of this issue accurately describes the bug.**
|
||||
## ⚠️ Make sure you are able to reproduce the bug with the [latest version](https://github.com/vfsfitvnm/vimusic/releases/latest).
|
||||
## ⚠️ Make sure there is no issue about this bug already.
|
||||
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
|
@ -65,7 +63,7 @@ body:
|
|||
attributes:
|
||||
label: ViMusic version
|
||||
placeholder: |
|
||||
Example: "0.1.2"
|
||||
Example: "0.5.4"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
34
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
34
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
@ -1,34 +0,0 @@
|
|||
name: Feature request
|
||||
description: Suggest an idea for ViMusic
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**1- I've checked that there is no other issue about this feature request.**
|
||||
**2- This issue contains only one feature request.**
|
||||
**3- The title of this issue accurately describes the feature request.**
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Feature description
|
||||
description: What feature you want the app to have? Provide detailed description about what it should look like or where it should be added.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why-is-the-feature-requested
|
||||
attributes:
|
||||
label: Why do you want this feature?
|
||||
description: Describe the problem or limitation that motivates you to want this feature to be added.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
6
.github/workflows/android.yml
vendored
6
.github/workflows/android.yml
vendored
|
@ -1,10 +1,6 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
@ -11,8 +11,8 @@ android {
|
|||
applicationId = "it.vfsfitvnm.vimusic"
|
||||
minSdk = 21
|
||||
targetSdk = 33
|
||||
versionCode = 19
|
||||
versionName = "0.5.3"
|
||||
versionCode = 20
|
||||
versionName = "0.5.4"
|
||||
}
|
||||
|
||||
splits {
|
||||
|
@ -88,7 +88,6 @@ dependencies {
|
|||
|
||||
implementation(libs.room)
|
||||
kapt(libs.room.compiler)
|
||||
annotationProcessor(libs.room.compiler)
|
||||
|
||||
implementation(projects.innertube)
|
||||
implementation(projects.kugou)
|
||||
|
|
672
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json
Normal file
672
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json
Normal file
|
@ -0,0 +1,672 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 23,
|
||||
"identityHash": "205c24811149a247279bcbfdc2d6c396",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Song",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistsText",
|
||||
"columnName": "artistsText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationText",
|
||||
"columnName": "durationText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "likedAt",
|
||||
"columnName": "likedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "totalPlayTimeMs",
|
||||
"columnName": "totalPlayTimeMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongPlaylistMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playlistId",
|
||||
"columnName": "playlistId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"playlistId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongPlaylistMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongPlaylistMap_playlistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"playlistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Playlist",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"playlistId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Playlist",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "browseId",
|
||||
"columnName": "browseId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Artist",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkedAt",
|
||||
"columnName": "bookmarkedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongArtistMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"artistId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongArtistMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongArtistMap_artistId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"artistId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Artist",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"artistId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Album",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnailUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorsText",
|
||||
"columnName": "authorsText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "shareUrl",
|
||||
"columnName": "shareUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarkedAt",
|
||||
"columnName": "bookmarkedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "SongAlbumMap",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId",
|
||||
"albumId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SongAlbumMap_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_SongAlbumMap_albumId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"albumId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "Album",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"albumId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "SearchQuery",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "query",
|
||||
"columnName": "query",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_SearchQuery_query",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"query"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "QueuedMediaItem",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mediaItem",
|
||||
"columnName": "mediaItem",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Format",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "itag",
|
||||
"columnName": "itag",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "bitrate",
|
||||
"columnName": "bitrate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentLength",
|
||||
"columnName": "contentLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "loudnessDb",
|
||||
"columnName": "loudnessDb",
|
||||
"affinity": "REAL",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Event",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playTime",
|
||||
"columnName": "playTime",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Event_songId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "Lyrics",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "songId",
|
||||
"columnName": "songId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fixed",
|
||||
"columnName": "fixed",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "synced",
|
||||
"columnName": "synced",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"songId"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Song",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"viewName": "SortedSongPlaylistMap",
|
||||
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position"
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '205c24811149a247279bcbfdc2d6c396')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -38,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
|
||||
|
@ -62,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()
|
||||
|
@ -109,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>
|
||||
|
@ -138,17 +139,8 @@ 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?>
|
||||
|
@ -187,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>>
|
||||
|
@ -281,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?>
|
||||
|
||||
@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 =
|
||||
|
@ -312,12 +303,18 @@ interface Database {
|
|||
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?>
|
||||
|
||||
@Query("SELECT COUNT (*) FROM Event")
|
||||
fun eventsCount(): Flow<Int>
|
||||
|
@ -406,6 +403,9 @@ interface Database {
|
|||
@Update
|
||||
fun update(playlist: Playlist)
|
||||
|
||||
@Upsert
|
||||
fun upsert(lyrics: Lyrics)
|
||||
|
||||
@Upsert
|
||||
fun upsert(album: Album, songAlbumMaps: List<SongAlbumMap>)
|
||||
|
||||
|
@ -445,11 +445,12 @@ interface Database {
|
|||
QueuedMediaItem::class,
|
||||
Format::class,
|
||||
Event::class,
|
||||
Lyrics::class,
|
||||
],
|
||||
views = [
|
||||
SortedSongPlaylistMap::class
|
||||
],
|
||||
version = 22,
|
||||
version = 23,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
|
@ -487,7 +488,8 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
|
|||
.addMigrations(
|
||||
From8To9Migration(),
|
||||
From10To11Migration(),
|
||||
From14To15Migration()
|
||||
From14To15Migration(),
|
||||
From22To23Migration()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
@ -614,6 +616,27 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
|
|||
|
||||
@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
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
|
||||
@Immutable
|
||||
open class DetailedSong(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artistsText: String? = null,
|
||||
val durationText: String?,
|
||||
val thumbnailUrl: String?,
|
||||
val totalPlayTimeMs: Long = 0,
|
||||
@Relation(
|
||||
entity = SongAlbumMap::class,
|
||||
entityColumn = "songId",
|
||||
parentColumn = "id",
|
||||
projection = ["albumId"]
|
||||
)
|
||||
val albumId: String?,
|
||||
@Relation(
|
||||
entity = Artist::class,
|
||||
entityColumn = "id",
|
||||
parentColumn = "id",
|
||||
associateBy = Junction(
|
||||
value = SongArtistMap::class,
|
||||
parentColumn = "songId",
|
||||
entityColumn = "artistId"
|
||||
),
|
||||
projection = ["id", "name"]
|
||||
)
|
||||
val artists: List<Info>?
|
||||
) {
|
||||
val formattedTotalPlayTime: String
|
||||
get() {
|
||||
val seconds = totalPlayTimeMs / 1000
|
||||
|
||||
val hours = seconds / 3600
|
||||
|
||||
return when {
|
||||
hours == 0L -> "${seconds / 60}m"
|
||||
hours < 24L -> "${hours}h"
|
||||
else -> "${hours / 24}d"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Relation
|
||||
|
||||
@Immutable
|
||||
class DetailedSongWithContentLength(
|
||||
id: String,
|
||||
title: String,
|
||||
artistsText: String? = null,
|
||||
durationText: String?,
|
||||
thumbnailUrl: String?,
|
||||
totalPlayTimeMs: Long = 0,
|
||||
albumId: String?,
|
||||
artists: List<Info>?,
|
||||
@Relation(
|
||||
entity = Format::class,
|
||||
entityColumn = "songId",
|
||||
parentColumn = "id",
|
||||
projection = ["contentLength"]
|
||||
)
|
||||
val contentLength: Long?
|
||||
) : DetailedSong(id, title, artistsText, durationText, thumbnailUrl, totalPlayTimeMs, albumId, artists)
|
23
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Lyrics.kt
Normal file
23
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Lyrics.kt
Normal file
|
@ -0,0 +1,23 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Immutable
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Song::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
]
|
||||
)
|
||||
class Lyrics(
|
||||
@PrimaryKey val songId: String,
|
||||
val fixed: String?,
|
||||
val synced: String?,
|
||||
)
|
|
@ -18,5 +18,5 @@ data class PlaylistWithSongs(
|
|||
entityColumn = "songId"
|
||||
)
|
||||
)
|
||||
val songs: List<DetailedSong>
|
||||
val songs: List<Song>
|
||||
)
|
||||
|
|
|
@ -12,11 +12,22 @@ data class Song(
|
|||
val artistsText: String? = null,
|
||||
val durationText: String?,
|
||||
val thumbnailUrl: String?,
|
||||
val lyrics: String? = null,
|
||||
val synchronizedLyrics: String? = null,
|
||||
val likedAt: Long? = null,
|
||||
val totalPlayTimeMs: Long = 0
|
||||
) {
|
||||
val formattedTotalPlayTime: String
|
||||
get() {
|
||||
val seconds = totalPlayTimeMs / 1000
|
||||
|
||||
val hours = seconds / 3600
|
||||
|
||||
return when {
|
||||
hours == 0L -> "${seconds / 60}m"
|
||||
hours < 24L -> "${hours}h"
|
||||
else -> "${hours / 24}d"
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLike(): Song {
|
||||
return copy(
|
||||
likedAt = if (likedAt == null) System.currentTimeMillis() else null
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Embedded
|
||||
|
||||
@Immutable
|
||||
data class SongWithContentLength(
|
||||
@Embedded val song: Song,
|
||||
val contentLength: Long?
|
||||
)
|
|
@ -20,8 +20,9 @@ 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.DetailedSong
|
||||
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
|
||||
|
@ -36,7 +37,7 @@ import kotlinx.coroutines.withContext
|
|||
|
||||
class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
private var lastSongs = emptyList<DetailedSong>()
|
||||
private var lastSongs = emptyList<Song>()
|
||||
|
||||
private var bound = false
|
||||
|
||||
|
@ -187,7 +188,7 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
|
|||
BrowserMediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
|
||||
private val DetailedSong.asBrowserMediaItem
|
||||
private val Song.asBrowserMediaItem
|
||||
inline get() = BrowserMediaItem(
|
||||
BrowserMediaDescription.Builder()
|
||||
.setMediaId(MediaId.forSong(id))
|
||||
|
@ -254,9 +255,10 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
|
|||
.first()
|
||||
.filter { song ->
|
||||
song.contentLength?.let {
|
||||
cache.isCached(song.id, 0, song.contentLength)
|
||||
cache.isCached(song.song.id, 0, it)
|
||||
} ?: false
|
||||
}
|
||||
.map(SongWithContentLength::song)
|
||||
.shuffled()
|
||||
|
||||
MediaId.playlists -> data
|
||||
|
@ -273,7 +275,7 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
|
|||
?.first()
|
||||
|
||||
else -> emptyList()
|
||||
}?.map(DetailedSong::asMediaItem) ?: return@launch
|
||||
}?.map(Song::asMediaItem) ?: return@launch
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
player.forcePlayAtIndex(mediaItems, index.coerceIn(0, mediaItems.size))
|
||||
|
|
|
@ -21,6 +21,7 @@ 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
|
||||
|
@ -112,9 +113,7 @@ 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.filterNotNull
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -130,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()
|
||||
|
@ -150,7 +152,6 @@ 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
|
||||
|
@ -158,6 +159,8 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
private var audioManager: AudioManager? = null
|
||||
private var audioDeviceCallback: AudioDeviceCallback? = null
|
||||
|
||||
private var loudnessEnhancer: LoudnessEnhancer? = null
|
||||
|
||||
private val binder = Binder()
|
||||
|
||||
private var isNotificationStarted = false
|
||||
|
@ -188,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)
|
||||
|
@ -284,6 +286,8 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
mediaSession.release()
|
||||
cache.release()
|
||||
|
||||
loudnessEnhancer?.release()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
@ -380,7 +384,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
.setSubtitle(mediaItem.mediaMetadata.artist)
|
||||
.setIconUri(mediaItem.mediaMetadata.artworkUri)
|
||||
.build(),
|
||||
index.toLong()
|
||||
(index + startIndex).toLong()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -456,30 +460,28 @@ 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.Main) {
|
||||
Database
|
||||
.loudnessDb(songId)
|
||||
.cancellable()
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { loudnessDb ->
|
||||
val x = loudnessDb.coerceIn(-10f, 10f)
|
||||
val x2 = x * x
|
||||
val x3 = x2 * x
|
||||
val x4 = x2 * x2
|
||||
|
||||
player.volume =
|
||||
0.0000452661f * x4 - 0.0000870966f * x3 - 0.00251095f * x2 - 0.0336928f * x + 0.427456f
|
||||
}
|
||||
Database.loudnessDb(songId).cancellable().collectLatest { loudnessDb ->
|
||||
try {
|
||||
loudnessEnhancer?.setTargetGain(-((loudnessDb ?: 0f) * 100).toInt() + 500)
|
||||
loudnessEnhancer?.enabled = true
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -622,11 +624,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||
persistentQueueKey -> isPersistentQueueEnabled =
|
||||
sharedPreferences.getBoolean(key, isPersistentQueueEnabled)
|
||||
|
||||
volumeNormalizationKey -> {
|
||||
isVolumeNormalizationEnabled =
|
||||
sharedPreferences.getBoolean(key, isVolumeNormalizationEnabled)
|
||||
maybeNormalizeVolume()
|
||||
}
|
||||
volumeNormalizationKey -> maybeNormalizeVolume()
|
||||
|
||||
resumePlaybackWhenDeviceConnectedKey -> maybeResumePlaybackWhenDeviceConnected()
|
||||
|
||||
|
@ -956,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() {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -25,6 +25,7 @@ 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
|
||||
|
@ -48,7 +49,7 @@ 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
|
||||
|
@ -69,15 +70,16 @@ import it.vfsfitvnm.vimusic.utils.formatAsDuration
|
|||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
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
|
||||
|
@ -115,7 +117,7 @@ fun InPlaylistMediaItemMenu(
|
|||
onDismiss: () -> Unit,
|
||||
playlistId: Long,
|
||||
positionInPlaylist: Int,
|
||||
song: DetailedSong,
|
||||
song: Song,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
NonQueuedMediaItemMenu(
|
||||
|
@ -276,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)
|
||||
|
@ -371,7 +401,8 @@ fun MediaItemMenu(
|
|||
.padding(end = 12.dp)
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailUrl = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx)?.toString(),
|
||||
thumbnailUrl = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx)
|
||||
?.toString(),
|
||||
title = mediaItem.mediaMetadata.title.toString(),
|
||||
authors = mediaItem.mediaMetadata.artist.toString(),
|
||||
duration = null,
|
||||
|
@ -601,7 +632,10 @@ fun MediaItemMenu(
|
|||
text = "${formatAsDuration(it)} left",
|
||||
style = typography.xxs.medium,
|
||||
modifier = modifier
|
||||
.background(color = colorPalette.background0, shape = RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
color = colorPalette.background0,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.animateContentSize()
|
||||
)
|
||||
|
@ -619,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)
|
||||
)
|
||||
|
@ -628,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",
|
||||
|
@ -641,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 ->
|
||||
|
|
|
@ -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
|
||||
|
@ -28,6 +27,7 @@ import it.vfsfitvnm.vimusic.utils.secondary
|
|||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
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,
|
||||
|
|
|
@ -27,7 +27,7 @@ import it.vfsfitvnm.vimusic.Database
|
|||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
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
|
||||
|
@ -59,7 +59,7 @@ fun AlbumSongs(
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
var songs by persistList<DetailedSong>("album/$browseId/songs")
|
||||
var songs by persistList<Song>("album/$browseId/songs")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Database.albumSongs(browseId).collect { songs = it }
|
||||
|
@ -89,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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -133,7 +133,7 @@ fun AlbumSongs(
|
|||
onClick = {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(
|
||||
songs.map(DetailedSong::asMediaItem),
|
||||
songs.map(Song::asMediaItem),
|
||||
index
|
||||
)
|
||||
}
|
||||
|
@ -162,7 +162,7 @@ fun AlbumSongs(
|
|||
if (songs.isNotEmpty()) {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.shuffled().map(DetailedSong::asMediaItem)
|
||||
songs.shuffled().map(Song::asMediaItem)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import it.vfsfitvnm.vimusic.Database
|
|||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
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
|
||||
|
@ -53,7 +53,7 @@ fun ArtistLocalSongs(
|
|||
val (colorPalette) = LocalAppearance.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
var songs by persist<List<DetailedSong>?>("artist/$browseId/localSongs")
|
||||
var songs by persist<List<Song>?>("artist/$browseId/localSongs")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Database.artistSongs(browseId).collect { songs = it }
|
||||
|
@ -84,7 +84,7 @@ fun ArtistLocalSongs(
|
|||
text = "Enqueue",
|
||||
enabled = !songs.isNullOrEmpty(),
|
||||
onClick = {
|
||||
binder?.player?.enqueue(songs!!.map(DetailedSong::asMediaItem))
|
||||
binder?.player?.enqueue(songs!!.map(Song::asMediaItem))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ fun ArtistLocalSongs(
|
|||
onClick = {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(
|
||||
songs.map(DetailedSong::asMediaItem),
|
||||
songs.map(Song::asMediaItem),
|
||||
index
|
||||
)
|
||||
}
|
||||
|
@ -139,7 +139,7 @@ fun ArtistLocalSongs(
|
|||
if (songs.isNotEmpty()) {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.shuffled().map(DetailedSong::asMediaItem)
|
||||
songs.shuffled().map(Song::asMediaItem)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,8 @@ 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.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
|
||||
|
@ -53,7 +54,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
var songs by persistList<DetailedSong>("${builtInPlaylist.name}/songs")
|
||||
var songs by persistList<Song>("${builtInPlaylist.name}/songs")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
when (builtInPlaylist) {
|
||||
|
@ -66,9 +67,9 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
|
|||
.map { songs ->
|
||||
songs.filter { song ->
|
||||
song.contentLength?.let {
|
||||
binder?.cache?.isCached(song.id, 0, song.contentLength)
|
||||
binder?.cache?.isCached(song.song.id, 0, song.contentLength)
|
||||
} ?: false
|
||||
}
|
||||
}.map(SongWithContentLength::song)
|
||||
}
|
||||
}.collect { songs = it }
|
||||
}
|
||||
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ 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.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
|
||||
|
@ -75,7 +75,7 @@ fun HomeSongs(
|
|||
var sortBy by rememberPreference(songSortByKey, SongSortBy.DateAdded)
|
||||
var sortOrder by rememberPreference(songSortOrderKey, SortOrder.Descending)
|
||||
|
||||
var items by persistList<DetailedSong>("home/songs")
|
||||
var items by persistList<Song>("home/songs")
|
||||
|
||||
LaunchedEffect(sortBy, sortOrder) {
|
||||
Database.songs(sortBy, sortOrder).collect { items = it }
|
||||
|
@ -175,7 +175,7 @@ fun HomeSongs(
|
|||
onClick = {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(
|
||||
items.map(DetailedSong::asMediaItem),
|
||||
items.map(Song::asMediaItem),
|
||||
index
|
||||
)
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ import it.vfsfitvnm.vimusic.Database
|
|||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
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
|
||||
|
@ -89,7 +89,7 @@ fun QuickPicks(
|
|||
val menuState = LocalMenuState.current
|
||||
val windowInsets = LocalPlayerAwareWindowInsets.current
|
||||
|
||||
var trending by persist<DetailedSong?>("home/trending")
|
||||
var trending by persist<Song?>("home/trending")
|
||||
|
||||
var relatedPageResult by persist<Result<Innertube.RelatedPage?>?>(tag = "home/relatedPageResult")
|
||||
|
||||
|
|
|
@ -36,8 +36,8 @@ import it.vfsfitvnm.vimusic.Database
|
|||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
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.transaction
|
||||
|
@ -158,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)
|
||||
}
|
||||
|
@ -266,7 +266,7 @@ fun LocalPlaylistSongs(
|
|||
},
|
||||
onClick = {
|
||||
playlistWithSongs?.songs
|
||||
?.map(DetailedSong::asMediaItem)
|
||||
?.map(Song::asMediaItem)
|
||||
?.let { mediaItems ->
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayAtIndex(mediaItems, index)
|
||||
|
@ -288,7 +288,7 @@ fun LocalPlaylistSongs(
|
|||
if (songs.isNotEmpty()) {
|
||||
binder?.stopRadio()
|
||||
binder?.player?.forcePlayFromBeginning(
|
||||
songs.shuffled().map(DetailedSong::asMediaItem)
|
||||
songs.shuffled().map(Song::asMediaItem)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
@ -32,10 +33,10 @@ 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.rememberSaveable
|
||||
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
|
||||
|
@ -47,6 +48,7 @@ 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
|
||||
|
@ -54,9 +56,9 @@ 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
|
||||
|
@ -75,7 +77,6 @@ import it.vfsfitvnm.vimusic.utils.toast
|
|||
import it.vfsfitvnm.vimusic.utils.verticalFadingEdge
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
@ -87,7 +88,7 @@ fun Lyrics(
|
|||
size: Dp,
|
||||
mediaMetadataProvider: () -> MediaMetadata,
|
||||
durationProvider: () -> Long,
|
||||
onLyricsUpdate: (Boolean, String, String) -> Unit,
|
||||
ensureSongInserted: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
|
@ -106,67 +107,84 @@ fun Lyrics(
|
|||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var lyrics by rememberSaveable {
|
||||
mutableStateOf<String?>(".")
|
||||
var lyrics by remember {
|
||||
mutableStateOf<Lyrics?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(mediaId, isShowingSynchronizedLyrics) {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.synchronizedLyrics(mediaId)
|
||||
} else {
|
||||
Database.lyrics(mediaId)
|
||||
}.distinctUntilChanged().collect { lyrics = it }
|
||||
}
|
||||
val text = if (isShowingSynchronizedLyrics) lyrics?.synced else lyrics?.fixed
|
||||
|
||||
var isError by remember(lyrics) {
|
||||
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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -193,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
|
||||
|
@ -210,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
|
||||
|
@ -226,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -357,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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -273,8 +273,8 @@ fun Queue(
|
|||
player.play()
|
||||
}
|
||||
} else {
|
||||
player.playWhenReady = true
|
||||
player.seekToDefaultPosition(window.firstPeriodIndex)
|
||||
player.playWhenReady = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -25,7 +25,6 @@ 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 androidx.media3.common.Player
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import androidx.media3.datasource.cache.CacheSpan
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
|
@ -37,7 +36,6 @@ import it.vfsfitvnm.vimusic.models.Format
|
|||
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.DisposableListener
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import kotlin.math.roundToInt
|
||||
|
@ -72,40 +70,31 @@ fun StatsForNerds(
|
|||
}
|
||||
|
||||
LaunchedEffect(mediaId) {
|
||||
Database.format(mediaId).distinctUntilChanged().collectLatest {
|
||||
if (it?.itag == null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
delay(3000)
|
||||
Innertube.player(PlayerBody(videoId = mediaId))?.onSuccess { response ->
|
||||
response.streamingData?.highestQualityFormat?.let { format ->
|
||||
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
|
||||
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 = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var volume by remember {
|
||||
mutableStateOf(binder.player.volume)
|
||||
}
|
||||
|
||||
binder.player.DisposableListener {
|
||||
object : Player.Listener {
|
||||
override fun onVolumeChanged(newVolume: Float) {
|
||||
volume = newVolume
|
||||
format = currentFormat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -120,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)
|
||||
|
@ -158,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(
|
||||
|
@ -177,46 +159,48 @@ 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)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -32,7 +32,6 @@ 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
|
||||
|
@ -143,27 +142,7 @@ fun Thumbnail(
|
|||
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 == currentWindow.mediaItem.mediaId) {
|
||||
Database.insert(currentWindow.mediaItem) { song ->
|
||||
song.copy(synchronizedLyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Database.updateLyrics(mediaId, lyrics) == 0) {
|
||||
if (mediaId == currentWindow.mediaItem.mediaId) {
|
||||
Database.insert(currentWindow.mediaItem) { song ->
|
||||
song.copy(lyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ensureSongInserted = { Database.insert(currentWindow.mediaItem) },
|
||||
size = thumbnailSizeDp,
|
||||
mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata,
|
||||
durationProvider = player::getDuration,
|
||||
|
|
|
@ -27,7 +27,7 @@ import it.vfsfitvnm.innertube.models.NavigationEndpoint
|
|||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
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
|
||||
|
@ -54,7 +54,7 @@ fun LocalSongSearch(
|
|||
val binder = LocalPlayerServiceBinder.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
var items by persistList<DetailedSong>("search/local/songs")
|
||||
var items by persistList<Song>("search/local/songs")
|
||||
|
||||
LaunchedEffect(textFieldValue.text) {
|
||||
if (textFieldValue.text.length > 1) {
|
||||
|
@ -105,7 +105,7 @@ fun LocalSongSearch(
|
|||
|
||||
items(
|
||||
items = items,
|
||||
key = DetailedSong::id,
|
||||
key = Song::id,
|
||||
) { song ->
|
||||
SongItem(
|
||||
song = song,
|
||||
|
|
|
@ -102,6 +102,7 @@ fun OnlineSearch(
|
|||
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?"
|
||||
).any(textFieldValue.text::startsWith)
|
||||
|
|
|
@ -141,7 +141,13 @@ fun DatabaseSettings() {
|
|||
text = "Import the database from the external storage",
|
||||
onClick = {
|
||||
try {
|
||||
restoreLauncher.launch(arrayOf("application/vnd.sqlite3"))
|
||||
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")
|
||||
}
|
||||
|
|
|
@ -100,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
|
||||
|
|
|
@ -7,11 +7,11 @@ import androidx.core.net.toUri
|
|||
import androidx.core.os.bundleOf
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.innertube.Innertube
|
||||
import it.vfsfitvnm.innertube.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.innertube.requests.playlistPage
|
||||
import it.vfsfitvnm.innertube.utils.plus
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
|
||||
val Innertube.SongItem.asMediaItem: MediaItem
|
||||
get() = MediaItem.Builder()
|
||||
|
@ -26,7 +26,6 @@ val Innertube.SongItem.asMediaItem: MediaItem
|
|||
.setArtworkUri(thumbnail?.url?.toUri())
|
||||
.setExtras(
|
||||
bundleOf(
|
||||
"videoId" to key,
|
||||
"albumId" to album?.endpoint?.browseId,
|
||||
"durationText" to durationText,
|
||||
"artistNames" to authors?.filter { it.endpoint != null }?.mapNotNull { it.name },
|
||||
|
@ -49,7 +48,6 @@ val Innertube.VideoItem.asMediaItem: MediaItem
|
|||
.setArtworkUri(thumbnail?.url?.toUri())
|
||||
.setExtras(
|
||||
bundleOf(
|
||||
"videoId" to key,
|
||||
"durationText" to durationText,
|
||||
"artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.mapNotNull { it.name } else null,
|
||||
"artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } else null,
|
||||
|
@ -59,7 +57,7 @@ val Innertube.VideoItem.asMediaItem: MediaItem
|
|||
)
|
||||
.build()
|
||||
|
||||
val DetailedSong.asMediaItem: MediaItem
|
||||
val Song.asMediaItem: MediaItem
|
||||
get() = MediaItem.Builder()
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
|
@ -68,10 +66,6 @@ val DetailedSong.asMediaItem: MediaItem
|
|||
.setArtworkUri(thumbnailUrl?.toUri())
|
||||
.setExtras(
|
||||
bundleOf(
|
||||
"videoId" to id,
|
||||
"albumId" to albumId,
|
||||
"artistNames" to artists?.map { it.name },
|
||||
"artistIds" to artists?.map { it.id },
|
||||
"durationText" to durationText
|
||||
)
|
||||
)
|
||||
|
|
1
fastlane/metadata/android/en-US/changelogs/20.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/20.txt
Normal file
|
@ -0,0 +1 @@
|
|||
* Minor fixes and improvements
|
|
@ -34,7 +34,7 @@ dependencyResolutionManagement {
|
|||
library("room", "androidx.room", "room-ktx").versionRef("room")
|
||||
library("room-compiler", "androidx.room", "room-compiler").versionRef("room")
|
||||
|
||||
version("media3", "1.0.0-beta02")
|
||||
version("media3", "1.0.0-beta03")
|
||||
library("exoplayer", "androidx.media3", "media3-exoplayer").versionRef("media3")
|
||||
|
||||
version("ktor", "2.1.2")
|
||||
|
|
Loading…
Add table
Reference in a new issue