Compare commits

..

No commits in common. "master" and "v0.5.3" have entirely different histories.

35 changed files with 448 additions and 1102 deletions

View file

@ -1,12 +1,14 @@
name: 🐛 Bug report
description: Something isn't working, uh?
name: Bug report
description: Create a bug report to help us improve
labels: [bug]
body:
- type: markdown
attributes:
value: |
## ⚠️ 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.
**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.**
- type: textarea
id: reproduce-steps
@ -63,7 +65,7 @@ body:
attributes:
label: ViMusic version
placeholder: |
Example: "0.5.4"
Example: "0.1.2"
validations:
required: true

View file

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

View file

@ -0,0 +1,34 @@
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,6 +1,10 @@
name: CI
on: push
on:
push:
branches: ["master"]
pull_request:
branches: ["master"]
jobs:
build:

View file

@ -11,8 +11,8 @@ android {
applicationId = "it.vfsfitvnm.vimusic"
minSdk = 21
targetSdk = 33
versionCode = 20
versionName = "0.5.4"
versionCode = 19
versionName = "0.5.3"
}
splits {
@ -88,6 +88,7 @@ dependencies {
implementation(libs.room)
kapt(libs.room.compiler)
annotationProcessor(libs.room.compiler)
implementation(projects.innertube)
implementation(projects.kugou)

View file

@ -1,672 +0,0 @@
{
"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

@ -38,11 +38,10 @@ 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.SongWithContentLength
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.models.DetailedSongWithContentLength
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
@ -63,34 +62,34 @@ interface Database {
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID ASC")
@RewriteQueriesToDropUnusedColumns
fun songsByRowIdAsc(): Flow<List<Song>>
fun songsByRowIdAsc(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID DESC")
@RewriteQueriesToDropUnusedColumns
fun songsByRowIdDesc(): Flow<List<Song>>
fun songsByRowIdDesc(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title ASC")
@RewriteQueriesToDropUnusedColumns
fun songsByTitleAsc(): Flow<List<Song>>
fun songsByTitleAsc(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title DESC")
@RewriteQueriesToDropUnusedColumns
fun songsByTitleDesc(): Flow<List<Song>>
fun songsByTitleDesc(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs ASC")
@RewriteQueriesToDropUnusedColumns
fun songsByPlayTimeAsc(): Flow<List<Song>>
fun songsByPlayTimeAsc(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs DESC")
@RewriteQueriesToDropUnusedColumns
fun songsByPlayTimeDesc(): Flow<List<Song>>
fun songsByPlayTimeDesc(): Flow<List<DetailedSong>>
fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow<List<Song>> {
fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow<List<DetailedSong>> {
return when (sortBy) {
SongSortBy.PlayTime -> when (sortOrder) {
SortOrder.Ascending -> songsByPlayTimeAsc()
@ -110,7 +109,7 @@ interface Database {
@Transaction
@Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC")
@RewriteQueriesToDropUnusedColumns
fun favorites(): Flow<List<Song>>
fun favorites(): Flow<List<DetailedSong>>
@Query("SELECT * FROM QueuedMediaItem")
fun queue(): List<QueuedMediaItem>
@ -139,8 +138,17 @@ interface Database {
@Query("UPDATE Song SET durationText = :durationText WHERE id = :songId")
fun updateDurationText(songId: String, durationText: String): Int
@Query("SELECT * FROM Lyrics WHERE songId = :songId")
fun lyrics(songId: String): Flow<Lyrics?>
@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 Artist WHERE id = :id")
fun artist(id: String): Flow<Artist?>
@ -179,7 +187,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<Song>>
fun albumSongs(albumId: String): Flow<List<DetailedSong>>
@Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title ASC")
fun albumsByTitleAsc(): Flow<List<Album>>
@ -273,14 +281,15 @@ 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<Song>>
fun artistSongs(artistId: String): Flow<List<DetailedSong>>
@Query("SELECT * FROM Format WHERE songId = :songId")
fun format(songId: String): Flow<Format?>
@Transaction
@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("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("""
UPDATE SongPlaylistMap SET position =
@ -303,18 +312,12 @@ 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<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>
fun search(query: String): Flow<List<DetailedSong>>
@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<Song?>
fun trending(now: Long = System.currentTimeMillis()): Flow<DetailedSong?>
@Query("SELECT COUNT (*) FROM Event")
fun eventsCount(): Flow<Int>
@ -403,9 +406,6 @@ interface Database {
@Update
fun update(playlist: Playlist)
@Upsert
fun upsert(lyrics: Lyrics)
@Upsert
fun upsert(album: Album, songAlbumMaps: List<SongAlbumMap>)
@ -445,12 +445,11 @@ interface Database {
QueuedMediaItem::class,
Format::class,
Event::class,
Lyrics::class,
],
views = [
SortedSongPlaylistMap::class
],
version = 23,
version = 22,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
@ -488,8 +487,7 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
.addMigrations(
From8To9Migration(),
From10To11Migration(),
From14To15Migration(),
From22To23Migration()
From14To15Migration()
)
.build()
}
@ -616,27 +614,6 @@ 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

View file

@ -0,0 +1,47 @@
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

@ -0,0 +1,23 @@
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

@ -1,23 +0,0 @@
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<Song>
val songs: List<DetailedSong>
)

View file

@ -12,22 +12,11 @@ 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

@ -1,10 +0,0 @@
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

@ -20,9 +20,8 @@ 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
@ -37,7 +36,7 @@ import kotlinx.coroutines.withContext
class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private var lastSongs = emptyList<Song>()
private var lastSongs = emptyList<DetailedSong>()
private var bound = false
@ -188,7 +187,7 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
BrowserMediaItem.FLAG_PLAYABLE
)
private val Song.asBrowserMediaItem
private val DetailedSong.asBrowserMediaItem
inline get() = BrowserMediaItem(
BrowserMediaDescription.Builder()
.setMediaId(MediaId.forSong(id))
@ -255,10 +254,9 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
.first()
.filter { song ->
song.contentLength?.let {
cache.isCached(song.song.id, 0, it)
cache.isCached(song.id, 0, song.contentLength)
} ?: false
}
.map(SongWithContentLength::song)
.shuffled()
MediaId.playlists -> data
@ -275,7 +273,7 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection {
?.first()
else -> emptyList()
}?.map(Song::asMediaItem) ?: return@launch
}?.map(DetailedSong::asMediaItem) ?: return@launch
withContext(Dispatchers.Main) {
player.forcePlayAtIndex(mediaItems, index.coerceIn(0, mediaItems.size))

View file

@ -21,7 +21,6 @@ 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
@ -113,7 +112,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.runBlocking
@ -129,13 +130,10 @@ 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_SKIP_TO_QUEUE_ITEM
or PlaybackState.ACTION_PLAY_PAUSE
or PlaybackState.ACTION_SEEK_TO
or PlaybackState.ACTION_REWIND
)
private val metadataBuilder = MediaMetadata.Builder()
@ -152,6 +150,7 @@ 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
@ -159,8 +158,6 @@ 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
@ -191,6 +188,7 @@ 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)
@ -286,8 +284,6 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
mediaSession.release()
cache.release()
loudnessEnhancer?.release()
super.onDestroy()
}
@ -384,7 +380,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
.setSubtitle(mediaItem.mediaMetadata.artist)
.setIconUri(mediaItem.mediaMetadata.artworkUri)
.build(),
(index + startIndex).toLong()
index.toLong()
)
}
)
@ -460,28 +456,30 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
}
private fun maybeNormalizeVolume() {
if (!preferences.getBoolean(volumeNormalizationKey, false)) {
loudnessEnhancer?.enabled = false
loudnessEnhancer?.release()
loudnessEnhancer = null
if (!isVolumeNormalizationEnabled) {
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().collectLatest { loudnessDb ->
try {
loudnessEnhancer?.setTargetGain(-((loudnessDb ?: 0f) * 100).toInt() + 500)
loudnessEnhancer?.enabled = true
} catch (_: Exception) { }
}
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
}
}
}
}
@ -624,7 +622,11 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
persistentQueueKey -> isPersistentQueueEnabled =
sharedPreferences.getBoolean(key, isPersistentQueueEnabled)
volumeNormalizationKey -> maybeNormalizeVolume()
volumeNormalizationKey -> {
isVolumeNormalizationEnabled =
sharedPreferences.getBoolean(key, isVolumeNormalizationEnabled)
maybeNormalizeVolume()
}
resumePlaybackWhenDeviceConnectedKey -> maybeResumePlaybackWhenDeviceConnected()
@ -954,12 +956,9 @@ 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() = runCatching(player::forceSeekToPrevious).let { }
override fun onSkipToNext() = runCatching(player::forceSeekToNext).let { }
override fun onSkipToPrevious() = player.forceSeekToPrevious()
override fun onSkipToNext() = player.forceSeekToNext()
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() {

View file

@ -1,6 +1,5 @@
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
@ -17,12 +16,10 @@ 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

@ -25,7 +25,6 @@ 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
@ -49,7 +48,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.Info
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
@ -70,16 +69,15 @@ 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: Song,
song: DetailedSong,
modifier: Modifier = Modifier
) {
val binder = LocalPlayerServiceBinder.current
@ -117,7 +115,7 @@ fun InPlaylistMediaItemMenu(
onDismiss: () -> Unit,
playlistId: Long,
positionInPlaylist: Int,
song: Song,
song: DetailedSong,
modifier: Modifier = Modifier
) {
NonQueuedMediaItemMenu(
@ -278,43 +276,15 @@ fun MediaItemMenu(
mutableStateOf(0.dp)
}
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 }
}
}
val likedAt by remember(mediaItem.mediaId) {
Database.likedAt(mediaItem.mediaId).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
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)
@ -401,8 +371,7 @@ 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,
@ -632,10 +601,7 @@ 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()
)
@ -653,9 +619,7 @@ 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)
)
@ -664,7 +628,7 @@ fun MediaItemMenu(
}
onGoToAlbum?.let { onGoToAlbum ->
albumInfo?.let { (albumId) ->
mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId ->
MenuEntry(
icon = R.drawable.disc,
text = "Go to album",
@ -677,16 +641,25 @@ fun MediaItemMenu(
}
onGoToArtist?.let { onGoToArtist ->
artistsInfo?.forEach { (authorId, authorName) ->
MenuEntry(
icon = R.drawable.person,
text = "More from $authorName",
onClick = {
onDismiss()
onGoToArtist(authorId)
}
)
}
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)
}
)
}
}
}
}
}
onRemoveFromQueue?.let { onRemoveFromQueue ->

View file

@ -19,6 +19,7 @@ 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 +28,6 @@ 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: Song,
song: DetailedSong,
thumbnailSizePx: Int,
thumbnailSizeDp: Dp,
modifier: Modifier = Modifier,

View file

@ -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.Song
import it.vfsfitvnm.vimusic.models.DetailedSong
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<Song>("album/$browseId/songs")
var songs by persistList<DetailedSong>("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(Song::asMediaItem))
binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem))
}
)
}
@ -133,7 +133,7 @@ fun AlbumSongs(
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(
songs.map(Song::asMediaItem),
songs.map(DetailedSong::asMediaItem),
index
)
}
@ -162,7 +162,7 @@ fun AlbumSongs(
if (songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(Song::asMediaItem)
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
}

View file

@ -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.Song
import it.vfsfitvnm.vimusic.models.DetailedSong
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<Song>?>("artist/$browseId/localSongs")
var songs by persist<List<DetailedSong>?>("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(Song::asMediaItem))
binder?.player?.enqueue(songs!!.map(DetailedSong::asMediaItem))
}
)
}
@ -115,7 +115,7 @@ fun ArtistLocalSongs(
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(
songs.map(Song::asMediaItem),
songs.map(DetailedSong::asMediaItem),
index
)
}
@ -139,7 +139,7 @@ fun ArtistLocalSongs(
if (songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(Song::asMediaItem)
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
}

View file

@ -26,8 +26,7 @@ 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.Song
import it.vfsfitvnm.vimusic.models.SongWithContentLength
import it.vfsfitvnm.vimusic.models.DetailedSong
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 +53,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
val binder = LocalPlayerServiceBinder.current
val menuState = LocalMenuState.current
var songs by persistList<Song>("${builtInPlaylist.name}/songs")
var songs by persistList<DetailedSong>("${builtInPlaylist.name}/songs")
LaunchedEffect(Unit) {
when (builtInPlaylist) {
@ -67,9 +66,9 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
.map { songs ->
songs.filter { song ->
song.contentLength?.let {
binder?.cache?.isCached(song.song.id, 0, song.contentLength)
binder?.cache?.isCached(song.id, 0, song.contentLength)
} ?: false
}.map(SongWithContentLength::song)
}
}
}.collect { songs = it }
}
@ -104,7 +103,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
text = "Enqueue",
enabled = songs.isNotEmpty(),
onClick = {
binder?.player?.enqueue(songs.map(Song::asMediaItem))
binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem))
}
)
@ -144,7 +143,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(
songs.map(Song::asMediaItem),
songs.map(DetailedSong::asMediaItem),
index
)
}
@ -161,7 +160,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
if (songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(Song::asMediaItem)
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
}

View file

@ -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.Song
import it.vfsfitvnm.vimusic.models.DetailedSong
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<Song>("home/songs")
var items by persistList<DetailedSong>("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(Song::asMediaItem),
items.map(DetailedSong::asMediaItem),
index
)
}

View file

@ -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.Song
import it.vfsfitvnm.vimusic.models.DetailedSong
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<Song?>("home/trending")
var trending by persist<DetailedSong?>("home/trending")
var relatedPageResult by persist<Result<Innertube.RelatedPage?>?>(tag = "home/relatedPageResult")

View file

@ -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(Song::asMediaItem)
?.map(DetailedSong::asMediaItem)
?.let { mediaItems ->
binder?.player?.enqueue(mediaItems)
}
@ -266,7 +266,7 @@ fun LocalPlaylistSongs(
},
onClick = {
playlistWithSongs?.songs
?.map(Song::asMediaItem)
?.map(DetailedSong::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(Song::asMediaItem)
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
}

View file

@ -14,7 +14,6 @@ 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
@ -33,10 +32,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
@ -48,7 +47,6 @@ 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
@ -56,9 +54,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
@ -77,6 +75,7 @@ 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
@ -88,7 +87,7 @@ fun Lyrics(
size: Dp,
mediaMetadataProvider: () -> MediaMetadata,
durationProvider: () -> Long,
ensureSongInserted: () -> Unit,
onLyricsUpdate: (Boolean, String, String) -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
@ -107,84 +106,67 @@ fun Lyrics(
mutableStateOf(false)
}
var lyrics by remember {
mutableStateOf<Lyrics?>(null)
}
val text = if (isShowingSynchronizedLyrics) lyrics?.synced else lyrics?.fixed
var isError by remember(mediaId, isShowingSynchronizedLyrics) {
mutableStateOf(false)
var lyrics by rememberSaveable {
mutableStateOf<String?>(".")
}
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) {
Database.synchronizedLyrics(mediaId)
} else {
Database.lyrics(mediaId)
}.distinctUntilChanged().collect { lyrics = it }
}
while (duration == C.TIME_UNSET) {
delay(100)
duration = withContext(Dispatchers.Main) {
durationProvider()
}
}
var isError by remember(lyrics) {
mutableStateOf(false)
}
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
LaunchedEffect(lyrics == null) {
if (lyrics != null) return@LaunchedEffect
if (isShowingSynchronizedLyrics) {
val mediaMetadata = mediaMetadataProvider()
var 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
)?.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 = text ?: "",
initialTextInput = lyrics ?: "",
singleLine = false,
maxLines = 10,
isTextInputValid = { true },
onDismiss = { isEditing = false },
onDone = {
query {
ensureSongInserted()
Database.upsert(
Lyrics(
songId = mediaId,
fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else it,
synced = if (isShowingSynchronizedLyrics) it else lyrics?.synced,
)
)
if (isShowingSynchronizedLyrics) {
Database.updateSynchronizedLyrics(mediaId, it)
} else {
Database.updateLyrics(mediaId, it)
}
}
}
)
@ -211,7 +193,7 @@ fun Lyrics(
.background(Color.Black.copy(0.8f))
) {
AnimatedVisibility(
visible = isError && text == null,
visible = isError && lyrics == null,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
@ -228,7 +210,7 @@ fun Lyrics(
}
AnimatedVisibility(
visible = text?.let(String::isEmpty) ?: false,
visible = lyrics?.let(String::isEmpty) ?: false,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
@ -244,78 +226,72 @@ fun Lyrics(
)
}
if (text?.isNotEmpty() == true) {
if (isShowingSynchronizedLyrics) {
val density = LocalDensity.current
val player = LocalPlayerServiceBinder.current?.player
?: return@AnimatedVisibility
lyrics?.let { lyrics ->
if (lyrics.isNotEmpty() && lyrics != ".") {
if (isShowingSynchronizedLyrics) {
val density = LocalDensity.current
val player = LocalPlayerServiceBinder.current?.player
?: return@AnimatedVisibility
val synchronizedLyrics = remember(text) {
SynchronizedLyrics(KuGou.Lyrics(text).sentences) {
player.currentPosition + 50
val synchronizedLyrics = remember(lyrics) {
SynchronizedLyrics(KuGou.Lyrics(lyrics).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
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)
)
}
}
} 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 (text == null && !isError) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.shimmer()
) {
if (lyrics == null && !isError) {
ShimmerHost(horizontalAlignment = Alignment.CenterHorizontally) {
repeat(4) {
TextPlaceholder(
color = colorPalette.onOverlayShimmer,
modifier = Modifier
.alpha(1f - it * 0.2f)
)
TextPlaceholder(color = colorPalette.onOverlayShimmer)
}
}
}
@ -381,13 +357,11 @@ fun Lyrics(
onClick = {
menuState.hide()
query {
Database.upsert(
Lyrics(
songId = mediaId,
fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else null,
synced = if (isShowingSynchronizedLyrics) null else lyrics?.synced,
)
)
if (isShowingSynchronizedLyrics) {
Database.updateSynchronizedLyrics(mediaId, null)
} else {
Database.updateLyrics(mediaId, null)
}
}
}
)

View file

@ -273,8 +273,8 @@ fun Queue(
player.play()
}
} else {
player.seekToDefaultPosition(window.firstPeriodIndex)
player.playWhenReady = true
player.seekToDefaultPosition(window.firstPeriodIndex)
}
}
)

View file

@ -25,6 +25,7 @@ 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
@ -36,6 +37,7 @@ 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
@ -70,31 +72,40 @@ fun StatsForNerds(
}
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
)
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
)
}
)
}
}
}
} else {
format = currentFormat
format = it
}
}
}
var volume by remember {
mutableStateOf(binder.player.volume)
}
binder.player.DisposableListener {
object : Player.Listener {
override fun onVolumeChanged(newVolume: Float) {
volume = newVolume
}
}
}
@ -109,8 +120,11 @@ 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)
@ -144,7 +158,11 @@ fun StatsForNerds(
style = typography.xs.medium.color(colorPalette.onOverlay)
)
BasicText(
text = "Itag",
text = "Volume",
style = typography.xs.medium.color(colorPalette.onOverlay)
)
BasicText(
text = "Loudness",
style = typography.xs.medium.color(colorPalette.onOverlay)
)
BasicText(
@ -159,48 +177,46 @@ 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 = format?.itag?.toString() ?: "Unknown",
maxLines = 1,
text = "${volume.times(100).roundToInt()}%",
style = typography.xs.medium.color(colorPalette.onOverlay)
)
BasicText(
text = format?.bitrate?.let { "${it / 1000} kbps" } ?: "Unknown",
maxLines = 1,
text = format?.loudnessDb?.let { loudnessDb ->
"%.2f dB".format(loudnessDb)
} ?: "Unknown",
style = typography.xs.medium.color(colorPalette.onOverlay)
)
BasicText(
text = format?.contentLength
?.let { Formatter.formatShortFileSize(context, it) } ?: "Unknown",
maxLines = 1,
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",
style = typography.xs.medium.color(colorPalette.onOverlay)
)
BasicText(
text = buildString {
append(Formatter.formatShortFileSize(context, cachedBytes))
format?.contentLength?.let {
append(" (${(cachedBytes.toFloat() / it * 100).roundToInt()}%)")
format?.contentLength?.let { contentLength ->
append(" (${(cachedBytes.toFloat() / contentLength * 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)
)
}

View file

@ -32,6 +32,7 @@ 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
@ -142,7 +143,27 @@ fun Thumbnail(
mediaId = currentWindow.mediaItem.mediaId,
isDisplayed = isShowingLyrics && error == null,
onDismiss = { onShowLyrics(false) },
ensureSongInserted = { Database.insert(currentWindow.mediaItem) },
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)
}
}
}
}
}
},
size = thumbnailSizeDp,
mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata,
durationProvider = player::getDuration,

View file

@ -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.Song
import it.vfsfitvnm.vimusic.models.DetailedSong
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<Song>("search/local/songs")
var items by persistList<DetailedSong>("search/local/songs")
LaunchedEffect(textFieldValue.text) {
if (textFieldValue.text.length > 1) {
@ -105,7 +105,7 @@ fun LocalSongSearch(
items(
items = items,
key = Song::id,
key = DetailedSong::id,
) { song ->
SongItem(
song = song,

View file

@ -102,7 +102,6 @@ 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)

View file

@ -141,13 +141,7 @@ fun DatabaseSettings() {
text = "Import the database from the external storage",
onClick = {
try {
restoreLauncher.launch(
arrayOf(
"application/vnd.sqlite3",
"application/x-sqlite3",
"application/octet-stream"
)
)
restoreLauncher.launch(arrayOf("application/vnd.sqlite3"))
} catch (e: ActivityNotFoundException) {
context.toast("Couldn't find an application to open documents")
}

View file

@ -100,7 +100,7 @@ fun PlayerSettings() {
SwitchSettingEntry(
title = "Loudness normalization",
text = "Adjust the volume to a fixed level",
text = "Lower the volume to a standard level",
isChecked = volumeNormalization,
onCheckedChange = {
volumeNormalization = it

View file

@ -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,6 +26,7 @@ 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 },
@ -48,6 +49,7 @@ 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,
@ -57,7 +59,7 @@ val Innertube.VideoItem.asMediaItem: MediaItem
)
.build()
val Song.asMediaItem: MediaItem
val DetailedSong.asMediaItem: MediaItem
get() = MediaItem.Builder()
.setMediaMetadata(
MediaMetadata.Builder()
@ -66,6 +68,10 @@ val Song.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
)
)

View file

@ -1 +0,0 @@
* Minor fixes and improvements

View file

@ -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-beta03")
version("media3", "1.0.0-beta02")
library("exoplayer", "androidx.media3", "media3-exoplayer").versionRef("media3")
version("ktor", "2.1.2")